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..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 @@ -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,11 @@ public void setLibraryMaintainerMode(boolean libraryMaintainerMode) { */ private boolean failOnUnknownProperties = false; + /** + * If you would like to detect required properties by throwing exceptions, set this flag as 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..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 @@ -44,12 +44,17 @@ public static Gson createSnakeCase(SlackConfig config) { GsonBuilder gsonBuilder = new GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); registerTypeAdapters(gsonBuilder, failOnUnknownProps); + if (failOnUnknownProps || config.isLibraryMaintainerMode()) { - gsonBuilder = gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); + gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); + } + if (config.isFailOnRequiredProperties()) { + gsonBuilder.registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()); } if (config.isPrettyResponseLoggingEnabled()) { - gsonBuilder = gsonBuilder.setPrettyPrinting(); + gsonBuilder.setPrettyPrinting(); } + return gsonBuilder.create(); } @@ -60,12 +65,17 @@ public static Gson createCamelCase(SlackConfig config) { boolean failOnUnknownProps = config.isFailOnUnknownProperties(); GsonBuilder gsonBuilder = new GsonBuilder(); registerTypeAdapters(gsonBuilder, failOnUnknownProps); + if (failOnUnknownProps || config.isLibraryMaintainerMode()) { - gsonBuilder = gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); + gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()); + } + if (config.isFailOnRequiredProperties()) { + gsonBuilder.registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()); } if (config.isPrettyResponseLoggingEnabled()) { - gsonBuilder = gsonBuilder.setPrettyPrinting(); + gsonBuilder.setPrettyPrinting(); } + return gsonBuilder.create(); } 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..76c85c075 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/annotation/Required.java @@ -0,0 +1,29 @@ +package com.slack.api.util.annotation; + +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; +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 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". + */ +@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/json/RequiredPropertyDetectionAdapterFactory.java b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java new file mode 100644 index 000000000..41a17ed88 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/json/RequiredPropertyDetectionAdapterFactory.java @@ -0,0 +1,111 @@ +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.predicate.FieldPredicate; +import com.slack.api.util.annotation.Required; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.io.IOException; + +/** + * 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#validate(Object)} via the + * specified {@link Required#validator()}. + *

+ * Note that this adapter handles both deserialization (JSON --> POJO) and serialization (POJO --> JSON). + */ +public class RequiredPropertyDetectionAdapterFactory 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, entries); + } + delegate.write(out, value); + } + + @Override + public T read(JsonReader in) throws IOException { + T result = delegate.read(in); + if (result == null) { + return null; + } + + ensureFieldValidity(result, entries); + return result; + } + }; + } + + /** + * 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 = 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.validate(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; + } +} diff --git a/slack-api-model/src/main/java/com/slack/api/util/predicate/FieldPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/FieldPredicate.java new file mode 100644 index 000000000..968ebe6ff --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/FieldPredicate.java @@ -0,0 +1,11 @@ +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.util.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/util/predicate/IsNotNullFieldPredicate.java b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsNotNullFieldPredicate.java new file mode 100644 index 000000000..a80ce45f9 --- /dev/null +++ b/slack-api-model/src/main/java/com/slack/api/util/predicate/IsNotNullFieldPredicate.java @@ -0,0 +1,10 @@ +package com.slack.api.util.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/test/java/test_locally/unit/GsonFactory.java b/slack-api-model/src/test/java/test_locally/unit/GsonFactory.java index 409056175..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 @@ -27,7 +27,19 @@ public static Gson createSnakeCaseWithoutUnknownPropertyDetection(boolean failOn return createSnakeCase(failOnUnknownProperties, false); } + public static Gson createSnakeCaseWithRequiredPropertyDetection() { + return createSnakeCase(false, true, true); + } + public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unknownPropertyDetection) { + return createSnakeCase(failOnUnknownProperties, unknownPropertyDetection, false); + } + + 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)) @@ -45,9 +57,12 @@ 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()); + } + if (failOnRequiredProperties) { + builder.registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory()); } + + 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 c2139faee..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 @@ -11,20 +11,31 @@ 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 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; +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; 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.assertNull; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; public class JSONUtilityTest { @@ -153,4 +164,145 @@ public void testGsonFunctionExecutedEventInputValueFactory() { parsed = f.deserialize(json, FunctionExecutedEvent.InputValue.class, context); assertThat(parsed.asStringArray(), is(Arrays.asList("C111", "C222"))); } + + @Test + public void testRequiredPropertyDetectionAdapterFactory_basicCase_failureCases() { + Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); + + // Serialization + TestClassWithRequiredBasic instance = TestClassWithRequiredBasic.builder().build(); + assertThrows(JsonParseException.class, () -> gson.toJson(instance)); + + // Deserialization + String json = "{\"name\": \"Hello\"}"; + assertThrows(JsonParseException.class, () -> gson.fromJson(json, TestClassWithRequiredBasic.class)); + } + + @Test + public void testRequiredPropertyDetectionAdapterFactory_basicCase_happyPath() { + Gson gson = GsonFactory.createSnakeCaseWithRequiredPropertyDetection(); + 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.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")); + + 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")); + + 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 + 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")); + + 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.createSnakeCaseWithRequiredPropertyDetection(); + List users = new ArrayList<>(); + users.add("testUser"); + TestClassWithRequiredAdvanced instance = TestClassWithRequiredAdvanced.builder() + .id(1) + .name("test") + .users(users) + .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 + @Builder + 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; + @Required(validator = NonEmptyCollection.class) + private List users; + @Required + Boolean myBool; + private String canBeNull; + + public static class IntegerGreaterThanZero implements FieldPredicate { + @Override + public boolean validate(Object obj) { + return obj instanceof Integer && (int)obj > 0; + } + } + + public static class NonEmptyString implements FieldPredicate { + @Override + 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); + } + } + } }