Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions slack-api-client/src/main/java/com/slack/api/SlackConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ public void setFailOnUnknownProperties(boolean failOnUnknownProperties) {
throwException();
}

@Override
public void setFailOnRequiredProperties(boolean failOnRequiredProperties) { throwException(); }
Comment thread
fst-john marked this conversation as resolved.

@Override
public void setPrettyResponseLoggingEnabled(boolean prettyResponseLoggingEnabled) {
throwException();
Expand Down Expand Up @@ -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.
Comment thread
fst-john marked this conversation as resolved.
Outdated
*/
private boolean failOnRequiredProperties = false;

/**
* Slack Web API client verifies the existence of tokens before sending HTTP requests to Slack servers.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -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());
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reassignment is redundant, so removing it

}
Expand All @@ -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());
}
Expand All @@ -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))
Expand All @@ -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());
}
Comment thread
fst-john marked this conversation as resolved.
Outdated
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.slack.api.model.annotation;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking we move this to a new package/directory

/**
 * Provides JSON custom annotation utilities for the classes in this library.
 */
package com.slack.api.util.annotations;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that these predicates are meant to live on slack model objects I think it makes sense for it to live in the model package, but I'm open to a new sub-module under model; maybe predicates (or something?)


public interface FieldPredicate {
boolean test(Object obj);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice job on the interface approach 🚀 but I think the test terminology might be confusing, what do you think of check or validate instead?

Maybe validate is better since it aligns with validator

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's fair; I went with test because that's the abstract method on the Predicate functional interface https://docs.oracle.com/javase/8/docs/api/java/util/function/Predicate.html

}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<? extends FieldPredicate> validator() default IsNotNullFieldPredicate.class;
}
Original file line number Diff line number Diff line change
@@ -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.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.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#test(Object)} via the
* specified {@link Required#validator()}.
* <p>
* Note that this adapter handles both deserialization (JSON --> POJO) and serialization (POJO --> JSON).
*/
public class RequiredAdapterFactory implements TypeAdapterFactory {
Comment thread
fst-john marked this conversation as resolved.
Outdated
@Override
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
List<RequiredFieldEntry> entries = buildRequiredFieldEntries(type.getRawType());

if (entries.isEmpty()) {
return delegate;
}

return new TypeAdapter<T>() {
@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<RequiredFieldEntry> buildRequiredFieldEntries(Class<?> clazz) {
List<RequiredFieldEntry> 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 <T> void ensureFieldValidity(T obj, List<RequiredFieldEntry> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ 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) {
Comment thread
fst-john marked this conversation as resolved.
Outdated
GsonBuilder builder = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProperties))
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,8 +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.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;
Expand All @@ -23,8 +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;

public class JSONUtilityTest {

Expand Down Expand Up @@ -153,4 +160,72 @@ public void testGsonFunctionExecutedEventInputValueFactory() {
parsed = f.deserialize(json, FunctionExecutedEvent.InputValue.class, context);
assertThat(parsed.asStringArray(), is(Arrays.asList("C111", "C222")));
}

Comment thread
fst-john marked this conversation as resolved.
@Test
public void testRequiredAdapterFactory_basicCase() {
Gson gson = GsonFactory.getBuilder(true, true)
.registerTypeAdapterFactory(new RequiredAdapterFactory()).create();

// 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 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"));

// 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
@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;

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();
}
}
Comment thread
fst-john marked this conversation as resolved.
}
}
Loading