From b6fc327e46b4d1bd92271d726139bf61296f4fcd Mon Sep 17 00:00:00 2001 From: "Ford St. John" Date: Mon, 23 Feb 2026 17:47:03 -0500 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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