From b6fc327e46b4d1bd92271d726139bf61296f4fcd Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Mon, 23 Feb 2026 17:47:03 -0500 Subject: [PATCH 01/18] impl --- .../slack/api/util/annotation/Required.java | 11 +++ .../api/util/json/RequiredAdapterFactory.java | 79 +++++++++++++++++++ .../test_locally/util/JSONUtilityTest.java | 25 ++++++ 3 files changed, 115 insertions(+) create mode 100644 slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java create mode 100644 slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java diff --git a/slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java b/slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java new file mode 100644 index 000000000..4e4c9b724 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java @@ -0,0 +1,11 @@ +package com.slack.api.util.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Required { +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java new file mode 100644 index 000000000..a1a723d55 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java @@ -0,0 +1,79 @@ +package com.slack.api.util.json; + +import com.google.gson.JsonParseException; +import com.google.gson.Gson; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.TypeAdapter; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.slack.api.util.annotation.Required; + +import java.util.Arrays; +import java.io.IOException; + +/** + * Adapter factory for processing objects annotated with our custom annotation {@link Required}. + *

+ * For deserialization (e.g. converting JSON --> POJO), it ensures that any fields marked as {@link Required} are + * present in the constructed object and nonnull. + *

+ * For serialization (e.g. converting POJO --> JSON), it ensures that any fields marked as {@link Required} are + * non-null and written to the JSON string. + */ +public class RequiredAdapterFactory implements TypeAdapterFactory { + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + TypeAdapter delegate = gson.getDelegateAdapter(this, type); + + // Check if there are any fields that have the @Required annotation. If there aren't, + // we can directly delegate to the underlying type factory + boolean hasRequiredAnnotation = Arrays.stream(type.getRawType().getDeclaredFields()) + .anyMatch(field -> field.isAnnotationPresent(Required.class)); + + if (!hasRequiredAnnotation) { + return delegate; + } + + return new TypeAdapter() { + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value != null) { + ensureFieldValidity(value); + } + delegate.write(out, value); + } + + @Override + public T read(JsonReader in) throws IOException { + T result = delegate.read(in); + if (result == null) { + return null; + } + + ensureFieldValidity(result); + return result; + } + }; + } + + private void ensureFieldValidity(T obj) { + Arrays.asList(obj.getClass().getDeclaredFields()).forEach(field -> { + if (field.isAnnotationPresent(Required.class)) { + // Primitives get initialized by the JVM, so if the annotation was used + // on any primitives, it doesn't really make sense to check this + if (!field.getType().isPrimitive()) { + field.setAccessible(true); + try { + if (field.get(obj) == null) { + throw new JsonParseException("Required field '" + field.getName() + "' is missing in " + + obj.getClass().getSimpleName()); + } + } catch (IllegalAccessException e) { + throw new JsonParseException("Cannot access field: " + field.getName(), e); + } + } + } + }); + } +} diff --git a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java index c2139faee..5da5d3845 100644 --- a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java +++ b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java @@ -11,7 +11,10 @@ import com.slack.api.model.block.element.ImageElement; import com.slack.api.model.block.element.OverflowMenuElement; import com.slack.api.model.event.FunctionExecutedEvent; +import com.slack.api.util.annotation.Required; import com.slack.api.util.json.*; +import lombok.Builder; +import lombok.Data; import org.junit.Test; import test_locally.unit.GsonFactory; @@ -25,6 +28,8 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.fail; public class JSONUtilityTest { @@ -153,4 +158,24 @@ public void testGsonFunctionExecutedEventInputValueFactory() { parsed = f.deserialize(json, FunctionExecutedEvent.InputValue.class, context); assertThat(parsed.asStringArray(), is(Arrays.asList("C111", "C222"))); } + + @Test + public void testRequiredAdapterFactory() { + Gson gson = new GsonBuilder().registerTypeAdapterFactory(new RequiredAdapterFactory()).create(); + + // Serialization + TestClassWithRequired instance = TestClassWithRequired.builder().build(); + assertThrows(JsonParseException.class, () -> gson.toJson(instance)); + + // Deserialization + String json = "{\"name\": \"Hello\"}"; + assertThrows(JsonParseException.class, () -> gson.fromJson(json, TestClassWithRequired.class)); + } + + @Data + @Builder + private static class TestClassWithRequired { + @Required private Integer id; + private String name; + } } From 61394abe67ccbae3cbc02b11b3ee08313e4c0f4e Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Mon, 23 Feb 2026 18:00:54 -0500 Subject: [PATCH 02/18] update javadocs --- .../com/slack/api/util/json/RequiredAdapterFactory.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java index a1a723d55..d5507a7f3 100644 --- a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java +++ b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java @@ -13,13 +13,15 @@ import java.io.IOException; /** - * Adapter factory for processing objects annotated with our custom annotation {@link Required}. + * Adapter factory for processing objects annotated with {@link Required}. This annotation signals what properties + * of a model object are required, and thus should be expected to be initialized and non-null on every instance of + * said object. *

* For deserialization (e.g. converting JSON --> POJO), it ensures that any fields marked as {@link Required} are * present in the constructed object and nonnull. *

- * For serialization (e.g. converting POJO --> JSON), it ensures that any fields marked as {@link Required} are - * non-null and written to the JSON string. + * For serialization (e.g. converting POJO --> JSON), it ensures that any fields marked as {@link Required} are non-null + * in the construct object prior to serialization. */ public class RequiredAdapterFactory implements TypeAdapterFactory { @Override From 00d45fab4cd1e38f135a50766a60429b7f4d0d08 Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Tue, 24 Feb 2026 12:52:14 -0500 Subject: [PATCH 03/18] updates --- .../main/java/com/slack/api/SlackConfig.java | 10 ++++ .../com/slack/api/util/json/GsonFactory.java | 14 +++-- .../api/model/annotation/FieldPredicate.java | 5 ++ .../annotation/IsNotNullFieldPredicate.java | 10 ++++ .../slack/api/model/annotation/Required.java | 25 +++++++++ .../slack/api/util/annotation/Required.java | 11 ---- .../api/util/json/RequiredAdapterFactory.java | 33 +++++------- .../java/test_locally/unit/GsonFactory.java | 12 +++-- .../test_locally/util/JSONUtilityTest.java | 54 ++++++++++++++++--- 9 files changed, 127 insertions(+), 47 deletions(-) create mode 100644 slack-api-model/src/main/java/com/slack/api/model/annotation/FieldPredicate.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/annotation/IsNotNullFieldPredicate.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/annotation/Required.java delete mode 100644 slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java diff --git a/slack-api-client/src/main/java/com/slack/api/SlackConfig.java b/slack-api-client/src/main/java/com/slack/api/SlackConfig.java index a3b0707b2..1c72260c7 100644 --- a/slack-api-client/src/main/java/com/slack/api/SlackConfig.java +++ b/slack-api-client/src/main/java/com/slack/api/SlackConfig.java @@ -62,6 +62,9 @@ public void setFailOnUnknownProperties(boolean failOnUnknownProperties) { throwException(); } + @Override + public void setFailOnRequiredProperties(boolean failOnRequiredProperties) { throwException(); } + @Override public void setPrettyResponseLoggingEnabled(boolean prettyResponseLoggingEnabled) { throwException(); @@ -248,6 +251,13 @@ public void setLibraryMaintainerMode(boolean libraryMaintainerMode) { */ private boolean failOnUnknownProperties = false; + /** + * Makes it so that any fields annotated with {@link com.slack.api.model.annotation.Required} that are missing + * or invalid when deserializing responses from the Slack Web API client will throw an exception. + * By default, this is "off", but can be opted into by setting to true. + */ + private boolean failOnRequiredProperties = false; + /** * Slack Web API client verifies the existence of tokens before sending HTTP requests to Slack servers. */ diff --git a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java index 4468f2df4..595b8d83b 100644 --- a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java +++ b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java @@ -32,7 +32,7 @@ private GsonFactory() { public static Gson createSnakeCase() { GsonBuilder gsonBuilder = new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); - registerTypeAdapters(gsonBuilder, false); + registerTypeAdapters(gsonBuilder, false, false); return gsonBuilder.create(); } @@ -41,9 +41,10 @@ public static Gson createSnakeCase() { */ public static Gson createSnakeCase(SlackConfig config) { boolean failOnUnknownProps = config.isFailOnUnknownProperties(); + boolean failOnRequiredProperties = config.isFailOnRequiredProperties(); GsonBuilder gsonBuilder = new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); - registerTypeAdapters(gsonBuilder, failOnUnknownProps); + registerTypeAdapters(gsonBuilder, failOnUnknownProps, failOnRequiredProperties); if (failOnUnknownProps || config.isLibraryMaintainerMode()) { gsonBuilder = gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); } @@ -58,8 +59,9 @@ public static Gson createSnakeCase(SlackConfig config) { */ public static Gson createCamelCase(SlackConfig config) { boolean failOnUnknownProps = config.isFailOnUnknownProperties(); + boolean failOnRequiredProperties = config.isFailOnRequiredProperties(); GsonBuilder gsonBuilder = new GsonBuilder(); - registerTypeAdapters(gsonBuilder, failOnUnknownProps); + registerTypeAdapters(gsonBuilder, failOnUnknownProps, failOnRequiredProperties); if (failOnUnknownProps || config.isLibraryMaintainerMode()) { gsonBuilder = gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); } @@ -69,7 +71,7 @@ public static Gson createCamelCase(SlackConfig config) { return gsonBuilder.create(); } - public static void registerTypeAdapters(GsonBuilder builder, boolean failOnUnknownProps) { + public static void registerTypeAdapters(GsonBuilder builder, boolean failOnUnknownProps, boolean failOnRequiredProperties) { builder .registerTypeAdapter(Instant.class, new JavaTimeInstantFactory(failOnUnknownProps)) .registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProps)) @@ -86,5 +88,9 @@ public static void registerTypeAdapters(GsonBuilder builder, boolean failOnUnkno .registerTypeAdapter(AppWorkflow.StepInputValueElementDefault.class, new GsonAppWorkflowStepInputValueDefaultFactory(failOnUnknownProps)) .registerTypeAdapter(LogsResponse.DetailsChangedValue.class, new GsonAuditLogsDetailsChangedValueFactory(failOnUnknownProps)) .registerTypeAdapter(LogsResponse.UserIDs.class, new GsonAuditLogsDetailsUserIDsFactory(failOnUnknownProps)); + + if (failOnRequiredProperties) { + builder.registerTypeAdapterFactory(new RequiredAdapterFactory()); + } } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/annotation/FieldPredicate.java b/slack-api-model/src/main/java/com/slack/api/model/annotation/FieldPredicate.java new file mode 100644 index 000000000..d6d7b29cc --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/annotation/FieldPredicate.java @@ -0,0 +1,5 @@ +package com.slack.api.model.annotation; + +public interface FieldPredicate { + boolean test(Object obj); +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/annotation/IsNotNullFieldPredicate.java b/slack-api-model/src/main/java/com/slack/api/model/annotation/IsNotNullFieldPredicate.java new file mode 100644 index 000000000..90e638701 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/annotation/IsNotNullFieldPredicate.java @@ -0,0 +1,10 @@ +package com.slack.api.model.annotation; + +import java.util.Objects; + +public class IsNotNullFieldPredicate implements FieldPredicate { + @Override + public boolean test(Object obj) { + return !Objects.isNull(obj); + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/annotation/Required.java b/slack-api-model/src/main/java/com/slack/api/model/annotation/Required.java new file mode 100644 index 000000000..d741490c9 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/annotation/Required.java @@ -0,0 +1,25 @@ +package com.slack.api.model.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Field-level annotation indicating whether the field is a "required" field or not on the model object. + *

+ * The enforcement of the field's presence in instantiated instances of the model object is accomplished using the + * {@link com.slack.api.util.json.RequiredAdapterFactory} which ensures all fields marked with {@link Required} are + * present during the object deserialization (or serialization) process. Note that the enforcement of this annotation + * is opt-in and defaults to "off". + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Required { + /** + * Optional predicate to evaluate against the field annotated with {@link Required}. By default, all fields + * marked with {@link Required} are checked for null. Primitive field types are initialized by the JVM, and thus + * are never null by default. + */ + Class validator() default IsNotNullFieldPredicate.class; +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java b/slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java deleted file mode 100644 index 4e4c9b724..000000000 --- a/slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.slack.api.util.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.FIELD) -public @interface Required { -} diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java index d5507a7f3..56c98693b 100644 --- a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java +++ b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java @@ -7,8 +7,10 @@ import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; -import com.slack.api.util.annotation.Required; +import com.slack.api.model.annotation.FieldPredicate; +import com.slack.api.model.annotation.Required; +import java.lang.reflect.InvocationTargetException; import java.util.Arrays; import java.io.IOException; @@ -28,15 +30,6 @@ public class RequiredAdapterFactory implements TypeAdapterFactory { public TypeAdapter create(Gson gson, TypeToken type) { TypeAdapter delegate = gson.getDelegateAdapter(this, type); - // Check if there are any fields that have the @Required annotation. If there aren't, - // we can directly delegate to the underlying type factory - boolean hasRequiredAnnotation = Arrays.stream(type.getRawType().getDeclaredFields()) - .anyMatch(field -> field.isAnnotationPresent(Required.class)); - - if (!hasRequiredAnnotation) { - return delegate; - } - return new TypeAdapter() { @Override public void write(JsonWriter out, T value) throws IOException { @@ -62,18 +55,16 @@ public T read(JsonReader in) throws IOException { private void ensureFieldValidity(T obj) { Arrays.asList(obj.getClass().getDeclaredFields()).forEach(field -> { if (field.isAnnotationPresent(Required.class)) { - // Primitives get initialized by the JVM, so if the annotation was used - // on any primitives, it doesn't really make sense to check this - if (!field.getType().isPrimitive()) { - field.setAccessible(true); - try { - if (field.get(obj) == null) { - throw new JsonParseException("Required field '" + field.getName() + "' is missing in " - + obj.getClass().getSimpleName()); - } - } catch (IllegalAccessException e) { - throw new JsonParseException("Cannot access field: " + field.getName(), e); + field.setAccessible(true); + try { + FieldPredicate predicate = field.getAnnotation(Required.class).validator().getDeclaredConstructor().newInstance(); + if (!predicate.test(field.get(obj))) { + throw new JsonParseException("Required field '" + field.getName() + "' failed validation in " + + obj.getClass().getSimpleName() + " using predicate " + predicate.getClass().getSimpleName()); } + } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | + InvocationTargetException e) { + throw new JsonParseException("Cannot parse field: " + field.getName(), e); } } }); diff --git a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java index 409056175..a75c6d371 100644 --- a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java +++ b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java @@ -28,9 +28,13 @@ public static Gson createSnakeCaseWithoutUnknownPropertyDetection(boolean failOn } public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unknownPropertyDetection) { + return getBuilder(failOnUnknownProperties, unknownPropertyDetection).create(); + } + + public static GsonBuilder getBuilder(boolean failOnUnknownProperties, boolean unknownPropertyDetection) { GsonBuilder builder = new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProperties)) + .registerTypeAdapter(File.class, new GsonFileFactory()) .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory(failOnUnknownProperties)) .registerTypeAdapter(BlockElement.class, new GsonBlockElementFactory(failOnUnknownProperties)) .registerTypeAdapter(ContextBlockElement.class, new GsonContextBlockElementFactory(failOnUnknownProperties)) @@ -45,9 +49,9 @@ public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unkn new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProperties)); if (unknownPropertyDetection) { - return builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()).create(); - } else { - return builder.create(); + builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); } + + return builder; } } diff --git a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java index 5da5d3845..b252677f6 100644 --- a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java +++ b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java @@ -1,6 +1,7 @@ package test_locally.util; import com.google.gson.*; +import com.slack.api.model.annotation.FieldPredicate; import com.slack.api.model.block.ContextBlockElement; import com.slack.api.model.block.DividerBlock; import com.slack.api.model.block.LayoutBlock; @@ -11,11 +12,12 @@ import com.slack.api.model.block.element.ImageElement; import com.slack.api.model.block.element.OverflowMenuElement; import com.slack.api.model.event.FunctionExecutedEvent; -import com.slack.api.util.annotation.Required; +import com.slack.api.model.annotation.Required; import com.slack.api.util.json.*; import lombok.Builder; import lombok.Data; import org.junit.Test; +import org.junit.runners.model.TestClass; import test_locally.unit.GsonFactory; import java.lang.reflect.Type; @@ -26,10 +28,10 @@ import static com.slack.api.model.block.element.BlockElements.image; import static com.slack.api.model.block.element.BlockElements.overflowMenu; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; -import static org.junit.Assert.fail; public class JSONUtilityTest { @@ -160,22 +162,60 @@ public void testGsonFunctionExecutedEventInputValueFactory() { } @Test - public void testRequiredAdapterFactory() { - Gson gson = new GsonBuilder().registerTypeAdapterFactory(new RequiredAdapterFactory()).create(); + public void testRequiredAdapterFactory_basicCase() { + Gson gson = GsonFactory.getBuilder(true, true) + .registerTypeAdapterFactory(new RequiredAdapterFactory()).create(); // Serialization - TestClassWithRequired instance = TestClassWithRequired.builder().build(); + TestClassWithRequiredBasic instance = TestClassWithRequiredBasic.builder().build(); assertThrows(JsonParseException.class, () -> gson.toJson(instance)); // Deserialization String json = "{\"name\": \"Hello\"}"; - assertThrows(JsonParseException.class, () -> gson.fromJson(json, TestClassWithRequired.class)); + assertThrows(JsonParseException.class, () -> gson.fromJson(json, TestClassWithRequiredBasic.class)); + } + + @Test + public void testRequiredAdapterFactory_advancedCase() { + Gson gson = GsonFactory.getBuilder(true, true).registerTypeAdapterFactory(new RequiredAdapterFactory()).create(); + + // Serialization + JsonParseException e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().build())); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'id' failed validation in TestClassWithRequiredAdvanced using predicate IntegerGreaterThanZero")); + + e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().id(1).build())); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'name' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyString")); + e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().id(1).name("").build())); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'name' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyString")); } @Data @Builder - private static class TestClassWithRequired { + private static class TestClassWithRequiredBasic { @Required private Integer id; private String name; } + + @Data + @Builder + private static class TestClassWithRequiredAdvanced { + @Required(validator = IntegerGreaterThanZero.class) + private int id; + @Required(validator = NonEmptyString.class) + private String name; + + public static class IntegerGreaterThanZero implements FieldPredicate { + @Override + public boolean test(Object obj) { + return obj instanceof Integer && (int)obj > 0; + } + } + + public static class NonEmptyString implements FieldPredicate { + @Override + public boolean test(Object obj) { + return obj instanceof String && !((String) obj).isEmpty(); + } + } + } } From ead8247bb2f7ab5979de0abbbe2cfc2311876ad4 Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Tue, 24 Feb 2026 12:58:36 -0500 Subject: [PATCH 04/18] update javadocs --- .../slack/api/util/json/RequiredAdapterFactory.java | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java index 56c98693b..c77a258e4 100644 --- a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java +++ b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java @@ -16,14 +16,11 @@ /** * Adapter factory for processing objects annotated with {@link Required}. This annotation signals what properties - * of a model object are required, and thus should be expected to be initialized and non-null on every instance of - * said object. + * of a model object are required, and thus should be expected to be initialized on instantiated instances. For all + * fields on the model objected annotated with {@link Required} applies the {@link FieldPredicate#test(Object)} via the + * specified {@link Required#validator()}. *

- * For deserialization (e.g. converting JSON --> POJO), it ensures that any fields marked as {@link Required} are - * present in the constructed object and nonnull. - *

- * For serialization (e.g. converting POJO --> JSON), it ensures that any fields marked as {@link Required} are non-null - * in the construct object prior to serialization. + * Note that this adapter handles both deserialization (JSON --> POJO) and serialization (POJO --> JSON). */ public class RequiredAdapterFactory implements TypeAdapterFactory { @Override From f04be39774e3801114d137511cfca235876aaac8 Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Tue, 24 Feb 2026 15:39:51 -0500 Subject: [PATCH 05/18] small update --- .../src/test/java/test_locally/unit/GsonFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java index a75c6d371..4138c5b0e 100644 --- a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java +++ b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java @@ -34,7 +34,7 @@ public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unkn public static GsonBuilder getBuilder(boolean failOnUnknownProperties, boolean unknownPropertyDetection) { GsonBuilder builder = new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) - .registerTypeAdapter(File.class, new GsonFileFactory()) + .registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProperties)) .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory(failOnUnknownProperties)) .registerTypeAdapter(BlockElement.class, new GsonBlockElementFactory(failOnUnknownProperties)) .registerTypeAdapter(ContextBlockElement.class, new GsonContextBlockElementFactory(failOnUnknownProperties)) From 8e21407037925186b94a7c31f4514a316770666d Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Tue, 24 Feb 2026 15:45:36 -0500 Subject: [PATCH 06/18] add a couple more test cases --- .../test/java/test_locally/util/JSONUtilityTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java index b252677f6..088a319f3 100644 --- a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java +++ b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java @@ -187,6 +187,16 @@ public void testRequiredAdapterFactory_advancedCase() { assertThat(e.getMessage(), equalToIgnoringCase("Required field 'name' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyString")); e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().id(1).name("").build())); assertThat(e.getMessage(), equalToIgnoringCase("Required field 'name' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyString")); + + // Deserialization + e = assertThrows(JsonParseException.class, () -> gson.fromJson("{\"id\": 0}", TestClassWithRequiredAdvanced.class)); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'id' failed validation in TestClassWithRequiredAdvanced using predicate IntegerGreaterThanZero")); + + e = assertThrows(JsonParseException.class, () -> gson.fromJson("{\"id\": 1}", TestClassWithRequiredAdvanced.class)); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'name' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyString")); + + e = assertThrows(JsonParseException.class, () -> gson.fromJson("{\"id\": 1, \"name\": ''}", TestClassWithRequiredAdvanced.class)); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'name' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyString")); } @Data From df4cda2a28cafff204e72ac2aaac0dc1da5649be Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Tue, 24 Feb 2026 17:24:29 -0500 Subject: [PATCH 07/18] cache FieldPredicate instances upfront --- .../api/util/json/RequiredAdapterFactory.java | 72 +++++++++++++++---- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java index c77a258e4..cb63ea38b 100644 --- a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java +++ b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java @@ -9,9 +9,14 @@ import com.google.gson.stream.JsonWriter; import com.slack.api.model.annotation.FieldPredicate; import com.slack.api.model.annotation.Required; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; -import java.util.Arrays; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.io.IOException; /** @@ -26,12 +31,17 @@ public class RequiredAdapterFactory implements TypeAdapterFactory { @Override public TypeAdapter create(Gson gson, TypeToken type) { TypeAdapter delegate = gson.getDelegateAdapter(this, type); + List entries = buildRequiredFieldEntries(type.getRawType()); + + if (entries.isEmpty()) { + return delegate; + } return new TypeAdapter() { @Override public void write(JsonWriter out, T value) throws IOException { if (value != null) { - ensureFieldValidity(value); + ensureFieldValidity(value, entries); } delegate.write(out, value); } @@ -43,27 +53,59 @@ public T read(JsonReader in) throws IOException { return null; } - ensureFieldValidity(result); + ensureFieldValidity(result, entries); return result; } }; } - private void ensureFieldValidity(T obj) { - Arrays.asList(obj.getClass().getDeclaredFields()).forEach(field -> { - if (field.isAnnotationPresent(Required.class)) { + /** + * Scans the given class for fields annotated with {@link Required}, pre-resolves each field's + * accessibility and {@link FieldPredicate} instance, and returns an immutable list of entries. + * This is called once per type during Gson adapter creation. + */ + private List buildRequiredFieldEntries(Class clazz) { + List entries = new ArrayList<>(); + for (Field field : clazz.getDeclaredFields()) { + Required annotation = field.getAnnotation(Required.class); + if (annotation != null) { field.setAccessible(true); try { - FieldPredicate predicate = field.getAnnotation(Required.class).validator().getDeclaredConstructor().newInstance(); - if (!predicate.test(field.get(obj))) { - throw new JsonParseException("Required field '" + field.getName() + "' failed validation in " - + obj.getClass().getSimpleName() + " using predicate " + predicate.getClass().getSimpleName()); - } - } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | - InvocationTargetException e) { - throw new JsonParseException("Cannot parse field: " + field.getName(), e); + FieldPredicate predicate = annotation.validator().getDeclaredConstructor().newInstance(); + entries.add(new RequiredFieldEntry(field, predicate)); + } catch (NoSuchMethodException | InstantiationException | + IllegalAccessException | InvocationTargetException e) { + throw new JsonParseException( + "Cannot instantiate validator for field: " + field.getName(), e); } } - }); + } + return Collections.unmodifiableList(entries); + } + + private void ensureFieldValidity(T obj, List entries) { + for (RequiredFieldEntry entry : entries) { + try { + Object value = entry.field.get(obj); + if (!entry.predicate.test(value)) { + throw new JsonParseException("Required field '" + entry.field.getName() + + "' failed validation in " + obj.getClass().getSimpleName() + + " using predicate " + entry.predicate.getClass().getSimpleName()); + } + } catch (IllegalAccessException e) { + throw new JsonParseException( + "Cannot access field: " + entry.field.getName(), e); + } + } + } + + /** + * Class holding the accessible {@link Field} handle and the cached instance of {@link FieldPredicate}. + */ + @RequiredArgsConstructor + @EqualsAndHashCode + private static class RequiredFieldEntry { + final Field field; + final FieldPredicate predicate; } } From f40b893545c24113b14b14d9bf49092e0933d01f Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Wed, 25 Feb 2026 16:54:04 -0500 Subject: [PATCH 08/18] address comments --- .../main/java/com/slack/api/SlackConfig.java | 4 +- .../com/slack/api/util/json/GsonFactory.java | 100 ++++++++++-------- slack-api-model/pom.xml | 12 +++ .../api/model/annotation/FieldPredicate.java | 5 - .../annotation/IsNotNullFieldPredicate.java | 10 -- .../slack/api/model/annotation/Required.java | 6 +- .../api/model/predicate/FieldPredicate.java | 11 ++ .../predicate/IsNotNullFieldPredicate.java | 10 ++ ...uiredPropertyDetectionAdapterFactory.java} | 6 +- .../test_locally/util/JSONUtilityTest.java | 90 ++++++++++++++-- 10 files changed, 182 insertions(+), 72 deletions(-) delete mode 100644 slack-api-model/src/main/java/com/slack/api/model/annotation/FieldPredicate.java delete mode 100644 slack-api-model/src/main/java/com/slack/api/model/annotation/IsNotNullFieldPredicate.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/predicate/FieldPredicate.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/predicate/IsNotNullFieldPredicate.java rename slack-api-model/src/main/java/com/slack/api/util/json/{RequiredAdapterFactory.java => RequiredPropertyDetectionAdapterFactory.java} (95%) diff --git a/slack-api-client/src/main/java/com/slack/api/SlackConfig.java b/slack-api-client/src/main/java/com/slack/api/SlackConfig.java index 1c72260c7..ac2a6329e 100644 --- a/slack-api-client/src/main/java/com/slack/api/SlackConfig.java +++ b/slack-api-client/src/main/java/com/slack/api/SlackConfig.java @@ -252,9 +252,7 @@ public void setLibraryMaintainerMode(boolean libraryMaintainerMode) { private boolean failOnUnknownProperties = false; /** - * Makes it so that any fields annotated with {@link com.slack.api.model.annotation.Required} that are missing - * or invalid when deserializing responses from the Slack Web API client will throw an exception. - * By default, this is "off", but can be opted into by setting to true. + * If you would like to detect required properties by throwing exceptions, set this flag as true. */ private boolean failOnRequiredProperties = false; diff --git a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java index 595b8d83b..44ee97009 100644 --- a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java +++ b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java @@ -30,9 +30,8 @@ private GsonFactory() { * Most of the Slack APIs' key naming is snake-cased. */ public static Gson createSnakeCase() { - GsonBuilder gsonBuilder = new GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); - registerTypeAdapters(gsonBuilder, false, false); + GsonBuilder gsonBuilder = getSnakeCaseBuilder(); + configureBuilder(gsonBuilder); return gsonBuilder.create(); } @@ -40,17 +39,8 @@ public static Gson createSnakeCase() { * Most of the Slack APIs' key naming is snake-cased. */ public static Gson createSnakeCase(SlackConfig config) { - boolean failOnUnknownProps = config.isFailOnUnknownProperties(); - boolean failOnRequiredProperties = config.isFailOnRequiredProperties(); - GsonBuilder gsonBuilder = new GsonBuilder() - .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); - registerTypeAdapters(gsonBuilder, failOnUnknownProps, failOnRequiredProperties); - if (failOnUnknownProps || config.isLibraryMaintainerMode()) { - gsonBuilder = gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); - } - if (config.isPrettyResponseLoggingEnabled()) { - gsonBuilder = gsonBuilder.setPrettyPrinting(); - } + GsonBuilder gsonBuilder = getSnakeCaseBuilder(); + configureBuilder(gsonBuilder, config); return gsonBuilder.create(); } @@ -58,39 +48,65 @@ public static Gson createSnakeCase(SlackConfig config) { * Mainly used for SCIM APIs. */ public static Gson createCamelCase(SlackConfig config) { - boolean failOnUnknownProps = config.isFailOnUnknownProperties(); - boolean failOnRequiredProperties = config.isFailOnRequiredProperties(); GsonBuilder gsonBuilder = new GsonBuilder(); - registerTypeAdapters(gsonBuilder, failOnUnknownProps, failOnRequiredProperties); - if (failOnUnknownProps || config.isLibraryMaintainerMode()) { - gsonBuilder = gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); - } - if (config.isPrettyResponseLoggingEnabled()) { - gsonBuilder = gsonBuilder.setPrettyPrinting(); - } + configureBuilder(gsonBuilder, config); return gsonBuilder.create(); } - public static void registerTypeAdapters(GsonBuilder builder, boolean failOnUnknownProps, boolean failOnRequiredProperties) { - builder - .registerTypeAdapter(Instant.class, new JavaTimeInstantFactory(failOnUnknownProps)) - .registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProps)) - .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory(failOnUnknownProps)) - .registerTypeAdapter(TextObject.class, new GsonTextObjectFactory(failOnUnknownProps)) - .registerTypeAdapter(ContextBlockElement.class, new GsonContextBlockElementFactory(failOnUnknownProps)) - .registerTypeAdapter(ContextActionsBlockElement.class, new GsonContextActionsBlockElementFactory(failOnUnknownProps)) - .registerTypeAdapter(BlockElement.class, new GsonBlockElementFactory(failOnUnknownProps)) - .registerTypeAdapter(RichTextElement.class, new GsonRichTextElementFactory(failOnUnknownProps)) - .registerTypeAdapter(FunctionExecutedEvent.InputValue.class, new GsonFunctionExecutedEventInputValueFactory()) - .registerTypeAdapter(Attachment.VideoHtml.class, new GsonMessageAttachmentVideoHtmlFactory(failOnUnknownProps)) - .registerTypeAdapter(MessageChangedEvent.PreviousMessage.class, new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProps)) - .registerTypeAdapter(AppWorkflow.StepInputValue.class, new GsonAppWorkflowStepInputValueFactory(failOnUnknownProps)) - .registerTypeAdapter(AppWorkflow.StepInputValueElementDefault.class, new GsonAppWorkflowStepInputValueDefaultFactory(failOnUnknownProps)) - .registerTypeAdapter(LogsResponse.DetailsChangedValue.class, new GsonAuditLogsDetailsChangedValueFactory(failOnUnknownProps)) - .registerTypeAdapter(LogsResponse.UserIDs.class, new GsonAuditLogsDetailsUserIDsFactory(failOnUnknownProps)); + private static GsonBuilder getSnakeCaseBuilder() { + return new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); + } + + private static void configureBuilder(GsonBuilder builder) { + configureBuilder(builder, false, false, false, false); + } - if (failOnRequiredProperties) { - builder.registerTypeAdapterFactory(new RequiredAdapterFactory()); + private static void configureBuilder(GsonBuilder builder, SlackConfig config) { + configureBuilder( + builder, + config.isFailOnUnknownProperties(), + config.isFailOnRequiredProperties(), + config.isPrettyResponseLoggingEnabled(), + config.isLibraryMaintainerMode() + ); + } + + private static void configureBuilder( + GsonBuilder builder, + boolean failOnUnknownProps, + boolean failOnRequiredProps, + boolean isPrettyResponseLoggingEnabled, + boolean isLibraryMaintainerMode + ) { + if (failOnUnknownProps || isLibraryMaintainerMode) { + builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); + } + if (failOnRequiredProps) { + builder.registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()); + } + if (isPrettyResponseLoggingEnabled) { + builder.setPrettyPrinting(); } + + registerTypeAdapters(builder, failOnUnknownProps); + } + + public static void registerTypeAdapters(GsonBuilder builder, boolean failOnUnknownProps) { + builder + .registerTypeAdapter(Instant.class, new JavaTimeInstantFactory(failOnUnknownProps)) + .registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProps)) + .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory(failOnUnknownProps)) + .registerTypeAdapter(TextObject.class, new GsonTextObjectFactory(failOnUnknownProps)) + .registerTypeAdapter(ContextBlockElement.class, new GsonContextBlockElementFactory(failOnUnknownProps)) + .registerTypeAdapter(ContextActionsBlockElement.class, new GsonContextActionsBlockElementFactory(failOnUnknownProps)) + .registerTypeAdapter(BlockElement.class, new GsonBlockElementFactory(failOnUnknownProps)) + .registerTypeAdapter(RichTextElement.class, new GsonRichTextElementFactory(failOnUnknownProps)) + .registerTypeAdapter(FunctionExecutedEvent.InputValue.class, new GsonFunctionExecutedEventInputValueFactory()) + .registerTypeAdapter(Attachment.VideoHtml.class, new GsonMessageAttachmentVideoHtmlFactory(failOnUnknownProps)) + .registerTypeAdapter(MessageChangedEvent.PreviousMessage.class, new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProps)) + .registerTypeAdapter(AppWorkflow.StepInputValue.class, new GsonAppWorkflowStepInputValueFactory(failOnUnknownProps)) + .registerTypeAdapter(AppWorkflow.StepInputValueElementDefault.class, new GsonAppWorkflowStepInputValueDefaultFactory(failOnUnknownProps)) + .registerTypeAdapter(LogsResponse.DetailsChangedValue.class, new GsonAuditLogsDetailsChangedValueFactory(failOnUnknownProps)) + .registerTypeAdapter(LogsResponse.UserIDs.class, new GsonAuditLogsDetailsUserIDsFactory(failOnUnknownProps)); } } diff --git a/slack-api-model/pom.xml b/slack-api-model/pom.xml index 5c5a51342..5a4eab54f 100644 --- a/slack-api-model/pom.xml +++ b/slack-api-model/pom.xml @@ -24,5 +24,17 @@ ${gson.version} + + + + org.apache.maven.plugins + maven-compiler-plugin + + 9 + 9 + + + + diff --git a/slack-api-model/src/main/java/com/slack/api/model/annotation/FieldPredicate.java b/slack-api-model/src/main/java/com/slack/api/model/annotation/FieldPredicate.java deleted file mode 100644 index d6d7b29cc..000000000 --- a/slack-api-model/src/main/java/com/slack/api/model/annotation/FieldPredicate.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.slack.api.model.annotation; - -public interface FieldPredicate { - boolean test(Object obj); -} diff --git a/slack-api-model/src/main/java/com/slack/api/model/annotation/IsNotNullFieldPredicate.java b/slack-api-model/src/main/java/com/slack/api/model/annotation/IsNotNullFieldPredicate.java deleted file mode 100644 index 90e638701..000000000 --- a/slack-api-model/src/main/java/com/slack/api/model/annotation/IsNotNullFieldPredicate.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.slack.api.model.annotation; - -import java.util.Objects; - -public class IsNotNullFieldPredicate implements FieldPredicate { - @Override - public boolean test(Object obj) { - return !Objects.isNull(obj); - } -} diff --git a/slack-api-model/src/main/java/com/slack/api/model/annotation/Required.java b/slack-api-model/src/main/java/com/slack/api/model/annotation/Required.java index d741490c9..795c30d79 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/annotation/Required.java +++ b/slack-api-model/src/main/java/com/slack/api/model/annotation/Required.java @@ -1,5 +1,9 @@ package com.slack.api.model.annotation; +import com.slack.api.model.predicate.FieldPredicate; +import com.slack.api.model.predicate.IsNotNullFieldPredicate; +import com.slack.api.util.json.RequiredPropertyDetectionAdapterFactory; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -9,7 +13,7 @@ * Field-level annotation indicating whether the field is a "required" field or not on the model object. *

* The enforcement of the field's presence in instantiated instances of the model object is accomplished using the - * {@link com.slack.api.util.json.RequiredAdapterFactory} which ensures all fields marked with {@link Required} are + * {@link RequiredPropertyDetectionAdapterFactory} which ensures all fields marked with {@link Required} are * present during the object deserialization (or serialization) process. Note that the enforcement of this annotation * is opt-in and defaults to "off". */ diff --git a/slack-api-model/src/main/java/com/slack/api/model/predicate/FieldPredicate.java b/slack-api-model/src/main/java/com/slack/api/model/predicate/FieldPredicate.java new file mode 100644 index 000000000..58cc287e0 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/predicate/FieldPredicate.java @@ -0,0 +1,11 @@ +package com.slack.api.model.predicate; + +/** + * A functional interface for defining validation predicates against {@link java.lang.reflect.Field}. Used by + * {@link com.slack.api.model.annotation.Required} during object serialization and deserialization to ensure the + * field member is "valid" per the defined predicate. + */ +@FunctionalInterface +public interface FieldPredicate { + boolean validate(Object obj); +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/predicate/IsNotNullFieldPredicate.java b/slack-api-model/src/main/java/com/slack/api/model/predicate/IsNotNullFieldPredicate.java new file mode 100644 index 000000000..53d948c4a --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/predicate/IsNotNullFieldPredicate.java @@ -0,0 +1,10 @@ +package com.slack.api.model.predicate; + +import java.util.Objects; + +public class IsNotNullFieldPredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return Objects.nonNull(obj); + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java similarity index 95% rename from slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java rename to slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java index cb63ea38b..4e94f71bb 100644 --- a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredAdapterFactory.java +++ b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java @@ -7,7 +7,7 @@ import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; -import com.slack.api.model.annotation.FieldPredicate; +import com.slack.api.model.predicate.FieldPredicate; import com.slack.api.model.annotation.Required; import lombok.EqualsAndHashCode; import lombok.RequiredArgsConstructor; @@ -27,7 +27,7 @@ *

* Note that this adapter handles both deserialization (JSON --> POJO) and serialization (POJO --> JSON). */ -public class RequiredAdapterFactory implements TypeAdapterFactory { +public class RequiredPropertyDetectionAdapterFactory implements TypeAdapterFactory { @Override public TypeAdapter create(Gson gson, TypeToken type) { TypeAdapter delegate = gson.getDelegateAdapter(this, type); @@ -87,7 +87,7 @@ private void ensureFieldValidity(T obj, List entries) { for (RequiredFieldEntry entry : entries) { try { Object value = entry.field.get(obj); - if (!entry.predicate.test(value)) { + if (!entry.predicate.validate(value)) { throw new JsonParseException("Required field '" + entry.field.getName() + "' failed validation in " + obj.getClass().getSimpleName() + " using predicate " + entry.predicate.getClass().getSimpleName()); diff --git a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java index 088a319f3..255598239 100644 --- a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java +++ b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java @@ -1,7 +1,7 @@ package test_locally.util; import com.google.gson.*; -import com.slack.api.model.annotation.FieldPredicate; +import com.slack.api.model.predicate.FieldPredicate; import com.slack.api.model.block.ContextBlockElement; import com.slack.api.model.block.DividerBlock; import com.slack.api.model.block.LayoutBlock; @@ -17,11 +17,13 @@ import lombok.Builder; import lombok.Data; import org.junit.Test; -import org.junit.runners.model.TestClass; import test_locally.unit.GsonFactory; import java.lang.reflect.Type; import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.function.Predicate; import static com.slack.api.model.block.composition.BlockCompositions.markdownText; import static com.slack.api.model.block.composition.BlockCompositions.plainText; @@ -30,6 +32,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; @@ -162,9 +165,9 @@ public void testGsonFunctionExecutedEventInputValueFactory() { } @Test - public void testRequiredAdapterFactory_basicCase() { + public void testRequiredPropertyDetectionAdapterFactory_basicCase_failureCases() { Gson gson = GsonFactory.getBuilder(true, true) - .registerTypeAdapterFactory(new RequiredAdapterFactory()).create(); + .registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()).create(); // Serialization TestClassWithRequiredBasic instance = TestClassWithRequiredBasic.builder().build(); @@ -176,8 +179,30 @@ public void testRequiredAdapterFactory_basicCase() { } @Test - public void testRequiredAdapterFactory_advancedCase() { - Gson gson = GsonFactory.getBuilder(true, true).registerTypeAdapterFactory(new RequiredAdapterFactory()).create(); + public void testRequiredPropertyDetectionAdapterFactory_basicCase_happyPath() { + Gson gson = GsonFactory.getBuilder(true, true).registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()).create(); + TestClassWithRequiredBasic instanceNoName = TestClassWithRequiredBasic.builder().id(1).build(); + TestClassWithRequiredBasic instanceWithName = TestClassWithRequiredBasic.builder().id(1).name("Hello").build(); + + // Serialization + assertThat(gson.toJson(instanceNoName), is("{\"id\":1}")); + assertThat(gson.toJson(instanceWithName), is("{\"id\":1,\"name\":\"Hello\"}")); + + // Deserialization + String json = "{\"id\": 1}"; + TestClassWithRequiredBasic instance = gson.fromJson(json, TestClassWithRequiredBasic.class); + assertThat(instance.getId(), is(1)); + assertNull(instance.getName()); + + json = "{\"id\": 1, \"name\": \"Hello\"}"; + instance = gson.fromJson(json, TestClassWithRequiredBasic.class); + assertThat(instance.getId(), is(1)); + assertThat(instance.getName(), is("Hello")); + } + + @Test + public void testRequiredPropertyDetectionAdapterFactory_advancedCase_failureCases() { + Gson gson = GsonFactory.getBuilder(true, true).registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()).create(); // Serialization JsonParseException e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().build())); @@ -185,9 +210,16 @@ public void testRequiredAdapterFactory_advancedCase() { e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().id(1).build())); assertThat(e.getMessage(), equalToIgnoringCase("Required field 'name' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyString")); + e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().id(1).name("").build())); assertThat(e.getMessage(), equalToIgnoringCase("Required field 'name' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyString")); + e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().id(1).name("Hello").build())); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'users' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyCollection")); + + e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().id(1).name("Hello").users(List.of("user1")).build())); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'myBool' failed validation in TestClassWithRequiredAdvanced using predicate isNotNullFieldPredicate")); + // Deserialization e = assertThrows(JsonParseException.class, () -> gson.fromJson("{\"id\": 0}", TestClassWithRequiredAdvanced.class)); assertThat(e.getMessage(), equalToIgnoringCase("Required field 'id' failed validation in TestClassWithRequiredAdvanced using predicate IntegerGreaterThanZero")); @@ -197,6 +229,35 @@ public void testRequiredAdapterFactory_advancedCase() { e = assertThrows(JsonParseException.class, () -> gson.fromJson("{\"id\": 1, \"name\": ''}", TestClassWithRequiredAdvanced.class)); assertThat(e.getMessage(), equalToIgnoringCase("Required field 'name' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyString")); + + e = assertThrows(JsonParseException.class, () -> gson.fromJson("{\"id\":1,\"name\":\"test\",\"users\":[]}", TestClassWithRequiredAdvanced.class)); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'users' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyCollection")); + + e = assertThrows(JsonParseException.class, () -> gson.fromJson("{\"id\":1,\"name\":\"test\",\"users\":[\"hello\"]}", TestClassWithRequiredAdvanced.class)); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'myBool' failed validation in TestClassWithRequiredAdvanced using predicate isNotNullFieldPredicate")); + } + + @Test + public void testRequiredPropertyDetectionAdapterFactory_advancedCase_happyPath() { + Gson gson = GsonFactory.getBuilder(true, true).registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()).create(); + TestClassWithRequiredAdvanced instance = TestClassWithRequiredAdvanced.builder() + .id(1) + .name("test") + .users(List.of("testUser")) + .myBool(true) + .build(); + + // Serialization + assertThat(gson.toJson(instance), is("{\"id\":1,\"name\":\"test\",\"users\":[\"testUser\"],\"my_bool\":true}")); + + // Deserialization + String json = "{\"id\":1,\"name\":\"test\",\"users\":[\"testUser\"],\"my_bool\":true}"; + instance = gson.fromJson(json, TestClassWithRequiredAdvanced.class); + assertThat(instance.getId(), is(1)); + assertThat(instance.getName(), is("test")); + assertThat(instance.getUsers().get(0), is("testUser")); + assertThat(instance.getMyBool(), is(true)); + assertNull(instance.getCanBeNull()); } @Data @@ -213,19 +274,32 @@ private static class TestClassWithRequiredAdvanced { private int id; @Required(validator = NonEmptyString.class) private String name; + @Required(validator = NonEmptyCollection.class) + private List users; + @Required + Boolean myBool; + private String canBeNull; public static class IntegerGreaterThanZero implements FieldPredicate { @Override - public boolean test(Object obj) { + public boolean validate(Object obj) { return obj instanceof Integer && (int)obj > 0; } } public static class NonEmptyString implements FieldPredicate { @Override - public boolean test(Object obj) { + public boolean validate(Object obj) { return obj instanceof String && !((String) obj).isEmpty(); } } + + public static class NonEmptyCollection implements FieldPredicate { + @Override + public boolean validate(Object obj) { + Predicate> isNotEmpty = collection -> !collection.isEmpty(); + return obj instanceof Collection && isNotEmpty.test((Collection) obj); + } + } } } From d4ca333f095d17de8aa2d8d0cd8917aa12c78897 Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Wed, 25 Feb 2026 16:59:53 -0500 Subject: [PATCH 09/18] oops --- slack-api-model/pom.xml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/slack-api-model/pom.xml b/slack-api-model/pom.xml index 5a4eab54f..5c5a51342 100644 --- a/slack-api-model/pom.xml +++ b/slack-api-model/pom.xml @@ -24,17 +24,5 @@ ${gson.version} - - - - org.apache.maven.plugins - maven-compiler-plugin - - 9 - 9 - - - - From 4339fc257e4bf44660ae95642c2345f3c18f0f30 Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Thu, 26 Feb 2026 11:01:40 -0500 Subject: [PATCH 10/18] addressing more comments --- .../com/slack/api/util/json/GsonFactory.java | 96 ++++++++----------- .../java/test_locally/unit/GsonFactory.java | 21 +++- .../test_locally/util/JSONUtilityTest.java | 9 +- 3 files changed, 62 insertions(+), 64 deletions(-) diff --git a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java index 44ee97009..3bd6c9c7a 100644 --- a/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java +++ b/slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java @@ -30,8 +30,9 @@ private GsonFactory() { * Most of the Slack APIs' key naming is snake-cased. */ public static Gson createSnakeCase() { - GsonBuilder gsonBuilder = getSnakeCaseBuilder(); - configureBuilder(gsonBuilder); + GsonBuilder gsonBuilder = new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); + registerTypeAdapters(gsonBuilder, false); return gsonBuilder.create(); } @@ -39,8 +40,21 @@ public static Gson createSnakeCase() { * Most of the Slack APIs' key naming is snake-cased. */ public static Gson createSnakeCase(SlackConfig config) { - GsonBuilder gsonBuilder = getSnakeCaseBuilder(); - configureBuilder(gsonBuilder, config); + boolean failOnUnknownProps = config.isFailOnUnknownProperties(); + GsonBuilder gsonBuilder = new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); + registerTypeAdapters(gsonBuilder, failOnUnknownProps); + + if (failOnUnknownProps || config.isLibraryMaintainerMode()) { + gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); + } + if (config.isFailOnRequiredProperties()) { + gsonBuilder.registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()); + } + if (config.isPrettyResponseLoggingEnabled()) { + gsonBuilder.setPrettyPrinting(); + } + return gsonBuilder.create(); } @@ -48,65 +62,39 @@ public static Gson createSnakeCase(SlackConfig config) { * Mainly used for SCIM APIs. */ public static Gson createCamelCase(SlackConfig config) { + boolean failOnUnknownProps = config.isFailOnUnknownProperties(); GsonBuilder gsonBuilder = new GsonBuilder(); - configureBuilder(gsonBuilder, config); - return gsonBuilder.create(); - } + registerTypeAdapters(gsonBuilder, failOnUnknownProps); - private static GsonBuilder getSnakeCaseBuilder() { - return new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); - } - - private static void configureBuilder(GsonBuilder builder) { - configureBuilder(builder, false, false, false, false); - } - - private static void configureBuilder(GsonBuilder builder, SlackConfig config) { - configureBuilder( - builder, - config.isFailOnUnknownProperties(), - config.isFailOnRequiredProperties(), - config.isPrettyResponseLoggingEnabled(), - config.isLibraryMaintainerMode() - ); - } - - private static void configureBuilder( - GsonBuilder builder, - boolean failOnUnknownProps, - boolean failOnRequiredProps, - boolean isPrettyResponseLoggingEnabled, - boolean isLibraryMaintainerMode - ) { - if (failOnUnknownProps || isLibraryMaintainerMode) { - builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); + if (failOnUnknownProps || config.isLibraryMaintainerMode()) { + gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); } - if (failOnRequiredProps) { - builder.registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()); + if (config.isFailOnRequiredProperties()) { + gsonBuilder.registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()); } - if (isPrettyResponseLoggingEnabled) { - builder.setPrettyPrinting(); + if (config.isPrettyResponseLoggingEnabled()) { + gsonBuilder.setPrettyPrinting(); } - registerTypeAdapters(builder, failOnUnknownProps); + return gsonBuilder.create(); } public static void registerTypeAdapters(GsonBuilder builder, boolean failOnUnknownProps) { builder - .registerTypeAdapter(Instant.class, new JavaTimeInstantFactory(failOnUnknownProps)) - .registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProps)) - .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory(failOnUnknownProps)) - .registerTypeAdapter(TextObject.class, new GsonTextObjectFactory(failOnUnknownProps)) - .registerTypeAdapter(ContextBlockElement.class, new GsonContextBlockElementFactory(failOnUnknownProps)) - .registerTypeAdapter(ContextActionsBlockElement.class, new GsonContextActionsBlockElementFactory(failOnUnknownProps)) - .registerTypeAdapter(BlockElement.class, new GsonBlockElementFactory(failOnUnknownProps)) - .registerTypeAdapter(RichTextElement.class, new GsonRichTextElementFactory(failOnUnknownProps)) - .registerTypeAdapter(FunctionExecutedEvent.InputValue.class, new GsonFunctionExecutedEventInputValueFactory()) - .registerTypeAdapter(Attachment.VideoHtml.class, new GsonMessageAttachmentVideoHtmlFactory(failOnUnknownProps)) - .registerTypeAdapter(MessageChangedEvent.PreviousMessage.class, new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProps)) - .registerTypeAdapter(AppWorkflow.StepInputValue.class, new GsonAppWorkflowStepInputValueFactory(failOnUnknownProps)) - .registerTypeAdapter(AppWorkflow.StepInputValueElementDefault.class, new GsonAppWorkflowStepInputValueDefaultFactory(failOnUnknownProps)) - .registerTypeAdapter(LogsResponse.DetailsChangedValue.class, new GsonAuditLogsDetailsChangedValueFactory(failOnUnknownProps)) - .registerTypeAdapter(LogsResponse.UserIDs.class, new GsonAuditLogsDetailsUserIDsFactory(failOnUnknownProps)); + .registerTypeAdapter(Instant.class, new JavaTimeInstantFactory(failOnUnknownProps)) + .registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProps)) + .registerTypeAdapter(LayoutBlock.class, new GsonLayoutBlockFactory(failOnUnknownProps)) + .registerTypeAdapter(TextObject.class, new GsonTextObjectFactory(failOnUnknownProps)) + .registerTypeAdapter(ContextBlockElement.class, new GsonContextBlockElementFactory(failOnUnknownProps)) + .registerTypeAdapter(ContextActionsBlockElement.class, new GsonContextActionsBlockElementFactory(failOnUnknownProps)) + .registerTypeAdapter(BlockElement.class, new GsonBlockElementFactory(failOnUnknownProps)) + .registerTypeAdapter(RichTextElement.class, new GsonRichTextElementFactory(failOnUnknownProps)) + .registerTypeAdapter(FunctionExecutedEvent.InputValue.class, new GsonFunctionExecutedEventInputValueFactory()) + .registerTypeAdapter(Attachment.VideoHtml.class, new GsonMessageAttachmentVideoHtmlFactory(failOnUnknownProps)) + .registerTypeAdapter(MessageChangedEvent.PreviousMessage.class, new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProps)) + .registerTypeAdapter(AppWorkflow.StepInputValue.class, new GsonAppWorkflowStepInputValueFactory(failOnUnknownProps)) + .registerTypeAdapter(AppWorkflow.StepInputValueElementDefault.class, new GsonAppWorkflowStepInputValueDefaultFactory(failOnUnknownProps)) + .registerTypeAdapter(LogsResponse.DetailsChangedValue.class, new GsonAuditLogsDetailsChangedValueFactory(failOnUnknownProps)) + .registerTypeAdapter(LogsResponse.UserIDs.class, new GsonAuditLogsDetailsUserIDsFactory(failOnUnknownProps)); } } diff --git a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java index 4138c5b0e..8e797da0f 100644 --- a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java +++ b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java @@ -20,18 +20,26 @@ private GsonFactory() { } public static Gson createSnakeCase() { - return createSnakeCase(false, true); + return createSnakeCase(false, true, false); } public static Gson createSnakeCaseWithoutUnknownPropertyDetection(boolean failOnUnknownProperties) { - return createSnakeCase(failOnUnknownProperties, false); + return createSnakeCase(failOnUnknownProperties, false, false); + } + + public static Gson createSnakeCaseWithRequiredPropertyDetection() { + return createSnakeCase(false, true, true); } public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unknownPropertyDetection) { - return getBuilder(failOnUnknownProperties, unknownPropertyDetection).create(); + return createSnakeCase(failOnUnknownProperties, unknownPropertyDetection, false); } - public static GsonBuilder getBuilder(boolean failOnUnknownProperties, boolean unknownPropertyDetection) { + public static Gson createSnakeCase( + boolean failOnUnknownProperties, + boolean unknownPropertyDetection, + boolean failOnRequiredProperties + ) { GsonBuilder builder = new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProperties)) @@ -51,7 +59,10 @@ public static GsonBuilder getBuilder(boolean failOnUnknownProperties, boolean un if (unknownPropertyDetection) { builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); } + if (failOnRequiredProperties) { + builder.registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()); + } - return builder; + return builder.create(); } } diff --git a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java index 255598239..ed5eb2946 100644 --- a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java +++ b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java @@ -166,8 +166,7 @@ public void testGsonFunctionExecutedEventInputValueFactory() { @Test public void testRequiredPropertyDetectionAdapterFactory_basicCase_failureCases() { - Gson gson = GsonFactory.getBuilder(true, true) - .registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()).create(); + Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); // Serialization TestClassWithRequiredBasic instance = TestClassWithRequiredBasic.builder().build(); @@ -180,7 +179,7 @@ public void testRequiredPropertyDetectionAdapterFactory_basicCase_failureCases() @Test public void testRequiredPropertyDetectionAdapterFactory_basicCase_happyPath() { - Gson gson = GsonFactory.getBuilder(true, true).registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()).create(); + Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); TestClassWithRequiredBasic instanceNoName = TestClassWithRequiredBasic.builder().id(1).build(); TestClassWithRequiredBasic instanceWithName = TestClassWithRequiredBasic.builder().id(1).name("Hello").build(); @@ -202,7 +201,7 @@ public void testRequiredPropertyDetectionAdapterFactory_basicCase_happyPath() { @Test public void testRequiredPropertyDetectionAdapterFactory_advancedCase_failureCases() { - Gson gson = GsonFactory.getBuilder(true, true).registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()).create(); + Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); // Serialization JsonParseException e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().build())); @@ -239,7 +238,7 @@ public void testRequiredPropertyDetectionAdapterFactory_advancedCase_failureCase @Test public void testRequiredPropertyDetectionAdapterFactory_advancedCase_happyPath() { - Gson gson = GsonFactory.getBuilder(true, true).registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()).create(); + Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); TestClassWithRequiredAdvanced instance = TestClassWithRequiredAdvanced.builder() .id(1) .name("test") From 60de10b45c8ca365f46f04ed77caf02a08221d31 Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Thu, 26 Feb 2026 11:04:32 -0500 Subject: [PATCH 11/18] update --- .../src/test/java/test_locally/unit/GsonFactory.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java index 8e797da0f..9f6591b02 100644 --- a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java +++ b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java @@ -20,11 +20,11 @@ private GsonFactory() { } public static Gson createSnakeCase() { - return createSnakeCase(false, true, false); + return createSnakeCase(false, true); } public static Gson createSnakeCaseWithoutUnknownPropertyDetection(boolean failOnUnknownProperties) { - return createSnakeCase(failOnUnknownProperties, false, false); + return createSnakeCase(failOnUnknownProperties, false); } public static Gson createSnakeCaseWithRequiredPropertyDetection() { From e8ca2142bcb0823892430cb73d0fb63e5cf10075 Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Thu, 26 Feb 2026 15:09:34 -0500 Subject: [PATCH 12/18] move packages and use java8 compliant syntax --- .../api/{model => util}/annotation/Required.java | 6 +++--- .../RequiredPropertyDetectionAdapterFactory.java | 6 +++--- .../{model => util}/predicate/FieldPredicate.java | 4 ++-- .../predicate/IsNotNullFieldPredicate.java | 2 +- .../java/test_locally/util/JSONUtilityTest.java | 14 +++++++++----- 5 files changed, 18 insertions(+), 14 deletions(-) rename slack-api-model/src/main/java/com/slack/api/{model => util}/annotation/Required.java (88%) rename slack-api-model/src/main/java/com/slack/api/{model => util}/predicate/FieldPredicate.java (63%) rename slack-api-model/src/main/java/com/slack/api/{model => util}/predicate/IsNotNullFieldPredicate.java (83%) diff --git a/slack-api-model/src/main/java/com/slack/api/model/annotation/Required.java b/slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java similarity index 88% rename from slack-api-model/src/main/java/com/slack/api/model/annotation/Required.java rename to slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java index 795c30d79..76c85c075 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/annotation/Required.java +++ b/slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java @@ -1,7 +1,7 @@ -package com.slack.api.model.annotation; +package com.slack.api.util.annotation; -import com.slack.api.model.predicate.FieldPredicate; -import com.slack.api.model.predicate.IsNotNullFieldPredicate; +import com.slack.api.util.predicate.FieldPredicate; +import com.slack.api.util.predicate.IsNotNullFieldPredicate; import com.slack.api.util.json.RequiredPropertyDetectionAdapterFactory; import java.lang.annotation.ElementType; diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java index 4e94f71bb..41a17ed88 100644 --- a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java +++ b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java @@ -7,8 +7,8 @@ import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; -import com.slack.api.model.predicate.FieldPredicate; -import com.slack.api.model.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; +import com.slack.api.util.annotation.Required; import lombok.EqualsAndHashCode; import lombok.RequiredArgsConstructor; @@ -22,7 +22,7 @@ /** * Adapter factory for processing objects annotated with {@link Required}. This annotation signals what properties * of a model object are required, and thus should be expected to be initialized on instantiated instances. For all - * fields on the model objected annotated with {@link Required} applies the {@link FieldPredicate#test(Object)} via the + * fields on the model objected annotated with {@link Required} applies the {@link FieldPredicate#validate(Object)} via the * specified {@link Required#validator()}. *

* Note that this adapter handles both deserialization (JSON --> POJO) and serialization (POJO --> JSON). diff --git a/slack-api-model/src/main/java/com/slack/api/model/predicate/FieldPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/FieldPredicate.java similarity index 63% rename from slack-api-model/src/main/java/com/slack/api/model/predicate/FieldPredicate.java rename to slack-api-model/src/main/java/com/slack/api/util/predicate/FieldPredicate.java index 58cc287e0..968ebe6ff 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/predicate/FieldPredicate.java +++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/FieldPredicate.java @@ -1,8 +1,8 @@ -package com.slack.api.model.predicate; +package com.slack.api.util.predicate; /** * A functional interface for defining validation predicates against {@link java.lang.reflect.Field}. Used by - * {@link com.slack.api.model.annotation.Required} during object serialization and deserialization to ensure the + * {@link com.slack.api.util.annotation.Required} during object serialization and deserialization to ensure the * field member is "valid" per the defined predicate. */ @FunctionalInterface diff --git a/slack-api-model/src/main/java/com/slack/api/model/predicate/IsNotNullFieldPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsNotNullFieldPredicate.java similarity index 83% rename from slack-api-model/src/main/java/com/slack/api/model/predicate/IsNotNullFieldPredicate.java rename to slack-api-model/src/main/java/com/slack/api/util/predicate/IsNotNullFieldPredicate.java index 53d948c4a..a80ce45f9 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/predicate/IsNotNullFieldPredicate.java +++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsNotNullFieldPredicate.java @@ -1,4 +1,4 @@ -package com.slack.api.model.predicate; +package com.slack.api.util.predicate; import java.util.Objects; diff --git a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java index ed5eb2946..718721586 100644 --- a/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java +++ b/slack-api-model/src/test/java/test_locally/util/JSONUtilityTest.java @@ -1,7 +1,6 @@ package test_locally.util; import com.google.gson.*; -import com.slack.api.model.predicate.FieldPredicate; import com.slack.api.model.block.ContextBlockElement; import com.slack.api.model.block.DividerBlock; import com.slack.api.model.block.LayoutBlock; @@ -12,14 +11,16 @@ import com.slack.api.model.block.element.ImageElement; import com.slack.api.model.block.element.OverflowMenuElement; import com.slack.api.model.event.FunctionExecutedEvent; -import com.slack.api.model.annotation.Required; +import com.slack.api.util.annotation.Required; import com.slack.api.util.json.*; +import com.slack.api.util.predicate.FieldPredicate; import lombok.Builder; import lombok.Data; import org.junit.Test; import test_locally.unit.GsonFactory; import java.lang.reflect.Type; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -202,7 +203,7 @@ public void testRequiredPropertyDetectionAdapterFactory_basicCase_happyPath() { @Test public void testRequiredPropertyDetectionAdapterFactory_advancedCase_failureCases() { Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); - + List users = new ArrayList<>(); // Serialization JsonParseException e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().build())); assertThat(e.getMessage(), equalToIgnoringCase("Required field 'id' failed validation in TestClassWithRequiredAdvanced using predicate IntegerGreaterThanZero")); @@ -216,7 +217,8 @@ public void testRequiredPropertyDetectionAdapterFactory_advancedCase_failureCase e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().id(1).name("Hello").build())); assertThat(e.getMessage(), equalToIgnoringCase("Required field 'users' failed validation in TestClassWithRequiredAdvanced using predicate NonEmptyCollection")); - e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().id(1).name("Hello").users(List.of("user1")).build())); + users.add("user1"); + e = assertThrows(JsonParseException.class, () -> gson.toJson(TestClassWithRequiredAdvanced.builder().id(1).name("Hello").users(users).build())); assertThat(e.getMessage(), equalToIgnoringCase("Required field 'myBool' failed validation in TestClassWithRequiredAdvanced using predicate isNotNullFieldPredicate")); // Deserialization @@ -239,10 +241,12 @@ public void testRequiredPropertyDetectionAdapterFactory_advancedCase_failureCase @Test public void testRequiredPropertyDetectionAdapterFactory_advancedCase_happyPath() { Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); + List users = new ArrayList<>(); + users.add("testUser"); TestClassWithRequiredAdvanced instance = TestClassWithRequiredAdvanced.builder() .id(1) .name("test") - .users(List.of("testUser")) + .users(users) .myBool(true) .build(); From 29c7ac6e472417a0a53bd4f82ee25641583bb32a Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Thu, 26 Feb 2026 17:33:13 -0500 Subject: [PATCH 13/18] wip --- .../api/model/work_objects/AppIcons.java | 12 +++++ .../api/model/work_objects/CompactLayout.java | 19 ++++++++ .../api/model/work_objects/ExternalUser.java | 9 ++++ .../slack/api/model/work_objects/Field.java | 47 +++++++++++++++++++ .../slack/api/model/work_objects/Fields.java | 13 +++++ .../slack/api/model/work_objects/Image.java | 16 +++++++ .../slack/api/model/work_objects/Layouts.java | 13 +++++ .../model/work_objects/LookupFunction.java | 14 ++++++ .../api/model/work_objects/SlackUser.java | 18 +++++++ .../slack/api/model/work_objects/Title.java | 12 +++++ .../slack/api/model/work_objects/User.java | 21 +++++++++ .../api/model/work_objects/UserMetadata.java | 20 ++++++++ .../model/work_objects/WorkObjectUnfurl.java | 35 ++++++++++++++ .../external/FullSizePreview.java | 24 ++++++++++ 14 files changed, 273 insertions(+) create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java new file mode 100644 index 000000000..7bee1465c --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java @@ -0,0 +1,12 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.element.ImageElement; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AppIcons { + private ImageElement image36; + private ImageElement image128; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java new file mode 100644 index 000000000..2bf5e3f7d --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java @@ -0,0 +1,19 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.ImageBlock; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class CompactLayout { + @Required String layoutType; + private ImageBlock productIcon; + @Required Title title; + private Title subtitile; + private Title headerTitle; + private Title hoverSubtitle; + private Fields fields; + private Integer updatedAt; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java new file mode 100644 index 000000000..362d3656f --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java @@ -0,0 +1,9 @@ +package com.slack.api.model.work_objects; + +public class ExternalUser extends User { + private static final String USER_TYPE = "external"; + + public ExternalUser() { + super(USER_TYPE); + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java new file mode 100644 index 000000000..3e5d8ffc6 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java @@ -0,0 +1,47 @@ +package com.slack.api.model.work_objects; + +import com.google.gson.annotations.SerializedName; +import com.slack.api.model.block.ImageBlock; +import com.slack.api.model.block.RichTextBlock; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class Field { + @Required private String type; + @Required private String label; + @Required private String fieldType; + + /** + * The name of the field from {@link com.slack.api.model.EntityMetadata}. + */ + private String fieldName; + + private String text; + + private RichTextBlock richText; + + /** + * List of Unix timestamps. + */ + private List timestamp; + + private List image; + + /** + * Should the field take up the full width. + */ + @SerializedName("long") + private Boolean isLong; + + /** + * List of Slack and external users. + */ + private List user; + + private List slackUser; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java new file mode 100644 index 000000000..b39ccec95 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java @@ -0,0 +1,13 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import java.util.List; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class Fields { + @Required String type; + @Required List elements; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java new file mode 100644 index 000000000..921331647 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java @@ -0,0 +1,16 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.composition.SlackFileObject; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class Image { + @Required private String type; + @Required private String altText; + private String imageUrl; + private String title; + private SlackFileObject slackFile; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java new file mode 100644 index 000000000..cf60e7012 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java @@ -0,0 +1,13 @@ +package com.slack.api.model.work_objects; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class Layouts { + private CompactLayout compact; + private ExpandedLayout expanded; + private FullLayout full; + private MinimalLayout minimal; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java new file mode 100644 index 000000000..0dc390a67 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java @@ -0,0 +1,14 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Data; + +import java.util.Map; + +@Data +@Builder +public class LookupFunction { + @Required private String functionId; + @Required private Map inputs; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java new file mode 100644 index 000000000..0a865e74c --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java @@ -0,0 +1,18 @@ +package com.slack.api.model.work_objects; + +import lombok.Getter; +import lombok.Setter; + +public class SlackUser extends User { + private static final String USER_TYPE = "slack"; + + @Getter @Setter + private String userId; + + @Getter @Setter + private UserMetadata metadata; + + public SlackUser() { + super(USER_TYPE); + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java new file mode 100644 index 000000000..22e048fb2 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java @@ -0,0 +1,12 @@ +package com.slack.api.model.work_objects; + + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class Title { + @Required String text; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java new file mode 100644 index 000000000..2c7e9a4c9 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java @@ -0,0 +1,21 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class User { + @Required + @Getter + protected final String userType; + + public boolean isExternalUser() { + return getUserType().equals("external"); + } + + public boolean isSlackUser() { + return getUserType().equals("slack"); + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java new file mode 100644 index 000000000..f9c4455d7 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java @@ -0,0 +1,20 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.ImageBlock; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class UserMetadata { + private String role; + private OverlayIcon overlayIcon; + + @Data + @Builder + public static class OverlayIcon { + @Required private String iconName; + private ImageBlock icon; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java new file mode 100644 index 000000000..7f717c48b --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java @@ -0,0 +1,35 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.work_objects.external.FullSizePreview; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class WorkObjectUnfurl { + @Required private String workObjectType; + @Required private String externalUrl; + @Required private String entityId; + private String relatedConversationsEntityId; + @Required private String appId; + private String appName; + private AppIcons appIcons; + private String productName; + private LookupFunction lookupFunction; + private String authProviderKey; + private String displayType; + private Integer ts; + private Layouts layouts; + private String workObjectEntityType; + private FullSizePreview fullSizePreview; + private String slackFileId; + private User perspectiveUser; + private CommentMetadata comments; + + @Data + @Builder + public static class CommentMetadata { + @Required Integer count; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java new file mode 100644 index 000000000..577b8e7dc --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java @@ -0,0 +1,24 @@ +package com.slack.api.model.work_objects.external; + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class FullSizePreview { + @Required private Boolean isSupported; + private String previewUrl; + private Boolean isAnimated; + private String width; + private String height; + private String mimeType; + private Error error; + + @Data + @Builder + public static class Error { + @Required private String code; + private String message; + } +} From ace64144fe3755b9f341f8bc55bd6fba542e8f8f Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Thu, 26 Feb 2026 21:40:31 -0500 Subject: [PATCH 14/18] adding tests --- .../api/model/work_objects/ExternalUser.java | 26 +++++- .../slack/api/model/work_objects/Field.java | 26 +++--- .../slack/api/model/work_objects/Layouts.java | 3 - .../api/model/work_objects/SlackUser.java | 44 ++++++++-- .../api/model/work_objects/UnknownUser.java | 6 ++ .../slack/api/model/work_objects/User.java | 23 +++-- .../util/json/GsonWorkObjectUserFactory.java | 52 ++++++++++++ ...quiredPropertyDetectionAdapterFactory.java | 24 +++--- .../api/model/work_objects/UserTest.java | 83 +++++++++++++++++++ .../java/test_locally/unit/GsonFactory.java | 10 ++- 10 files changed, 251 insertions(+), 46 deletions(-) create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java create mode 100644 slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectUserFactory.java create mode 100644 slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java index 362d3656f..810ff08b2 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java @@ -1,9 +1,29 @@ package com.slack.api.model.work_objects; +import com.slack.api.model.block.ImageBlock; +import com.slack.api.util.annotation.Required; +import lombok.EqualsAndHashCode; +import lombok.Value; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@Value +@EqualsAndHashCode(callSuper = true) public class ExternalUser extends User { - private static final String USER_TYPE = "external"; + public static final String USER_TYPE = "external"; + @Required String text; + ImageBlock image; + String url; + String email; + String caption; + UserMetadata userMetadata; - public ExternalUser() { - super(USER_TYPE); + private static final class ExternalUserBuilderImpl + extends ExternalUserBuilder { + @Override + public ExternalUser build() { + this.userType(USER_TYPE); + return new ExternalUser(this); + } } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java index 3e5d8ffc6..59154b292 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java @@ -5,43 +5,43 @@ import com.slack.api.model.block.RichTextBlock; import com.slack.api.util.annotation.Required; import lombok.Builder; -import lombok.Data; +import lombok.Value; import java.util.List; -@Data +@Value @Builder public class Field { - @Required private String type; - @Required private String label; - @Required private String fieldType; + @Required String type; + @Required String label; + @Required String fieldType; /** * The name of the field from {@link com.slack.api.model.EntityMetadata}. */ - private String fieldName; + String fieldName; - private String text; + String text; - private RichTextBlock richText; + RichTextBlock richText; /** * List of Unix timestamps. */ - private List timestamp; + List timestamp; - private List image; + List image; /** * Should the field take up the full width. */ @SerializedName("long") - private Boolean isLong; + Boolean isLong; /** * List of Slack and external users. */ - private List user; + List user; - private List slackUser; + List slackUser; } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java index cf60e7012..6fe408081 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java @@ -7,7 +7,4 @@ @Builder public class Layouts { private CompactLayout compact; - private ExpandedLayout expanded; - private FullLayout full; - private MinimalLayout minimal; } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java index 0a865e74c..cfebf0377 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java @@ -1,18 +1,44 @@ package com.slack.api.model.work_objects; -import lombok.Getter; -import lombok.Setter; +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; +import lombok.EqualsAndHashCode; +import lombok.Value; +import lombok.experimental.SuperBuilder; +import java.util.regex.Pattern; + +@SuperBuilder +@Value +@EqualsAndHashCode(callSuper = true) public class SlackUser extends User { - private static final String USER_TYPE = "slack"; + public static final String USER_TYPE = "slack"; + + @Required(validator = IsValidSlackUserIdPredicate.class) + String userId; + + UserMetadata metadata; + + private static final class SlackUserBuilderImpl + extends SlackUserBuilder { + @Override + public SlackUser build() { + this.userType(USER_TYPE); + return new SlackUser(this); + } + } - @Getter @Setter - private String userId; + public static class IsValidSlackUserIdPredicate implements FieldPredicate { + private static final Pattern USER_ID_REGEX = Pattern.compile("^[WU][A-Z0-9]{8,}$"); - @Getter @Setter - private UserMetadata metadata; + @Override + public boolean validate(Object obj) { + if (!(obj instanceof String)) { + return false; + } - public SlackUser() { - super(USER_TYPE); + String userId = ((String) obj); + return USER_ID_REGEX.matcher(userId).matches(); + } } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java new file mode 100644 index 000000000..9cb73ad37 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java @@ -0,0 +1,6 @@ +package com.slack.api.model.work_objects; + +import lombok.experimental.SuperBuilder; + +@SuperBuilder +public class UnknownUser extends User {} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java index 2c7e9a4c9..1dc0e4c1c 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java @@ -1,14 +1,15 @@ package com.slack.api.model.work_objects; import com.slack.api.util.annotation.Required; -import lombok.AccessLevel; +import com.slack.api.util.predicate.FieldPredicate; import lombok.Getter; -import lombok.RequiredArgsConstructor; +import lombok.experimental.SuperBuilder; -@RequiredArgsConstructor(access = AccessLevel.PROTECTED) + +@SuperBuilder +@Getter public abstract class User { - @Required - @Getter + @Required(validator = IsValidUserTypePredicate.class) protected final String userType; public boolean isExternalUser() { @@ -18,4 +19,16 @@ public boolean isExternalUser() { public boolean isSlackUser() { return getUserType().equals("slack"); } + + public static class IsValidUserTypePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + if (!(obj instanceof String)) { + return false; + } + + String userType = (String) obj; + return userType.equals("external") || userType.equals("slack"); + } + } } diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectUserFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectUserFactory.java new file mode 100644 index 000000000..c7db58437 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectUserFactory.java @@ -0,0 +1,52 @@ +package com.slack.api.util.json; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.slack.api.model.work_objects.ExternalUser; +import com.slack.api.model.work_objects.SlackUser; +import com.slack.api.model.work_objects.UnknownUser; +import com.slack.api.model.work_objects.User; +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Type; + +/** + * Factory for serializing and deserializing work object {@link User} into their appropriate concrete types, namely + * {@link com.slack.api.model.work_objects.SlackUser} and {@link com.slack.api.model.work_objects.ExternalUser}. + */ +@RequiredArgsConstructor +public class GsonWorkObjectUserFactory implements JsonDeserializer, JsonSerializer { + private final boolean failOnUnknownProperties; + + @Override + public User deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + final JsonObject jsonObject = json.getAsJsonObject(); + final String userType = jsonObject.getAsJsonPrimitive("user_type").getAsString(); + return context.deserialize(jsonObject, getUserClassForType(userType)); + } + + @Override + public JsonElement serialize(User src, Type typeOfSrc, JsonSerializationContext context) { + return context.serialize(src); + } + + private Class getUserClassForType(String userType) { + switch (userType) { + case SlackUser.USER_TYPE: + return SlackUser.class; + case ExternalUser.USER_TYPE: + return ExternalUser.class; + default: + if (failOnUnknownProperties) { + throw new JsonParseException("User type " + userType + " is not recognized"); + } + return UnknownUser.class; + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java index 41a17ed88..c49f6fc0c 100644 --- a/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java +++ b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java @@ -66,17 +66,19 @@ public T read(JsonReader in) throws IOException { */ private List buildRequiredFieldEntries(Class clazz) { List entries = new ArrayList<>(); - for (Field field : clazz.getDeclaredFields()) { - Required annotation = field.getAnnotation(Required.class); - if (annotation != null) { - field.setAccessible(true); - try { - FieldPredicate predicate = annotation.validator().getDeclaredConstructor().newInstance(); - entries.add(new RequiredFieldEntry(field, predicate)); - } catch (NoSuchMethodException | InstantiationException | - IllegalAccessException | InvocationTargetException e) { - throw new JsonParseException( - "Cannot instantiate validator for field: " + field.getName(), e); + for (Class current = clazz; current != null && current != Object.class; current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + Required annotation = field.getAnnotation(Required.class); + if (annotation != null) { + field.setAccessible(true); + try { + FieldPredicate predicate = annotation.validator().getDeclaredConstructor().newInstance(); + entries.add(new RequiredFieldEntry(field, predicate)); + } catch (NoSuchMethodException | InstantiationException | + IllegalAccessException | InvocationTargetException e) { + throw new JsonParseException( + "Cannot instantiate validator for field: " + field.getName(), e); + } } } } diff --git a/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java b/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java new file mode 100644 index 000000000..cc46da33c --- /dev/null +++ b/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java @@ -0,0 +1,83 @@ +package test_locally.api.model.work_objects; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.slack.api.model.work_objects.ExternalUser; +import com.slack.api.model.work_objects.SlackUser; +import com.slack.api.model.work_objects.User; +import test_locally.unit.GsonFactory; +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.equalToIgnoringCase; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class UserTest { + final Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); + + @Test + public void parseUser_withoutUserType_throwsException() { + String json = "{\"user_id\": \"U1234568\"}"; + assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class)); + } + + @Test + public void parseUser_withInvalidUserType_throwsException() { + String json = "{\"user_type\": \"test\"}"; + assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class)); + } + + @Test + public void parseExternalUser_withoutText_throwsException() { + String json = "{\"user_type\": \"external\"}"; + JsonParseException e = assertThrows(JsonParseException.class, () -> gson.fromJson(json, ExternalUser.class)); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'text' failed validation in ExternalUser using predicate IsNotNullFieldPredicate")); + } + + @Test + public void parseSlackUser_withoutUserId_throwsException() { + String json = "{\"user_type\": \"slack\"}"; + JsonParseException e = assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class)); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'userId' failed validation in SlackUser using predicate IsValidSlackUserIdPredicate")); + } + + @Test + public void parseSlackUser_withInvalidUserId_throwsException() { + String json = "{\"user_type\": \"slack\", \"user_id\": \"test\"}"; + JsonParseException e = assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class)); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'userId' failed validation in SlackUser using predicate IsValidSlackUserIdPredicate")); + } + + @Test + public void parseUsers_createsCorrectUserType() { + String slackUserJson = "{\"user_type\": \"slack\", \"user_id\": \"U12345678\"}"; + assertThat(gson.fromJson(slackUserJson, User.class), instanceOf(SlackUser.class)); + + String externalUserJson = "{\"user_type\":\"external\", \"text\": \"test\"}"; + assertThat(gson.fromJson(externalUserJson, User.class), instanceOf(ExternalUser.class)); + } + + @Test + public void parseUsers_withFailurePropertiesOff_returnsUnknownUser() { + Gson lenientGson = GsonFactory.createSnakeCase(false, false, false); + String badJson = "{\"user_type\": \"something we dont know\"}"; + User user = lenientGson.fromJson(badJson, User.class); + assertThat(user.getUserType(), is("something we dont know")); + } + + @Test + public void testUserBuilders() { + User slackUser = SlackUser.builder().userId("U12345678").userType("this will get ignored").build(); + User externalUser = ExternalUser.builder().text("test").userType("this will also get ignored").build(); + assertTrue(slackUser.isSlackUser()); + assertFalse(slackUser.isExternalUser()); + assertTrue(externalUser.isExternalUser()); + assertFalse(externalUser.isSlackUser()); + assertThat(slackUser.getUserType(), is("slack")); + assertThat(externalUser.getUserType(), is("external")); + } +} diff --git a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java index 9f6591b02..8d91091fa 100644 --- a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java +++ b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java @@ -13,6 +13,7 @@ import com.slack.api.model.block.element.RichTextElement; import com.slack.api.model.event.FunctionExecutedEvent; import com.slack.api.model.event.MessageChangedEvent; +import com.slack.api.model.work_objects.User; import com.slack.api.util.json.*; public class GsonFactory { @@ -28,7 +29,11 @@ public static Gson createSnakeCaseWithoutUnknownPropertyDetection(boolean failOn } public static Gson createSnakeCaseWithRequiredPropertyDetection() { - return createSnakeCase(false, true, true); + return createSnakeWithRequiredPropertyDetection(false); + } + + public static Gson createSnakeWithRequiredPropertyDetection(boolean failOnUnknownProperties) { + return createSnakeCase(failOnUnknownProperties, true, true); } public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unknownPropertyDetection) { @@ -54,7 +59,8 @@ public static Gson createSnakeCase( .registerTypeAdapter(Attachment.VideoHtml.class, new GsonMessageAttachmentVideoHtmlFactory(failOnUnknownProperties)) .registerTypeAdapter(MessageChangedEvent.PreviousMessage.class, - new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProperties)); + new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProperties)) + .registerTypeAdapter(User.class, new GsonWorkObjectUserFactory(failOnUnknownProperties)); if (unknownPropertyDetection) { builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); From 3dea5e13cefd319542e5a7c5bbb18e6791781eb8 Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Fri, 27 Feb 2026 12:30:35 -0500 Subject: [PATCH 15/18] getting there --- slack-api-model/pom.xml | 5 + .../api/model/work_objects/AppIcons.java | 8 +- .../slack/api/model/work_objects/Channel.java | 29 ++++++ .../model/work_objects/CheckboxOption.java | 14 +++ .../api/model/work_objects/CompactLayout.java | 17 ++-- .../api/model/work_objects/DateTimeRange.java | 96 +++++++++++++++++++ .../model/work_objects/EntityReference.java | 38 ++++++++ .../api/model/work_objects/ExternalUser.java | 26 +++-- .../slack/api/model/work_objects/Field.java | 75 +++++++++++++++ .../slack/api/model/work_objects/Fields.java | 19 +++- .../slack/api/model/work_objects/File.java | 35 +++++++ .../api/model/work_objects/FullLayout.java | 24 +++++ .../model/work_objects/HeaderWithBadge.java | 32 +++++++ .../slack/api/model/work_objects/Image.java | 14 +-- .../slack/api/model/work_objects/Layouts.java | 6 +- .../model/work_objects/LookupFunction.java | 8 +- .../api/model/work_objects/SlackUser.java | 41 +++----- .../api/model/work_objects/Subtitle.java | 14 +++ .../com/slack/api/model/work_objects/Tag.java | 15 +++ .../slack/api/model/work_objects/Title.java | 4 +- .../api/model/work_objects/UnknownUser.java | 14 ++- .../slack/api/model/work_objects/User.java | 15 ++- .../api/model/work_objects/UserMetadata.java | 14 +-- .../model/work_objects/WorkObjectUnfurl.java | 42 ++++---- .../model/work_objects/external/TagColor.java | 22 +++++ .../util/predicate/IsValidAppIdPredicate.java | 20 ++++ .../predicate/IsValidChannelIdPredicate.java | 23 +++++ .../predicate/IsValidUserIdPredicate.java | 22 +++++ .../api/model/work_objects/UserTest.java | 12 ++- 29 files changed, 595 insertions(+), 109 deletions(-) create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/Channel.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/CheckboxOption.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/EntityReference.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/File.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/HeaderWithBadge.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/Subtitle.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/Tag.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/external/TagColor.java create mode 100644 slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidAppIdPredicate.java create mode 100644 slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidChannelIdPredicate.java create mode 100644 slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidUserIdPredicate.java diff --git a/slack-api-model/pom.xml b/slack-api-model/pom.xml index 5c5a51342..419f864ba 100644 --- a/slack-api-model/pom.xml +++ b/slack-api-model/pom.xml @@ -23,6 +23,11 @@ gson ${gson.version} + + com.google.guava + guava + 33.4.8-jre + diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java index 7bee1465c..0291ceb6a 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java @@ -2,11 +2,11 @@ import com.slack.api.model.block.element.ImageElement; import lombok.Builder; -import lombok.Data; +import lombok.Value; -@Data +@Value @Builder public class AppIcons { - private ImageElement image36; - private ImageElement image128; + ImageElement image36; + ImageElement image128; } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Channel.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Channel.java new file mode 100644 index 000000000..83632987b --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Channel.java @@ -0,0 +1,29 @@ +package com.slack.api.model.work_objects; + +import com.google.common.base.Preconditions; +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.IsValidChannelIdPredicate; +import lombok.Builder; +import lombok.Value; + +import static com.slack.api.util.predicate.IsValidChannelIdPredicate.CHANNEL_ID_REGEX; + +/** + * Representation of a Slack channel on a work object. + */ +@Value +public class Channel { + private static final IsValidChannelIdPredicate isValidChannelId = new IsValidChannelIdPredicate(); + + @Required(validator = IsValidChannelIdPredicate.class) + String channelId; + + @Builder + public Channel(String channelId) { + Preconditions.checkArgument( + isValidChannelId.test(channelId), + String.format("Invalid slack channelId %s, required format %s", channelId, CHANNEL_ID_REGEX) + ); + this.channelId = channelId; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/CheckboxOption.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CheckboxOption.java new file mode 100644 index 000000000..fc34a3c28 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CheckboxOption.java @@ -0,0 +1,14 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class CheckboxOption { + @Required String text; + @Required Boolean checked; + String description; +} + diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java index 2bf5e3f7d..b0afca205 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/CompactLayout.java @@ -3,17 +3,18 @@ import com.slack.api.model.block.ImageBlock; import com.slack.api.util.annotation.Required; import lombok.Builder; -import lombok.Data; +import lombok.Value; -@Data +@Value @Builder public class CompactLayout { + public static final String LAYOUT_TYPE = "compact"; @Required String layoutType; - private ImageBlock productIcon; + ImageBlock productIcon; @Required Title title; - private Title subtitile; - private Title headerTitle; - private Title hoverSubtitle; - private Fields fields; - private Integer updatedAt; + Title subtitle; + Title headerTitle; + Title hoverSubtitle; + Fields fields; + Integer updatedAt; } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java new file mode 100644 index 000000000..421b62949 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java @@ -0,0 +1,96 @@ +package com.slack.api.model.work_objects; + +import com.google.gson.JsonSyntaxException; +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Date; + +/** + * Combined date/time display data for calendar events. + */ +public class DateTimeRange { + /** + * Start as Unix timestamp or YYYY-MM-DD dates. + */ + DateTimeImpl start; + /** + * End as Unix timestamp or YYYY-MM-DD dates + */ + DateTimeImpl end; + + /** + * Whether this is an all-day event. + */ + Boolean allDay; + + /** + * Recurrence description text. + */ + String recurrence; + + /** + * Since java doesn't support union types, this class represents a datetime input that can either be a unix + * timestamp or a date string in YYYY-MM-DD format. + */ + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public static class DateTimeImpl { + private static final DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_TIME.withZone(ZoneOffset.UTC); + private final Instant dateTime; + @Getter @Setter + private boolean wasYYYYMMDDInput = false; + @Getter @Setter + private boolean wasUnixtimeInput = false; + + public static DateTimeImpl from(String in) { + // See if this is a number first + try { + DateTimeImpl dateTime = DateTimeImpl.from(Long.parseLong(in)); + dateTime.setWasUnixtimeInput(true); + return dateTime; + } catch (NumberFormatException e) { + // Swallow - means the input is not a unix timestamp, so try to parse this as a string + } + + // Now make sure it's in YYYY-MM-DD format + try { + Instant dt = LocalDate.parse(in).atStartOfDay(ZoneOffset.UTC).toInstant(); + DateTimeImpl dateTime = new DateTimeImpl(dt); + dateTime.setWasYYYYMMDDInput(true); + return dateTime; + } catch (DateTimeParseException e) { + throw new IllegalArgumentException(String.format("Datetime input string not in a recognized format %s", in)); + } + } + + public static DateTimeImpl from(int in) { + return from(Long.valueOf(in)); + } + + public static DateTimeImpl from(long in) { + Instant dateTime = Instant.ofEpochSecond(in); + return new DateTimeImpl(dateTime); + } + + public long getUnixTime() { + return dateTime.getEpochSecond(); + } + + public String getDateTime() { + return formatter.format(dateTime); + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/EntityReference.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/EntityReference.java new file mode 100644 index 000000000..59eccda1e --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/EntityReference.java @@ -0,0 +1,38 @@ +package com.slack.api.model.work_objects; + +import com.google.common.base.Preconditions; +import com.slack.api.model.block.ImageBlock; +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.IsValidAppIdPredicate; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class EntityReference { + private static final IsValidAppIdPredicate isValidAppId = new IsValidAppIdPredicate(); + + @Required + String entityId; + + @Required(validator = IsValidAppIdPredicate.class) + String appId; + + @Required + String entityUrl; + + String displayType; + + @Required + String title; + + ImageBlock icon; + + public static class EntityReferenceBuilder { + public EntityReferenceBuilder appId(String appId) { + Preconditions.checkArgument(isValidAppId.test(appId)); + this.appId = appId; + return this; + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java index 810ff08b2..af6a85a2d 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ExternalUser.java @@ -2,11 +2,10 @@ import com.slack.api.model.block.ImageBlock; import com.slack.api.util.annotation.Required; +import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Value; -import lombok.experimental.SuperBuilder; -@SuperBuilder @Value @EqualsAndHashCode(callSuper = true) public class ExternalUser extends User { @@ -18,12 +17,21 @@ public class ExternalUser extends User { String caption; UserMetadata userMetadata; - private static final class ExternalUserBuilderImpl - extends ExternalUserBuilder { - @Override - public ExternalUser build() { - this.userType(USER_TYPE); - return new ExternalUser(this); - } + @Builder + public ExternalUser( + String text, + ImageBlock image, + String url, + String email, + String caption, + UserMetadata userMetadata + ) { + super(USER_TYPE); + this.text = text; + this.image = image; + this.url = url; + this.email = email; + this.caption = caption; + this.userMetadata = userMetadata; } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java index 59154b292..f9cf4ea52 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java @@ -2,9 +2,11 @@ import com.google.gson.annotations.SerializedName; import com.slack.api.model.block.ImageBlock; +import com.slack.api.model.block.InputBlock; import com.slack.api.model.block.RichTextBlock; import com.slack.api.util.annotation.Required; import lombok.Builder; +import lombok.Data; import lombok.Value; import java.util.List; @@ -21,8 +23,14 @@ public class Field { */ String fieldName; + /** + * Plain text. + */ String text; + /** + * Rich text block. + */ RichTextBlock richText; /** @@ -30,6 +38,9 @@ public class Field { */ List timestamp; + /** + * List of image blocks. + */ List image; /** @@ -43,5 +54,69 @@ public class Field { */ List user; + /** + * List of Slack users. + * @deprecated - prefer {@link this#user} field instead since this represents both Slack and external users. + */ + @Deprecated List slackUser; + + List tag; + + InputBlock input; + + /** + * Array of input blocks for editing. Used when a field has multiple edit inputs. + */ + List inputs; + + /*** + * List of "YYYY-MM-DD dates". + */ + List date; + + /** + * List of channels. + */ + List channel; + + /** + * List of entity references. + */ + List entityRef; + + /** + * List of checkbox options. + */ + List checkbox; + + /** + * List of email addresses. + */ + List email; + + /** + * List of links. + */ + List link; + + /** + * Header with optional badge. + */ + HeaderWithBadge headerWithBadge; + + /** + * List of files. + */ + List file; + + /** + * List of date time ranges. + */ + List dateTimeRange; + + /** + * Represents the native value for boolean field types. + */ + Boolean booleanValue; } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java index b39ccec95..601e2362d 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Fields.java @@ -1,13 +1,26 @@ package com.slack.api.model.work_objects; import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; import java.util.List; + import lombok.Builder; -import lombok.Data; +import lombok.Value; + +import static com.google.common.base.Predicates.instanceOf; +import static com.google.common.base.Predicates.equalTo; -@Data +@Value @Builder public class Fields { - @Required String type; + @Required(validator = FieldsTypeValuePredicate.class) + String type; @Required List elements; + + public static class FieldsTypeValuePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(equalTo("fields")).test(obj); + } + } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/File.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/File.java new file mode 100644 index 000000000..03ddeefe8 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/File.java @@ -0,0 +1,35 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; +import lombok.Builder; +import lombok.Value; + +import static com.google.common.base.Predicates.equalTo; +import static com.google.common.base.Predicates.instanceOf; + +@Value +@Builder +public class File { + @Required(validator = FileTypePredicate.class) String type; + @Required String fileId; + + public static class FileTypePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(equalTo("file")).test(obj); + } + } + + public static FileBuilder builder() { + return new CustomFileBuilder(); + } + + public static class CustomFileBuilder extends FileBuilder { + @Override + public File build() { + super.type("file"); + return super.build(); + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java new file mode 100644 index 000000000..31c5ba272 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java @@ -0,0 +1,24 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.InputBlock; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class FullLayout { + public static final String LAYOUT_TYPE = "full"; + @Required String layoutType; + com.slack.api.model.work_objects.Title headerTitle; + com.slack.api.model.work_objects.Title headerSubtitle; + @Required Title title; + Subtitle subtitle; + Boolean editable; + Fields fields; + + public static class Title { + @Required String text; + InputBlock input; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/HeaderWithBadge.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/HeaderWithBadge.java new file mode 100644 index 000000000..a71a9687a --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/HeaderWithBadge.java @@ -0,0 +1,32 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class HeaderWithBadge { + /** + * Plain text displayed as the header. + */ + @Required String text; + Badge badge; + + @Value + @Builder + public static class Badge { + /** + * Plain text displayed inside the badge. + */ + @Required String text; + /** + * Color of the text. + */ + String Color; + /** + * Color of the badge. + */ + String backgroundColor; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java index 921331647..88490e924 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Image.java @@ -3,14 +3,14 @@ import com.slack.api.model.block.composition.SlackFileObject; import com.slack.api.util.annotation.Required; import lombok.Builder; -import lombok.Data; +import lombok.Value; -@Data +@Value @Builder public class Image { - @Required private String type; - @Required private String altText; - private String imageUrl; - private String title; - private SlackFileObject slackFile; + @Required String type; + @Required String altText; + String imageUrl; + String title; + SlackFileObject slackFile; } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java index 6fe408081..342d742b0 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java @@ -1,10 +1,10 @@ package com.slack.api.model.work_objects; import lombok.Builder; -import lombok.Data; +import lombok.Value; -@Data +@Value @Builder public class Layouts { - private CompactLayout compact; + CompactLayout compact; } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java index 0dc390a67..d1e4e36d5 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/LookupFunction.java @@ -2,13 +2,13 @@ import com.slack.api.util.annotation.Required; import lombok.Builder; -import lombok.Data; +import lombok.Value; import java.util.Map; -@Data +@Value @Builder public class LookupFunction { - @Required private String functionId; - @Required private Map inputs; + @Required String functionId; + @Required Map inputs; } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java index cfebf0377..dbfdd07f4 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/SlackUser.java @@ -1,44 +1,33 @@ package com.slack.api.model.work_objects; +import com.google.common.base.Preconditions; import com.slack.api.util.annotation.Required; -import com.slack.api.util.predicate.FieldPredicate; +import com.slack.api.util.predicate.IsValidUserIdPredicate; +import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Value; -import lombok.experimental.SuperBuilder; -import java.util.regex.Pattern; +import static com.slack.api.util.predicate.IsValidUserIdPredicate.USER_ID_REGEX; -@SuperBuilder @Value @EqualsAndHashCode(callSuper = true) public class SlackUser extends User { public static final String USER_TYPE = "slack"; + private static final IsValidUserIdPredicate isValidUserId = new IsValidUserIdPredicate(); - @Required(validator = IsValidSlackUserIdPredicate.class) + @Required(validator = IsValidUserIdPredicate.class) String userId; UserMetadata metadata; - private static final class SlackUserBuilderImpl - extends SlackUserBuilder { - @Override - public SlackUser build() { - this.userType(USER_TYPE); - return new SlackUser(this); - } - } - - public static class IsValidSlackUserIdPredicate implements FieldPredicate { - private static final Pattern USER_ID_REGEX = Pattern.compile("^[WU][A-Z0-9]{8,}$"); - - @Override - public boolean validate(Object obj) { - if (!(obj instanceof String)) { - return false; - } - - String userId = ((String) obj); - return USER_ID_REGEX.matcher(userId).matches(); - } + @Builder + public SlackUser(String userId, UserMetadata metadata) { + super(USER_TYPE); + Preconditions.checkArgument( + isValidUserId.test(userId), + String.format("Invalid slack userId %s, required format %s", userId, USER_ID_REGEX) + ); + this.userId = userId; + this.metadata = metadata; } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Subtitle.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Subtitle.java new file mode 100644 index 000000000..53453041a --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Subtitle.java @@ -0,0 +1,14 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.ImageBlock; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class Subtitle { + @Required String text; + String url; + ImageBlock image; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Tag.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Tag.java new file mode 100644 index 000000000..713526a55 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Tag.java @@ -0,0 +1,15 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.work_objects.external.TagColor; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class Tag { + @Required String text; + TagColor color; + String link; + String iconUrl; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java index 22e048fb2..e9eb32c8d 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Title.java @@ -3,9 +3,9 @@ import com.slack.api.util.annotation.Required; import lombok.Builder; -import lombok.Data; +import lombok.Value; -@Data +@Value @Builder public class Title { @Required String text; diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java index 9cb73ad37..bce08422f 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownUser.java @@ -1,6 +1,14 @@ package com.slack.api.model.work_objects; -import lombok.experimental.SuperBuilder; +import lombok.Builder; -@SuperBuilder -public class UnknownUser extends User {} +public class UnknownUser extends User { + public UnknownUser() { + super("unknown"); + } + + @Builder + public UnknownUser(String userType) { + super(userType); + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java index 1dc0e4c1c..90ce1a09d 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/User.java @@ -2,12 +2,16 @@ import com.slack.api.util.annotation.Required; import com.slack.api.util.predicate.FieldPredicate; +import lombok.AccessLevel; import lombok.Getter; -import lombok.experimental.SuperBuilder; +import lombok.RequiredArgsConstructor; +import static com.google.common.base.Predicates.equalTo; +import static com.google.common.base.Predicates.instanceOf; +import static com.google.common.base.Predicates.or; -@SuperBuilder @Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) public abstract class User { @Required(validator = IsValidUserTypePredicate.class) protected final String userType; @@ -23,12 +27,7 @@ public boolean isSlackUser() { public static class IsValidUserTypePredicate implements FieldPredicate { @Override public boolean validate(Object obj) { - if (!(obj instanceof String)) { - return false; - } - - String userType = (String) obj; - return userType.equals("external") || userType.equals("slack"); + return instanceOf(String.class).and(or(equalTo("external"), equalTo("slack"))).test(obj); } } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java index f9c4455d7..9b98a3c3a 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UserMetadata.java @@ -3,18 +3,18 @@ import com.slack.api.model.block.ImageBlock; import com.slack.api.util.annotation.Required; import lombok.Builder; -import lombok.Data; +import lombok.Value; -@Data +@Value @Builder public class UserMetadata { - private String role; - private OverlayIcon overlayIcon; + String role; + OverlayIcon overlayIcon; - @Data + @Value @Builder public static class OverlayIcon { - @Required private String iconName; - private ImageBlock icon; + @Required String iconName; + ImageBlock icon; } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java index 7f717c48b..1ae9deaab 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java @@ -3,31 +3,31 @@ import com.slack.api.model.work_objects.external.FullSizePreview; import com.slack.api.util.annotation.Required; import lombok.Builder; -import lombok.Data; +import lombok.Value; -@Data +@Value @Builder public class WorkObjectUnfurl { - @Required private String workObjectType; - @Required private String externalUrl; - @Required private String entityId; - private String relatedConversationsEntityId; - @Required private String appId; - private String appName; - private AppIcons appIcons; - private String productName; - private LookupFunction lookupFunction; - private String authProviderKey; - private String displayType; - private Integer ts; - private Layouts layouts; - private String workObjectEntityType; - private FullSizePreview fullSizePreview; - private String slackFileId; - private User perspectiveUser; - private CommentMetadata comments; + @Required String workObjectType; + @Required String externalUrl; + @Required String entityId; + String relatedConversationsEntityId; + @Required String appId; + String appName; + AppIcons appIcons; + String productName; + LookupFunction lookupFunction; + String authProviderKey; + String displayType; + Integer ts; + Layouts layouts; + String workObjectEntityType; + FullSizePreview fullSizePreview; + String slackFileId; + User perspectiveUser; + CommentMetadata comments; - @Data + @Value @Builder public static class CommentMetadata { @Required Integer count; diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/TagColor.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/TagColor.java new file mode 100644 index 000000000..b26eaac1d --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/TagColor.java @@ -0,0 +1,22 @@ +package com.slack.api.model.work_objects.external; + +import com.google.gson.annotations.SerializedName; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TagColor { + @SerializedName("flamingo") FLAMINGO("flamingo"), + @SerializedName("honeycomb") HONEYCOMB("honeycomb"), + @SerializedName("grass") GRASS("grass"), + @SerializedName("gray") GREY("gray"), + @SerializedName("informative") INFORMATIVE("informative"), + @SerializedName("indigo") INDIGO("indigo"), + @SerializedName("lagooon") LAGOON("lagoon"), + @SerializedName("jade") JADE("jade"), + @SerializedName("horchata") HORCHATA("horchata"), + @SerializedName("aubergine") AUBERGINE("aubergine"); + + private final String color; +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidAppIdPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidAppIdPredicate.java new file mode 100644 index 000000000..ea55c5854 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidAppIdPredicate.java @@ -0,0 +1,20 @@ +package com.slack.api.util.predicate; + +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import static com.google.common.base.Predicates.instanceOf; + +public class IsValidAppIdPredicate implements FieldPredicate, Predicate { + public static final Pattern APP_ID_REGEX = Pattern.compile("^A[A-Z0-9]+$"); + + @Override + public boolean test(String t) { + return APP_ID_REGEX.matcher(t).matches(); + } + + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(o -> test((String) o)).test(obj); + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidChannelIdPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidChannelIdPredicate.java new file mode 100644 index 000000000..90e4710d3 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidChannelIdPredicate.java @@ -0,0 +1,23 @@ +package com.slack.api.util.predicate; + +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import static com.google.common.base.Predicates.instanceOf; + +/** + * Predicate for validating that a given ID conforms to Slack's channel ID format. + */ +public class IsValidChannelIdPredicate implements FieldPredicate, Predicate { + public static final Pattern CHANNEL_ID_REGEX = Pattern.compile("^C[A-Z0-9]{2,}$"); + + @Override + public boolean test(String t) { + return CHANNEL_ID_REGEX.matcher(t).matches(); + } + + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(o -> test((String) o)).test(obj); + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidUserIdPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidUserIdPredicate.java new file mode 100644 index 000000000..343c960df --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsValidUserIdPredicate.java @@ -0,0 +1,22 @@ +package com.slack.api.util.predicate; + +import java.util.function.Predicate; +import java.util.regex.Pattern; +import static com.google.common.base.Predicates.instanceOf; + +/** + * Predicate for validating that a given ID conforms to Slack's user ID format. + */ +public final class IsValidUserIdPredicate implements FieldPredicate, Predicate { + public static final Pattern USER_ID_REGEX = Pattern.compile("^[WU][A-Z0-9]{8,}$"); + + @Override + public boolean test(String t) { + return USER_ID_REGEX.matcher(t).matches(); + } + + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(o -> test((String) o)).test(obj); + } +} diff --git a/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java b/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java index cc46da33c..8a11cf728 100644 --- a/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java +++ b/slack-api-model/src/test/java/test_locally/api/model/work_objects/UserTest.java @@ -4,6 +4,7 @@ import com.google.gson.JsonParseException; import com.slack.api.model.work_objects.ExternalUser; import com.slack.api.model.work_objects.SlackUser; +import com.slack.api.model.work_objects.UnknownUser; import com.slack.api.model.work_objects.User; import test_locally.unit.GsonFactory; import org.junit.Test; @@ -42,14 +43,14 @@ public void parseExternalUser_withoutText_throwsException() { public void parseSlackUser_withoutUserId_throwsException() { String json = "{\"user_type\": \"slack\"}"; JsonParseException e = assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class)); - assertThat(e.getMessage(), equalToIgnoringCase("Required field 'userId' failed validation in SlackUser using predicate IsValidSlackUserIdPredicate")); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'userId' failed validation in SlackUser using predicate IsValidUserIdPredicate")); } @Test public void parseSlackUser_withInvalidUserId_throwsException() { String json = "{\"user_type\": \"slack\", \"user_id\": \"test\"}"; JsonParseException e = assertThrows(JsonParseException.class, () -> gson.fromJson(json, SlackUser.class)); - assertThat(e.getMessage(), equalToIgnoringCase("Required field 'userId' failed validation in SlackUser using predicate IsValidSlackUserIdPredicate")); + assertThat(e.getMessage(), equalToIgnoringCase("Required field 'userId' failed validation in SlackUser using predicate IsValidUserIdPredicate")); } @Test @@ -67,17 +68,20 @@ public void parseUsers_withFailurePropertiesOff_returnsUnknownUser() { String badJson = "{\"user_type\": \"something we dont know\"}"; User user = lenientGson.fromJson(badJson, User.class); assertThat(user.getUserType(), is("something we dont know")); + assertThat(user, instanceOf(UnknownUser.class)); } @Test public void testUserBuilders() { - User slackUser = SlackUser.builder().userId("U12345678").userType("this will get ignored").build(); - User externalUser = ExternalUser.builder().text("test").userType("this will also get ignored").build(); + User slackUser = SlackUser.builder().userId("U12345678").build(); + User externalUser = ExternalUser.builder().text("test").build(); assertTrue(slackUser.isSlackUser()); assertFalse(slackUser.isExternalUser()); assertTrue(externalUser.isExternalUser()); assertFalse(externalUser.isSlackUser()); assertThat(slackUser.getUserType(), is("slack")); assertThat(externalUser.getUserType(), is("external")); + + assertThrows(IllegalArgumentException.class, () -> SlackUser.builder().userId("invalid!").build()); } } From b9c28c00365aec2acf724a79e72db7aba242c989 Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Fri, 27 Feb 2026 14:48:35 -0500 Subject: [PATCH 16/18] adding tests --- .../api/model/work_objects/DateTimeRange.java | 80 ++++++++++++---- .../slack/api/model/work_objects/Field.java | 1 - .../GsonWorkObjectDateTimeDeserializer.java | 88 ++++++++++++++++++ .../model/work_objects/DateTimeRangeTest.java | 92 +++++++++++++++++++ .../java/test_locally/unit/GsonFactory.java | 8 +- 5 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectDateTimeDeserializer.java create mode 100644 slack-api-model/src/test/java/test_locally/api/model/work_objects/DateTimeRangeTest.java diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java index 421b62949..db6671dce 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DateTimeRange.java @@ -1,27 +1,30 @@ package com.slack.api.model.work_objects; -import com.google.gson.JsonSyntaxException; -import com.google.gson.TypeAdapter; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonToken; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import lombok.Setter; +import lombok.Value; -import java.io.IOException; +import java.lang.reflect.Type; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; -import java.util.Date; /** * Combined date/time display data for calendar events. */ +@Value +@Builder +@JsonAdapter(DateTimeRange.DateTimeRangeSerializer.class) public class DateTimeRange { /** * Start as Unix timestamp or YYYY-MM-DD dates. @@ -48,19 +51,33 @@ public class DateTimeRange { */ @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public static class DateTimeImpl { - private static final DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_TIME.withZone(ZoneOffset.UTC); private final Instant dateTime; @Getter @Setter - private boolean wasYYYYMMDDInput = false; - @Getter @Setter - private boolean wasUnixtimeInput = false; + private boolean inYearMonthDayFormat = false; + + public boolean isUnknown() { + return dateTime.equals(Instant.EPOCH); + } + + public static DateTimeImpl atStartOfDay() { + Instant startOfDay = LocalDate.now(ZoneOffset.UTC).atStartOfDay(ZoneOffset.UTC).toInstant(); + return new DateTimeImpl(startOfDay); + } + + public static DateTimeImpl atEndOfDay() { + Instant endOfDay = LocalDate.now(ZoneOffset.UTC).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant(); + return new DateTimeImpl(endOfDay); + } + + // Used during JSON serialization/deserialization when we can't infer what the date-time value is + public static DateTimeImpl unknown() { + return new DateTimeImpl(Instant.EPOCH); + } public static DateTimeImpl from(String in) { // See if this is a number first try { - DateTimeImpl dateTime = DateTimeImpl.from(Long.parseLong(in)); - dateTime.setWasUnixtimeInput(true); - return dateTime; + return DateTimeImpl.from(Long.parseLong(in)); } catch (NumberFormatException e) { // Swallow - means the input is not a unix timestamp, so try to parse this as a string } @@ -69,7 +86,7 @@ public static DateTimeImpl from(String in) { try { Instant dt = LocalDate.parse(in).atStartOfDay(ZoneOffset.UTC).toInstant(); DateTimeImpl dateTime = new DateTimeImpl(dt); - dateTime.setWasYYYYMMDDInput(true); + dateTime.setInYearMonthDayFormat(true); return dateTime; } catch (DateTimeParseException e) { throw new IllegalArgumentException(String.format("Datetime input string not in a recognized format %s", in)); @@ -90,7 +107,38 @@ public long getUnixTime() { } public String getDateTime() { - return formatter.format(dateTime); + return DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC).format(dateTime); + } + } + + public static class DateTimeRangeSerializer implements JsonSerializer { + @Override + public JsonElement serialize(DateTimeRange dateTimeRange, Type typeOfT, JsonSerializationContext context) { + JsonObject jsonObject = new JsonObject(); + if (dateTimeRange.getStart() != null) { + DateTimeImpl start = dateTimeRange.getStart(); + if (start.isInYearMonthDayFormat()) { + jsonObject.addProperty("start", start.getDateTime()); + } else { + jsonObject.addProperty("start", start.getUnixTime()); + } + } + if (dateTimeRange.getEnd() != null) { + DateTimeImpl end = dateTimeRange.getEnd(); + if (end.isInYearMonthDayFormat()) { + jsonObject.addProperty("end", end.getDateTime()); + } else { + jsonObject.addProperty("end", end.getUnixTime()); + } + } + if (dateTimeRange.getAllDay() != null) { + jsonObject.addProperty("all_day", dateTimeRange.getAllDay()); + } + if (dateTimeRange.getRecurrence() != null && !dateTimeRange.getRecurrence().isEmpty()) { + jsonObject.addProperty("recurrence", dateTimeRange.getRecurrence()); + } + + return jsonObject; } } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java index f9cf4ea52..fbef375df 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Field.java @@ -6,7 +6,6 @@ import com.slack.api.model.block.RichTextBlock; import com.slack.api.util.annotation.Required; import lombok.Builder; -import lombok.Data; import lombok.Value; import java.util.List; diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectDateTimeDeserializer.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectDateTimeDeserializer.java new file mode 100644 index 000000000..44b59ceb8 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectDateTimeDeserializer.java @@ -0,0 +1,88 @@ +package com.slack.api.util.json; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.slack.api.model.work_objects.DateTimeRange; +import com.slack.api.model.work_objects.DateTimeRange.DateTimeRangeBuilder; +import com.slack.api.model.work_objects.DateTimeRange.DateTimeImpl; +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Type; + +/** + * Factory for serializing and deserializing {@link com.slack.api.model.work_objects.DateTimeRange} objects, namely + * to handle start and end time inputs that are allowed to be in unix time OR YYYY-MM-DD format. + */ +@RequiredArgsConstructor +public class GsonWorkObjectDateTimeDeserializer implements JsonDeserializer { + private final boolean failOnUnknownProperties; + + @Override + public DateTimeRange deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + final JsonObject jsonObject = json.getAsJsonObject(); + final boolean isAllDay = jsonObject.has("all_day") && jsonObject.get("all_day").getAsBoolean(); + final DateTimeRangeBuilder builder = DateTimeRange.builder().allDay(isAllDay); + + // If we have an all-day event, and the start/end times haven't been provided, set them to the start/end + // of the today + if (isAllDay) { + if (!jsonObject.has("start")) { + builder.start(DateTimeImpl.atStartOfDay()); + } + if (!jsonObject.has("end")) { + builder.end(DateTimeImpl.atEndOfDay()); + } + } + + // If we don't have an all-day event, we need both start and end dates + if (!isAllDay && (!jsonObject.has("start") || !jsonObject.has("end"))) { + if (failOnUnknownProperties) { + throw new JsonParseException("DateTimeRange object missing start and/or end times"); + } + + // We can't really do anything in this case, just return null + return null; + } + + if (jsonObject.has("start")) { + JsonPrimitive startDate = jsonObject.getAsJsonPrimitive("start"); + builder.start(getDateTime(startDate)); + } + if (jsonObject.has("end")) { + JsonPrimitive endDate = jsonObject.getAsJsonPrimitive("end"); + builder.end(getDateTime(endDate)); + } + if (jsonObject.has("recurrence") && !jsonObject.get("recurrence").isJsonNull()) { + try { + builder.recurrence(jsonObject.getAsJsonPrimitive("recurrence").getAsString()); + } catch (AssertionError e) { + if (failOnUnknownProperties) { + throw new JsonParseException(e); + } + } + } + + return builder.build(); + } + + private DateTimeImpl getDateTime(JsonPrimitive prim) { + try { + if (prim.isNumber()) { + return DateTimeImpl.from(prim.getAsLong()); + } else if (prim.isString()) { + return DateTimeImpl.from(prim.getAsString()); + } + throw new IllegalArgumentException("Unrecognized JSON primitive type"); + } catch (IllegalArgumentException e) { + if (failOnUnknownProperties) { + throw new JsonParseException(e); + } + return DateTimeImpl.unknown(); + } + } +} diff --git a/slack-api-model/src/test/java/test_locally/api/model/work_objects/DateTimeRangeTest.java b/slack-api-model/src/test/java/test_locally/api/model/work_objects/DateTimeRangeTest.java new file mode 100644 index 000000000..7a65359df --- /dev/null +++ b/slack-api-model/src/test/java/test_locally/api/model/work_objects/DateTimeRangeTest.java @@ -0,0 +1,92 @@ +package test_locally.api.model.work_objects; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.slack.api.model.work_objects.DateTimeRange; +import com.slack.api.model.work_objects.DateTimeRange.DateTimeImpl; +import org.junit.Test; +import test_locally.unit.GsonFactory; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.*; + +public class DateTimeRangeTest { + final Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); + + @Test + public void testJsonSerialization() { + // An empty object shout output an empty json object + DateTimeRange dateTime = DateTimeRange.builder().build(); + assertThat(gson.toJson(dateTime), is("{}")); + + // Handles booleans + dateTime = DateTimeRange.builder().allDay(true).build(); + assertThat(gson.toJson(dateTime), is("{\"all_day\":true}")); + dateTime = DateTimeRange.builder().allDay(false).build(); + assertThat(gson.toJson(dateTime), is("{\"all_day\":false}")); + + // Handles the start/end times formatted in YYYY-MM-DD format + DateTimeImpl start = DateTimeImpl.from("2026-02-26"); + DateTimeImpl end = DateTimeImpl.from("2026-02-27"); + dateTime = DateTimeRange.builder().start(start).end(end).build(); + assertThat(gson.toJson(dateTime), is("{\"start\":\"2026-02-26\",\"end\":\"2026-02-27\"}")); + + // Handles the start/end times as string inputs in unixtime format + start = DateTimeImpl.from("1772215885694"); + end = DateTimeImpl.from("1772219309359"); + dateTime = DateTimeRange.builder().start(start).end(end).build(); + assertThat(gson.toJson(dateTime), is("{\"start\":1772215885694,\"end\":1772219309359}")); + + // Handles the start/end times as integer inputs in unixtime format + start = DateTimeImpl.from(1772215885694L); + end = DateTimeImpl.from(1772219309359L); + dateTime = DateTimeRange.builder().start(start).end(end).build(); + assertThat(gson.toJson(dateTime), is("{\"start\":1772215885694,\"end\":1772219309359}")); + } + + @Test + public void throwsOnInvalidDateTimeInput() { + assertThrows(IllegalArgumentException.class, () -> DateTimeImpl.from("this just isn't a date")); + } + + @Test + public void gracefullyHandles_malformedJson_whenFailOnUnknownProperties_isFalse() { + // We should return null when the json can't be converted to an actual date time value + String jsonMissingStartAndEnd = "{\"all_day\":false}"; + assertNull(gson.fromJson(jsonMissingStartAndEnd, DateTimeRange.class)); + + // Should add the start/end fields if the json just indicates it's an all-day event + String allDayOnly = "{\"all_day\":true}"; + DateTimeRange dateTime = gson.fromJson(allDayOnly, DateTimeRange.class); + assertThat(dateTime.getAllDay(), is(true)); + assertNotNull(dateTime.getStart()); + assertNotNull(dateTime.getEnd()); + + // We should skip adding the recurrence property if it can't be coerced to a string + String malformedRecurrence = "{\"all_day\":true, \"recurrence\": null}"; + dateTime = gson.fromJson(malformedRecurrence, DateTimeRange.class); + assertThat(dateTime.getAllDay(), is(true)); + assertNotNull(dateTime.getStart()); + assertNotNull(dateTime.getEnd()); + assertNull(dateTime.getRecurrence()); + + // We should use "unknown" start/end values when the input isn't a valid unixtime or date string + String malformedStartEnd = "{\"start\":hello, \"end\": world}"; + dateTime = gson.fromJson(malformedStartEnd, DateTimeRange.class); + assertTrue(dateTime.getStart().isUnknown()); + assertTrue(dateTime.getEnd().isUnknown()); + } + + @Test + public void throws_onInvalidJson_whenFailOnUnknownProperties_isTrue() { + final Gson strictGson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(true); + + // Can't do anything with an empty object + assertThrows(JsonParseException.class, () -> strictGson.fromJson("{}", DateTimeRange.class)); + + // Should throw when the start/end properties are malformed + String badJson = "{\"start\": hello, \"end\": world}"; + assertThrows(JsonParseException.class, () -> strictGson.fromJson(badJson, DateTimeRange.class)); + } +} diff --git a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java index 8d91091fa..4488365d4 100644 --- a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java +++ b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java @@ -13,6 +13,7 @@ import com.slack.api.model.block.element.RichTextElement; import com.slack.api.model.event.FunctionExecutedEvent; import com.slack.api.model.event.MessageChangedEvent; +import com.slack.api.model.work_objects.DateTimeRange; import com.slack.api.model.work_objects.User; import com.slack.api.util.json.*; @@ -29,10 +30,10 @@ public static Gson createSnakeCaseWithoutUnknownPropertyDetection(boolean failOn } public static Gson createSnakeCaseWithRequiredPropertyDetection() { - return createSnakeWithRequiredPropertyDetection(false); + return createSnakeCaseWithRequiredPropertyDetection(false); } - public static Gson createSnakeWithRequiredPropertyDetection(boolean failOnUnknownProperties) { + public static Gson createSnakeCaseWithRequiredPropertyDetection(boolean failOnUnknownProperties) { return createSnakeCase(failOnUnknownProperties, true, true); } @@ -60,7 +61,8 @@ public static Gson createSnakeCase( new GsonMessageAttachmentVideoHtmlFactory(failOnUnknownProperties)) .registerTypeAdapter(MessageChangedEvent.PreviousMessage.class, new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProperties)) - .registerTypeAdapter(User.class, new GsonWorkObjectUserFactory(failOnUnknownProperties)); + .registerTypeAdapter(User.class, new GsonWorkObjectUserFactory(failOnUnknownProperties)) + .registerTypeAdapter(DateTimeRange.class, new GsonWorkObjectDateTimeDeserializer(failOnUnknownProperties)); if (unknownPropertyDetection) { builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); From d14f8f223d2a406ceccfd6d41d185247c27f8e9f Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Fri, 27 Feb 2026 16:12:23 -0500 Subject: [PATCH 17/18] ugh almost done --- .../composition/ThirdPartyAuthObject.java | 14 ++++ .../api/model/work_objects/AppIcons.java | 6 +- .../slack/api/model/work_objects/Button.java | 44 +++++++++++++ .../api/model/work_objects/DefaultAction.java | 32 +++++++++ .../api/model/work_objects/FullLayout.java | 43 ++++++++++-- .../slack/api/model/work_objects/Layouts.java | 3 + .../model/work_objects/PrimaryActions.java | 6 ++ .../api/model/work_objects/UnknownAction.java | 6 ++ .../model/work_objects/WorkObjectUnfurl.java | 19 +++++- .../external/ButtonProcessingState.java | 12 ++++ .../external/FullSizePreview.java | 24 +++---- .../external/WorkObjectEntityType.java | 19 ++++++ .../GsonWorkObjectPrimaryActionsFactory.java | 65 +++++++++++++++++++ .../java/test_locally/unit/GsonFactory.java | 4 +- 14 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 slack-api-model/src/main/java/com/slack/api/model/block/composition/ThirdPartyAuthObject.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/Button.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/DefaultAction.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/PrimaryActions.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownAction.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/external/ButtonProcessingState.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/external/WorkObjectEntityType.java create mode 100644 slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectPrimaryActionsFactory.java diff --git a/slack-api-model/src/main/java/com/slack/api/model/block/composition/ThirdPartyAuthObject.java b/slack-api-model/src/main/java/com/slack/api/model/block/composition/ThirdPartyAuthObject.java new file mode 100644 index 000000000..33ee544c5 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/block/composition/ThirdPartyAuthObject.java @@ -0,0 +1,14 @@ +package com.slack.api.model.block.composition; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ThirdPartyAuthObject { + Boolean enableDynamicAuth; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java index 0291ceb6a..ea9d0d4d5 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/AppIcons.java @@ -1,12 +1,12 @@ package com.slack.api.model.work_objects; -import com.slack.api.model.block.element.ImageElement; +import com.slack.api.model.block.ImageBlock; import lombok.Builder; import lombok.Value; @Value @Builder public class AppIcons { - ImageElement image36; - ImageElement image128; + ImageBlock image36; + ImageBlock image128; } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Button.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Button.java new file mode 100644 index 000000000..9e01e9ab0 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Button.java @@ -0,0 +1,44 @@ +package com.slack.api.model.work_objects; + +import com.slack.api.model.block.composition.ConfirmationDialogObject; +import com.slack.api.model.block.composition.PlainTextObject; +import com.slack.api.model.block.composition.ThirdPartyAuthObject; +import com.slack.api.model.work_objects.external.ButtonProcessingState; +import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; +import lombok.Builder; +import lombok.Value; + +import java.util.List; + +import static com.google.common.base.Predicates.equalTo; +import static com.google.common.base.Predicates.instanceOf; + +/** + * Button Action Payload. + *

+ * Represents the payload sent to the server from a `button` action. + */ +@Value +@Builder +public class Button extends PrimaryActions { + @Required String blockId; + @Required String actionId; + @Required(validator = ButtonTypePredicate.class) String type; + String style; + @Required PlainTextObject text; + String value; + String url; + String accessibilityLabel; + ConfirmationDialogObject confirm; + ThirdPartyAuthObject thirdPartyAuth; + List visibleToUserIds; + ButtonProcessingState processingState; + + public static class ButtonTypePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(equalTo("button")).test(obj); + } + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/DefaultAction.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DefaultAction.java new file mode 100644 index 000000000..1d44f8c07 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/DefaultAction.java @@ -0,0 +1,32 @@ +package com.slack.api.model.work_objects; + +import com.google.gson.annotations.SerializedName; +import com.slack.api.model.block.composition.PlainTextObject; +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Value; + +@Value +@Builder +public class DefaultAction extends PrimaryActions { + @Required Type type; + String style; + PlainTextObject text; + + @Getter + @RequiredArgsConstructor + public enum Type { + @SerializedName("add-to-todo") ADD_TO_TODO("add-to-todo"), + @SerializedName("open-in-app") OPEN_IN_APP("open-in-app"), + @SerializedName("share-link") SHARE_LINK("share-link"), + @SerializedName("copy-link") COPY_LINK("copy-link"), + @SerializedName("add-to-list") ADD_TO_LIST("add-to-list"), + @SerializedName("save-for-later") SAVE_FOR_LATER("save-for-later"), + @SerializedName("remind-me") REMIND_ME("remind-me"), + @SerializedName("add-to-folder") ADD_TO_FOLDER("add-to-folder"); + + private final String value; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java index 31c5ba272..e68b8cc83 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java @@ -1,24 +1,59 @@ package com.slack.api.model.work_objects; import com.slack.api.model.block.InputBlock; +import com.slack.api.model.work_objects.Title; import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; import lombok.Builder; import lombok.Value; +import java.util.List; + +import static com.google.common.base.Predicates.instanceOf; +import static java.util.function.Predicate.isEqual; + @Value @Builder public class FullLayout { public static final String LAYOUT_TYPE = "full"; - @Required String layoutType; - com.slack.api.model.work_objects.Title headerTitle; - com.slack.api.model.work_objects.Title headerSubtitle; - @Required Title title; + @Required(validator = FullLayoutTypePredicate.class) String layoutType; + Title headerTitle; + Title headerSubtitle; + /** + * A title field on an external work object. + */ + @Required FullLayout.Title title; Subtitle subtitle; + /** + * When true, at least one of the fields in this schema can be edited by the user. + */ Boolean editable; Fields fields; + Actions actions; + @Value + @Builder public static class Title { + /** + * Plan text fallback of the field value. + */ @Required String text; InputBlock input; } + + @Value + @Builder + public static class Actions { + @Required List primaryActions; + List primaryActionsMenu; + @Required OverflowActions overflowActions; + @Required ActionBlockPayload blockPayload; + } + + public static class FullLayoutTypePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(isEqual(LAYOUT_TYPE)).test(obj); + } + } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java index 342d742b0..d3a35cd1e 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/Layouts.java @@ -7,4 +7,7 @@ @Builder public class Layouts { CompactLayout compact; + ExpandedLayout expanded; + FullLayout full; + MinimalLayout minimal; } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/PrimaryActions.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/PrimaryActions.java new file mode 100644 index 000000000..a04dec710 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/PrimaryActions.java @@ -0,0 +1,6 @@ +package com.slack.api.model.work_objects; + +/** + * Primary action buttons that appear in a work object's view. These can either be {@link Button} or {@link DefaultAction}. + */ +abstract public class PrimaryActions {} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownAction.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownAction.java new file mode 100644 index 000000000..c267bc54e --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/UnknownAction.java @@ -0,0 +1,6 @@ +package com.slack.api.model.work_objects; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class UnknownAction extends PrimaryActions {} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java index 1ae9deaab..60cd71542 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/WorkObjectUnfurl.java @@ -1,18 +1,24 @@ package com.slack.api.model.work_objects; import com.slack.api.model.work_objects.external.FullSizePreview; +import com.slack.api.model.work_objects.external.WorkObjectEntityType; import com.slack.api.util.annotation.Required; +import com.slack.api.util.predicate.FieldPredicate; +import com.slack.api.util.predicate.IsValidAppIdPredicate; import lombok.Builder; import lombok.Value; +import static com.google.common.base.Predicates.instanceOf; +import static java.util.function.Predicate.isEqual; + @Value @Builder public class WorkObjectUnfurl { - @Required String workObjectType; + @Required(validator = WorkObjectTypePredicate.class) String workObjectType; @Required String externalUrl; @Required String entityId; String relatedConversationsEntityId; - @Required String appId; + @Required(validator = IsValidAppIdPredicate.class) String appId; String appName; AppIcons appIcons; String productName; @@ -21,7 +27,7 @@ public class WorkObjectUnfurl { String displayType; Integer ts; Layouts layouts; - String workObjectEntityType; + WorkObjectEntityType workObjectEntityType; FullSizePreview fullSizePreview; String slackFileId; User perspectiveUser; @@ -32,4 +38,11 @@ public class WorkObjectUnfurl { public static class CommentMetadata { @Required Integer count; } + + public static class WorkObjectTypePredicate implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return instanceOf(String.class).and(isEqual("external")).test(obj); + } + } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/ButtonProcessingState.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/ButtonProcessingState.java new file mode 100644 index 000000000..96494c4a3 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/ButtonProcessingState.java @@ -0,0 +1,12 @@ +package com.slack.api.model.work_objects.external; + +import com.slack.api.util.annotation.Required; +import lombok.Builder; +import lombok.Value; + +@Value +@Builder +public class ButtonProcessingState { + @Required Boolean enabled; + String interstitialText; +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java index 577b8e7dc..19a70135e 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/FullSizePreview.java @@ -2,23 +2,23 @@ import com.slack.api.util.annotation.Required; import lombok.Builder; -import lombok.Data; +import lombok.Value; -@Data +@Value @Builder public class FullSizePreview { - @Required private Boolean isSupported; - private String previewUrl; - private Boolean isAnimated; - private String width; - private String height; - private String mimeType; - private Error error; + @Required Boolean isSupported; + String previewUrl; + Boolean isAnimated; + String width; + String height; + String mimeType; + Error error; - @Data + @Value @Builder public static class Error { - @Required private String code; - private String message; + @Required String code; + String message; } } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/WorkObjectEntityType.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/WorkObjectEntityType.java new file mode 100644 index 000000000..1003979a5 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/external/WorkObjectEntityType.java @@ -0,0 +1,19 @@ +package com.slack.api.model.work_objects.external; + +import com.google.gson.annotations.SerializedName; +import lombok.RequiredArgsConstructor; +import lombok.Getter; + +@Getter +@RequiredArgsConstructor +public enum WorkObjectEntityType { + @SerializedName("slack#/entities/task") TASK("slack#/entities/task"), + @SerializedName("slack#/entities/file") FILE("slack#/entities/file"), + @SerializedName("slack#/entities/item") ITEM("slack#/entities/item"), + @SerializedName("slack#/entities/incident") INCIDENT("slack#/entities/incident"), + @SerializedName("slack#/entities/content_item") CONTENT_ITEM("slack#/entities/content_item"), + @SerializedName("slack#/entities/tableau_analytics") TABLEAU_ANALYTICS("slack#/entities/tableau_analytics"), + @SerializedName("slack#/entities/calendar_event") CALENDAR_EVENT("slack#/entities/calendar_event"); + + private final String type; +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectPrimaryActionsFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectPrimaryActionsFactory.java new file mode 100644 index 000000000..9aea151f4 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/json/GsonWorkObjectPrimaryActionsFactory.java @@ -0,0 +1,65 @@ +package com.slack.api.util.json; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.slack.api.model.work_objects.Button; +import com.slack.api.model.work_objects.DefaultAction; +import com.slack.api.model.work_objects.PrimaryActions; +import com.slack.api.model.work_objects.UnknownAction; +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Type; + +/** + * Factory for serializing and deserializing work object {@link PrimaryActions} into their appropriate concrete types, + * namely {@link Button} and {@link DefaultAction}. + *

+ * The discriminator is the {@code "type"} field in the JSON object. A value of {@code "button"} maps to + * {@link Button}, while any other value is treated as a {@link DefaultAction}. + */ +@RequiredArgsConstructor +public class GsonWorkObjectPrimaryActionsFactory implements JsonDeserializer, JsonSerializer { + private final boolean failOnUnknownProperties; + + @Override + public PrimaryActions deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + final JsonObject jsonObject = json.getAsJsonObject(); + final JsonPrimitive typePrimitive = jsonObject.getAsJsonPrimitive("type"); + if (typePrimitive == null) { + if (failOnUnknownProperties) { + throw new JsonParseException("Missing 'type' field in PrimaryActions JSON object"); + } + return new UnknownAction(); + } + final String type = typePrimitive.getAsString(); + try { + return context.deserialize(jsonObject, getClassForType(type)); + } catch (JsonParseException e) { + if (failOnUnknownProperties) { + throw e; + } + + return new UnknownAction(); + } + } + + @Override + public JsonElement serialize(PrimaryActions src, Type typeOfSrc, JsonSerializationContext context) { + return context.serialize(src); + } + + private Class getClassForType(String type) { + if ("button".equals(type)) { + return Button.class; + } + + return DefaultAction.class; + } +} diff --git a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java index 4488365d4..375aea9fb 100644 --- a/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java +++ b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java @@ -14,6 +14,7 @@ import com.slack.api.model.event.FunctionExecutedEvent; import com.slack.api.model.event.MessageChangedEvent; import com.slack.api.model.work_objects.DateTimeRange; +import com.slack.api.model.work_objects.PrimaryActions; import com.slack.api.model.work_objects.User; import com.slack.api.util.json.*; @@ -62,7 +63,8 @@ public static Gson createSnakeCase( .registerTypeAdapter(MessageChangedEvent.PreviousMessage.class, new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProperties)) .registerTypeAdapter(User.class, new GsonWorkObjectUserFactory(failOnUnknownProperties)) - .registerTypeAdapter(DateTimeRange.class, new GsonWorkObjectDateTimeDeserializer(failOnUnknownProperties)); + .registerTypeAdapter(DateTimeRange.class, new GsonWorkObjectDateTimeDeserializer(failOnUnknownProperties)) + .registerTypeAdapter(PrimaryActions.class, new GsonWorkObjectPrimaryActionsFactory(failOnUnknownProperties)); if (unknownPropertyDetection) { builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); From 5348779b7ee70625e673c518ba77a38d5b535a26 Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Mon, 2 Mar 2026 08:34:56 -0500 Subject: [PATCH 18/18] pushing everything --- .../com/slack/api/model/work_objects/ActionBlockPayload.java | 4 ++++ .../java/com/slack/api/model/work_objects/ActionMenu.java | 4 ++++ .../java/com/slack/api/model/work_objects/FullLayout.java | 2 +- .../com/slack/api/model/work_objects/OverflowActions.java | 4 ++++ 4 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionBlockPayload.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionMenu.java create mode 100644 slack-api-model/src/main/java/com/slack/api/model/work_objects/OverflowActions.java diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionBlockPayload.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionBlockPayload.java new file mode 100644 index 000000000..0a0bcf2e7 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionBlockPayload.java @@ -0,0 +1,4 @@ +package com.slack.api.model.work_objects; + +public class ActionBlockPayload { +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionMenu.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionMenu.java new file mode 100644 index 000000000..900c7f23c --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/ActionMenu.java @@ -0,0 +1,4 @@ +package com.slack.api.model.work_objects; + +public class ActionMenu { +} diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java index e68b8cc83..dc3a22757 100644 --- a/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/FullLayout.java @@ -45,7 +45,7 @@ public static class Title { @Builder public static class Actions { @Required List primaryActions; - List primaryActionsMenu; + List primaryActionsMenu; @Required OverflowActions overflowActions; @Required ActionBlockPayload blockPayload; } diff --git a/slack-api-model/src/main/java/com/slack/api/model/work_objects/OverflowActions.java b/slack-api-model/src/main/java/com/slack/api/model/work_objects/OverflowActions.java new file mode 100644 index 000000000..1013b037b --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/model/work_objects/OverflowActions.java @@ -0,0 +1,4 @@ +package com.slack.api.model.work_objects; + +public class OverflowActions { +}