Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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(); }

@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.
*/
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());
}
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());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.slack.api.model.annotation;

public interface FieldPredicate {
boolean test(Object obj);
}
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,69 @@
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 java.lang.reflect.InvocationTargetException;
import java.util.Arrays;
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 {
@Override
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);

return new TypeAdapter<T>() {
@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 <T> void ensureFieldValidity(T obj) {
Arrays.asList(obj.getClass().getDeclaredFields()).forEach(field -> {
if (field.isAnnotationPresent(Required.class)) {
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);
}
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
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,62 @@ public void testGsonFunctionExecutedEventInputValueFactory() {
parsed = f.deserialize(json, FunctionExecutedEvent.InputValue.class, context);
assertThat(parsed.asStringArray(), is(Arrays.asList("C111", "C222")));
}

@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"));
}

@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();
}
}
}
}
Loading