Skip to content

Commit 372fff8

Browse files
authored
Add @required annotation with optional validator (#1558)
* impl * update javadocs * updates * update javadocs * small update * add a couple more test cases * cache FieldPredicate instances upfront * address comments * oops * addressing more comments * update * move packages and use java8 compliant syntax
1 parent 797d1c1 commit 372fff8

File tree

8 files changed

+353
-7
lines changed

8 files changed

+353
-7
lines changed

slack-api-client/src/main/java/com/slack/api/SlackConfig.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ public void setFailOnUnknownProperties(boolean failOnUnknownProperties) {
6262
throwException();
6363
}
6464

65+
@Override
66+
public void setFailOnRequiredProperties(boolean failOnRequiredProperties) { throwException(); }
67+
6568
@Override
6669
public void setPrettyResponseLoggingEnabled(boolean prettyResponseLoggingEnabled) {
6770
throwException();
@@ -248,6 +251,11 @@ public void setLibraryMaintainerMode(boolean libraryMaintainerMode) {
248251
*/
249252
private boolean failOnUnknownProperties = false;
250253

254+
/**
255+
* If you would like to detect required properties by throwing exceptions, set this flag as true.
256+
*/
257+
private boolean failOnRequiredProperties = false;
258+
251259
/**
252260
* Slack Web API client verifies the existence of tokens before sending HTTP requests to Slack servers.
253261
*/

slack-api-client/src/main/java/com/slack/api/util/json/GsonFactory.java

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,17 @@ public static Gson createSnakeCase(SlackConfig config) {
4444
GsonBuilder gsonBuilder = new GsonBuilder()
4545
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES);
4646
registerTypeAdapters(gsonBuilder, failOnUnknownProps);
47+
4748
if (failOnUnknownProps || config.isLibraryMaintainerMode()) {
48-
gsonBuilder = gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory());
49+
gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory());
50+
}
51+
if (config.isFailOnRequiredProperties()) {
52+
gsonBuilder.registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory());
4953
}
5054
if (config.isPrettyResponseLoggingEnabled()) {
51-
gsonBuilder = gsonBuilder.setPrettyPrinting();
55+
gsonBuilder.setPrettyPrinting();
5256
}
57+
5358
return gsonBuilder.create();
5459
}
5560

@@ -60,12 +65,17 @@ public static Gson createCamelCase(SlackConfig config) {
6065
boolean failOnUnknownProps = config.isFailOnUnknownProperties();
6166
GsonBuilder gsonBuilder = new GsonBuilder();
6267
registerTypeAdapters(gsonBuilder, failOnUnknownProps);
68+
6369
if (failOnUnknownProps || config.isLibraryMaintainerMode()) {
64-
gsonBuilder = gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory());
70+
gsonBuilder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory());
71+
}
72+
if (config.isFailOnRequiredProperties()) {
73+
gsonBuilder.registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory());
6574
}
6675
if (config.isPrettyResponseLoggingEnabled()) {
67-
gsonBuilder = gsonBuilder.setPrettyPrinting();
76+
gsonBuilder.setPrettyPrinting();
6877
}
78+
6979
return gsonBuilder.create();
7080
}
7181

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.slack.api.util.annotation;
2+
3+
import com.slack.api.util.predicate.FieldPredicate;
4+
import com.slack.api.util.predicate.IsNotNullFieldPredicate;
5+
import com.slack.api.util.json.RequiredPropertyDetectionAdapterFactory;
6+
7+
import java.lang.annotation.ElementType;
8+
import java.lang.annotation.Retention;
9+
import java.lang.annotation.RetentionPolicy;
10+
import java.lang.annotation.Target;
11+
12+
/**
13+
* Field-level annotation indicating whether the field is a "required" field or not on the model object.
14+
* <p>
15+
* The enforcement of the field's presence in instantiated instances of the model object is accomplished using the
16+
* {@link RequiredPropertyDetectionAdapterFactory} which ensures all fields marked with {@link Required} are
17+
* present during the object deserialization (or serialization) process. Note that the enforcement of this annotation
18+
* is opt-in and defaults to "off".
19+
*/
20+
@Retention(RetentionPolicy.RUNTIME)
21+
@Target(ElementType.FIELD)
22+
public @interface Required {
23+
/**
24+
* Optional predicate to evaluate against the field annotated with {@link Required}. By default, all fields
25+
* marked with {@link Required} are checked for null. Primitive field types are initialized by the JVM, and thus
26+
* are never null by default.
27+
*/
28+
Class<? extends FieldPredicate> validator() default IsNotNullFieldPredicate.class;
29+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package com.slack.api.util.json;
2+
3+
import com.google.gson.JsonParseException;
4+
import com.google.gson.Gson;
5+
import com.google.gson.TypeAdapterFactory;
6+
import com.google.gson.TypeAdapter;
7+
import com.google.gson.reflect.TypeToken;
8+
import com.google.gson.stream.JsonReader;
9+
import com.google.gson.stream.JsonWriter;
10+
import com.slack.api.util.predicate.FieldPredicate;
11+
import com.slack.api.util.annotation.Required;
12+
import lombok.EqualsAndHashCode;
13+
import lombok.RequiredArgsConstructor;
14+
15+
import java.lang.reflect.Field;
16+
import java.lang.reflect.InvocationTargetException;
17+
import java.util.ArrayList;
18+
import java.util.Collections;
19+
import java.util.List;
20+
import java.io.IOException;
21+
22+
/**
23+
* Adapter factory for processing objects annotated with {@link Required}. This annotation signals what properties
24+
* of a model object are required, and thus should be expected to be initialized on instantiated instances. For all
25+
* fields on the model objected annotated with {@link Required} applies the {@link FieldPredicate#validate(Object)} via the
26+
* specified {@link Required#validator()}.
27+
* <p>
28+
* Note that this adapter handles both deserialization (JSON --> POJO) and serialization (POJO --> JSON).
29+
*/
30+
public class RequiredPropertyDetectionAdapterFactory implements TypeAdapterFactory {
31+
@Override
32+
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
33+
TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
34+
List<RequiredFieldEntry> entries = buildRequiredFieldEntries(type.getRawType());
35+
36+
if (entries.isEmpty()) {
37+
return delegate;
38+
}
39+
40+
return new TypeAdapter<T>() {
41+
@Override
42+
public void write(JsonWriter out, T value) throws IOException {
43+
if (value != null) {
44+
ensureFieldValidity(value, entries);
45+
}
46+
delegate.write(out, value);
47+
}
48+
49+
@Override
50+
public T read(JsonReader in) throws IOException {
51+
T result = delegate.read(in);
52+
if (result == null) {
53+
return null;
54+
}
55+
56+
ensureFieldValidity(result, entries);
57+
return result;
58+
}
59+
};
60+
}
61+
62+
/**
63+
* Scans the given class for fields annotated with {@link Required}, pre-resolves each field's
64+
* accessibility and {@link FieldPredicate} instance, and returns an immutable list of entries.
65+
* This is called once per type during Gson adapter creation.
66+
*/
67+
private List<RequiredFieldEntry> buildRequiredFieldEntries(Class<?> clazz) {
68+
List<RequiredFieldEntry> entries = new ArrayList<>();
69+
for (Field field : clazz.getDeclaredFields()) {
70+
Required annotation = field.getAnnotation(Required.class);
71+
if (annotation != null) {
72+
field.setAccessible(true);
73+
try {
74+
FieldPredicate predicate = annotation.validator().getDeclaredConstructor().newInstance();
75+
entries.add(new RequiredFieldEntry(field, predicate));
76+
} catch (NoSuchMethodException | InstantiationException |
77+
IllegalAccessException | InvocationTargetException e) {
78+
throw new JsonParseException(
79+
"Cannot instantiate validator for field: " + field.getName(), e);
80+
}
81+
}
82+
}
83+
return Collections.unmodifiableList(entries);
84+
}
85+
86+
private <T> void ensureFieldValidity(T obj, List<RequiredFieldEntry> entries) {
87+
for (RequiredFieldEntry entry : entries) {
88+
try {
89+
Object value = entry.field.get(obj);
90+
if (!entry.predicate.validate(value)) {
91+
throw new JsonParseException("Required field '" + entry.field.getName()
92+
+ "' failed validation in " + obj.getClass().getSimpleName()
93+
+ " using predicate " + entry.predicate.getClass().getSimpleName());
94+
}
95+
} catch (IllegalAccessException e) {
96+
throw new JsonParseException(
97+
"Cannot access field: " + entry.field.getName(), e);
98+
}
99+
}
100+
}
101+
102+
/**
103+
* Class holding the accessible {@link Field} handle and the cached instance of {@link FieldPredicate}.
104+
*/
105+
@RequiredArgsConstructor
106+
@EqualsAndHashCode
107+
private static class RequiredFieldEntry {
108+
final Field field;
109+
final FieldPredicate predicate;
110+
}
111+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.slack.api.util.predicate;
2+
3+
/**
4+
* A functional interface for defining validation predicates against {@link java.lang.reflect.Field}. Used by
5+
* {@link com.slack.api.util.annotation.Required} during object serialization and deserialization to ensure the
6+
* field member is "valid" per the defined predicate.
7+
*/
8+
@FunctionalInterface
9+
public interface FieldPredicate {
10+
boolean validate(Object obj);
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.slack.api.util.predicate;
2+
3+
import java.util.Objects;
4+
5+
public class IsNotNullFieldPredicate implements FieldPredicate {
6+
@Override
7+
public boolean validate(Object obj) {
8+
return Objects.nonNull(obj);
9+
}
10+
}

slack-api-model/src/test/java/test_locally/unit/GsonFactory.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,19 @@ public static Gson createSnakeCaseWithoutUnknownPropertyDetection(boolean failOn
2727
return createSnakeCase(failOnUnknownProperties, false);
2828
}
2929

30+
public static Gson createSnakeCaseWithRequiredPropertyDetection() {
31+
return createSnakeCase(false, true, true);
32+
}
33+
3034
public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unknownPropertyDetection) {
35+
return createSnakeCase(failOnUnknownProperties, unknownPropertyDetection, false);
36+
}
37+
38+
public static Gson createSnakeCase(
39+
boolean failOnUnknownProperties,
40+
boolean unknownPropertyDetection,
41+
boolean failOnRequiredProperties
42+
) {
3143
GsonBuilder builder = new GsonBuilder()
3244
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
3345
.registerTypeAdapter(File.class, new GsonFileFactory(failOnUnknownProperties))
@@ -45,9 +57,12 @@ public static Gson createSnakeCase(boolean failOnUnknownProperties, boolean unkn
4557
new GsonMessageChangedEventPreviousMessageFactory(failOnUnknownProperties));
4658

4759
if (unknownPropertyDetection) {
48-
return builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory()).create();
49-
} else {
50-
return builder.create();
60+
builder.registerTypeAdapterFactory(new UnknownPropertyDetectionAdapterFactory());
61+
}
62+
if (failOnRequiredProperties) {
63+
builder.registerTypeAdapterFactory(new RequiredPropertyDetectionAdapterFactory());
5164
}
65+
66+
return builder.create();
5267
}
5368
}

0 commit comments

Comments
 (0)