View Javadoc
1   /*
2    * The contents of this file are subject to the terms of the Common Development and
3    * Distribution License (the License). You may not use this file except in compliance with the
4    * License.
5    *
6    * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7    * specific language governing permission and limitations under the License.
8    *
9    * When distributing Covered Software, include this CDDL Header Notice in each file and include
10   * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11   * Header, with the fields enclosed by brackets [] replaced by your own identifying
12   * information: "Portions copyright [year] [name of copyright owner]".
13   *
14   * Copyright 2016 ForgeRock AS.
15   */
16  
17  package org.forgerock.api.jackson;
18  
19  import static java.nio.charset.StandardCharsets.UTF_8;
20  import static org.forgerock.api.jackson.JacksonUtils.OBJECT_MAPPER;
21  import static org.forgerock.api.util.ValidationUtil.isEmpty;
22  
23  import java.io.IOException;
24  import java.lang.annotation.Annotation;
25  import java.math.BigDecimal;
26  import java.net.URL;
27  import java.util.ArrayList;
28  import java.util.Collections;
29  import java.util.HashSet;
30  import java.util.List;
31  import java.util.Set;
32  
33  import javax.validation.constraints.DecimalMax;
34  import javax.validation.constraints.DecimalMin;
35  import javax.validation.constraints.Max;
36  import javax.validation.constraints.Min;
37  import javax.validation.constraints.NotNull;
38  import javax.validation.constraints.Pattern;
39  import javax.validation.constraints.Size;
40  
41  import org.forgerock.api.annotations.Default;
42  import org.forgerock.api.annotations.Description;
43  import org.forgerock.api.annotations.EnumTitle;
44  import org.forgerock.api.annotations.Example;
45  import org.forgerock.api.annotations.Format;
46  import org.forgerock.api.annotations.MultipleOf;
47  import org.forgerock.api.annotations.PropertyOrder;
48  import org.forgerock.api.annotations.PropertyPolicies;
49  import org.forgerock.api.annotations.ReadOnly;
50  import org.forgerock.api.annotations.Title;
51  import org.forgerock.api.annotations.UniqueItems;
52  import org.forgerock.api.enums.WritePolicy;
53  import org.wrensecurity.guava.common.io.Resources;
54  
55  import com.fasterxml.jackson.databind.BeanProperty;
56  import com.fasterxml.jackson.databind.JavaType;
57  import com.fasterxml.jackson.databind.JsonMappingException;
58  import com.fasterxml.jackson.databind.SerializerProvider;
59  import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonObjectFormatVisitor;
60  import com.fasterxml.jackson.databind.type.SimpleType;
61  import com.fasterxml.jackson.module.jsonSchema.JsonSchema;
62  import com.fasterxml.jackson.module.jsonSchema.factories.ObjectVisitor;
63  import com.fasterxml.jackson.module.jsonSchema.factories.ObjectVisitorDecorator;
64  import com.fasterxml.jackson.module.jsonSchema.factories.SchemaFactoryWrapper;
65  import com.fasterxml.jackson.module.jsonSchema.factories.VisitorContext;
66  import com.fasterxml.jackson.module.jsonSchema.factories.WrapperFactory;
67  import com.fasterxml.jackson.module.jsonSchema.types.ArraySchema;
68  import com.fasterxml.jackson.module.jsonSchema.types.NumberSchema;
69  import com.fasterxml.jackson.module.jsonSchema.types.ObjectSchema;
70  import com.fasterxml.jackson.module.jsonSchema.types.SimpleTypeSchema;
71  import com.fasterxml.jackson.module.jsonSchema.types.StringSchema;
72  import org.forgerock.api.annotations.AdditionalProperties;
73  
74  /**
75   * A {@code SchemaFactoryWrapper} that adds the extra CREST schema attributes once the Jackson schema generation has
76   * been completed.
77   */
78  public class CrestPropertyDetailsSchemaFactoryWrapper extends SchemaFactoryWrapper {
79  
80      private static final WrapperFactory WRAPPER_FACTORY = new WrapperFactory() {
81          @Override
82          public SchemaFactoryWrapper getWrapper(SerializerProvider provider) {
83              SchemaFactoryWrapper wrapper = new CrestPropertyDetailsSchemaFactoryWrapper();
84              wrapper.setProvider(provider);
85              return wrapper;
86          }
87  
88          @Override
89          public SchemaFactoryWrapper getWrapper(SerializerProvider provider, VisitorContext rvc) {
90              SchemaFactoryWrapper wrapper = new CrestPropertyDetailsSchemaFactoryWrapper();
91              wrapper.setProvider(provider);
92              wrapper.setVisitorContext(rvc);
93              return wrapper;
94          }
95      };
96      private static final String CLASSPATH_RESOURCE = "classpath:";
97  
98      /**
99       * Create a new wrapper. Sets the {@link CrestJsonSchemaFactory} in the parent class's {@code schemaProvider} so
100      * that all of the schema objects that are created support the appropriate API Descriptor extensions.
101      */
102     public CrestPropertyDetailsSchemaFactoryWrapper() {
103         super(WRAPPER_FACTORY);
104         this.schemaProvider = new CrestJsonSchemaFactory();
105     }
106 
107     @Override
108     public JsonObjectFormatVisitor expectObjectFormat(JavaType convertedType) {
109         final ObjectVisitor objectVisitor = (ObjectVisitor) super.expectObjectFormat(convertedType);
110         final Class<?> clazz = convertedType.getRawClass();
111 
112         // look for type/class-level annotations
113         if (schema instanceof SimpleTypeSchema) {
114             final Title title = clazz.getAnnotation(Title.class);
115             if (title != null && !isEmpty(title.value())) {
116                 ((SimpleTypeSchema) schema).setTitle(title.value());
117             }
118         }
119 
120         final Description description = clazz.getAnnotation(Description.class);
121         if (description != null && !isEmpty(description.value())) {
122             schema.setDescription(description.value());
123         }
124 
125         final Set<String> requiredFieldNames;
126         if (schema instanceof RequiredFieldsSchema) {
127             requiredFieldNames = Collections.synchronizedSet(new HashSet<String>());
128             ((RequiredFieldsSchema) schema).setRequiredFields(requiredFieldNames);
129         } else {
130             requiredFieldNames = null;
131         }
132 
133         final Example example = clazz.getAnnotation(Example.class);
134         if (schema instanceof WithExampleSchema && example != null && !isEmpty(example.value())) {
135             setExample(clazz, example, (WithExampleSchema<?>) schema);
136         }
137 
138         // look for field/parameter/method-level annotations
139         return new ObjectVisitorDecorator(objectVisitor) {
140             @Override
141             public JsonSchema getSchema() {
142                 return super.getSchema();
143             }
144 
145             @Override
146             public void optionalProperty(BeanProperty writer) throws JsonMappingException {
147                 super.optionalProperty(writer);
148                 JsonSchema schema = schemaFor(writer);
149                 addFieldPolicies(writer, schema);
150                 addPropertyOrder(writer, schema);
151                 addEnumTitles(writer, schema);
152                 addRequired(writer, schema);
153                 addStringPattern(writer, schema);
154                 addStringMinLength(writer, schema);
155                 addStringMaxLength(writer, schema);
156                 addArrayMinItems(writer, schema);
157                 addArrayMaxItems(writer, schema);
158                 addNumberMaximum(writer, schema);
159                 addNumberMinimum(writer, schema);
160                 addNumberExclusiveMinimum(writer, schema);
161                 addNumberExclusiveMaximum(writer, schema);
162                 addReadOnly(writer, schema);
163                 addTitle(writer, schema);
164                 addDescription(writer, schema);
165                 addDefault(writer, schema);
166                 addUniqueItems(writer, schema);
167                 addMultipleOf(writer, schema);
168                 addFormat(writer, schema);
169                 addExample(writer, schema);
170                 addAdditionalProperties(writer, schema);
171             }
172 
173             private void addExample(BeanProperty writer, JsonSchema schema) {
174                 Example annotation = annotationFor(writer, Example.class);
175                 if (annotation != null) {
176                     WithExampleSchema<?> exampleSchema = (WithExampleSchema<?>) schema;
177                     setExample(writer.getType().getRawClass(), annotation, exampleSchema);
178                 }
179             }
180 
181             private void addEnumTitles(BeanProperty writer, JsonSchema schema) {
182                 JavaType type = writer.getType();
183                 if (type.isEnumType()) {
184                     Class<? extends Enum> enumClass = type.getRawClass().asSubclass(Enum.class);
185                     Enum[] enumConstants = enumClass.getEnumConstants();
186                     List<String> titles = new ArrayList<>(enumConstants.length);
187                     boolean foundTitle = false;
188                     for (Enum<?> value : enumConstants) {
189                         try {
190                             EnumTitle title = enumClass.getField(value.name()).getAnnotation(EnumTitle.class);
191                             if (title != null) {
192                                 titles.add(title.value());
193                                 foundTitle = true;
194                             } else {
195                                 titles.add(null);
196                             }
197                         } catch (NoSuchFieldException e) {
198                             throw new IllegalStateException("Enum doesn't have its own value as a field", e);
199                         }
200                     }
201                     if (foundTitle) {
202                         ((EnumSchema) schema).setEnumTitles(titles);
203                     }
204                 }
205             }
206 
207             private void addPropertyOrder(BeanProperty writer, JsonSchema schema) {
208                 PropertyOrder order = annotationFor(writer, PropertyOrder.class);
209                 if (order != null) {
210                     ((OrderedFieldSchema) schema).setPropertyOrder(order.value());
211                 }
212             }
213 
214             private void addFieldPolicies(BeanProperty writer, JsonSchema schema) {
215                 PropertyPolicies policies = annotationFor(writer, PropertyPolicies.class);
216                 if (policies != null) {
217                     CrestReadWritePoliciesSchema schemaPolicies = (CrestReadWritePoliciesSchema) schema;
218                     if (policies.write() != WritePolicy.WRITABLE) {
219                         schemaPolicies.setWritePolicy(policies.write());
220                         schemaPolicies.setErrorOnWritePolicyFailure(policies.errorOnWritePolicyFailure());
221                     }
222                     schemaPolicies.setReadPolicy(policies.read());
223                     schemaPolicies.setReturnOnDemand(policies.returnOnDemand());
224                 }
225             }
226 
227             private void addRequired(BeanProperty writer, JsonSchema schema) {
228                 NotNull notNull = annotationFor(writer, NotNull.class);
229                 if (notNull != null) {
230                     if (requiredFieldNames != null) {
231                         requiredFieldNames.add(writer.getName());
232                     } else {
233                         // NOTE: this condition may never happen, but is here to deal with unknown edge cases
234                         schema.setRequired(true);
235                     }
236                 }
237             }
238 
239             private void addStringPattern(BeanProperty writer, JsonSchema schema) {
240                 Pattern pattern = annotationFor(writer, Pattern.class);
241                 if (pattern != null && !isEmpty(pattern.regexp())) {
242                     ((StringSchema) schema).setPattern(pattern.regexp());
243                 }
244             }
245 
246             private void addStringMinLength(BeanProperty writer, JsonSchema schema) {
247                 Integer size = getMinSize(writer);
248                 if (size != null && schema instanceof StringSchema) {
249                     ((StringSchema) schema).setMinLength(size);
250                 }
251             }
252 
253             private void addStringMaxLength(BeanProperty writer, JsonSchema schema) {
254                 Integer size = getMaxSize(writer);
255                 if (size != null && schema instanceof StringSchema) {
256                     ((StringSchema) schema).setMaxLength(size);
257                 }
258             }
259 
260             private void addArrayMinItems(BeanProperty writer, JsonSchema schema) {
261                 Integer size = getMinSize(writer);
262                 if (size != null && schema instanceof ArraySchema) {
263                     ((ArraySchema) schema).setMinItems(size);
264                 }
265             }
266 
267             private void addArrayMaxItems(BeanProperty writer, JsonSchema schema) {
268                 Integer size = getMaxSize(writer);
269                 if (size != null && schema instanceof ArraySchema) {
270                     ((ArraySchema) schema).setMaxItems(size);
271                 }
272             }
273 
274             private void addNumberMinimum(BeanProperty writer, JsonSchema schema) {
275                 Min min = annotationFor(writer, Min.class);
276                 if (min != null) {
277                     ((MinimumMaximumSchema) schema).setPropertyMinimum(new BigDecimal(min.value()));
278                 }
279 
280                 DecimalMin decimalMin = annotationFor(writer, DecimalMin.class);
281                 if (decimalMin != null) {
282                     ((MinimumMaximumSchema) schema).setPropertyMinimum(new BigDecimal(decimalMin.value()));
283                 }
284             }
285 
286             private void addNumberMaximum(BeanProperty writer, JsonSchema schema) {
287                 Max max = annotationFor(writer, Max.class);
288                 if (max != null) {
289                     ((MinimumMaximumSchema) schema).setPropertyMaximum(new BigDecimal(max.value()));
290                 }
291 
292                 DecimalMax decimalMax = annotationFor(writer, DecimalMax.class);
293                 if (decimalMax != null) {
294                     ((MinimumMaximumSchema) schema).setPropertyMaximum(new BigDecimal(decimalMax.value()));
295                 }
296             }
297 
298             private void addNumberExclusiveMinimum(BeanProperty writer, JsonSchema schema) {
299                 DecimalMin decimalMin = annotationFor(writer, DecimalMin.class);
300                 if (decimalMin != null && !decimalMin.inclusive()) {
301                     ((NumberSchema) schema).setExclusiveMinimum(true);
302                 }
303             }
304 
305             private void addNumberExclusiveMaximum(BeanProperty writer, JsonSchema schema) {
306                 DecimalMax decimalMax = annotationFor(writer, DecimalMax.class);
307                 if (decimalMax != null && !decimalMax.inclusive()) {
308                     ((NumberSchema) schema).setExclusiveMaximum(true);
309                 }
310             }
311 
312             private void addReadOnly(BeanProperty writer, JsonSchema schema) {
313                 ReadOnly readOnly = annotationFor(writer, ReadOnly.class);
314                 if (readOnly != null) {
315                     schema.setReadonly(readOnly.value());
316                 }
317             }
318 
319             private void addTitle(BeanProperty writer, JsonSchema schema) {
320                 Title title = annotationFor(writer, Title.class);
321                 if (title != null && !isEmpty(title.value())) {
322                     ((SimpleTypeSchema) schema).setTitle(title.value());
323                 }
324             }
325 
326             private void addDescription(BeanProperty writer, JsonSchema schema) {
327                 Description description = annotationFor(writer, Description.class);
328                 if (description != null && !isEmpty(description.value())) {
329                     schema.setDescription(description.value());
330                 }
331             }
332 
333             private void addDefault(BeanProperty writer, JsonSchema schema) {
334                 Default defaultAnnotation = annotationFor(writer, Default.class);
335                 if (defaultAnnotation != null && !isEmpty(defaultAnnotation.value())) {
336                     ((SimpleTypeSchema) schema).setDefault(defaultAnnotation.value());
337                 }
338             }
339 
340             private void addUniqueItems(BeanProperty writer, JsonSchema schema) {
341                 UniqueItems uniqueItems = annotationFor(writer, UniqueItems.class);
342                 if (uniqueItems != null) {
343                     ((ArraySchema) schema).setUniqueItems(uniqueItems.value());
344                 }
345             }
346 
347             private void addMultipleOf(BeanProperty writer, JsonSchema schema) {
348                 MultipleOf multipleOf = annotationFor(writer, MultipleOf.class);
349                 if (multipleOf != null) {
350                     ((MultipleOfSchema) schema).setMultipleOf(multipleOf.value());
351                 }
352             }
353 
354             private void addFormat(BeanProperty writer, JsonSchema schema) {
355                 if (schema instanceof PropertyFormatSchema) {
356                     Format format = annotationFor(writer, Format.class);
357                     if (format != null && !isEmpty(format.value())) {
358                         ((PropertyFormatSchema) schema).setPropertyFormat(format.value());
359                     } else if (writer.getType() instanceof SimpleType) {
360                         // automatically assign 'format' to numeric types
361                         final Class rawClass = writer.getType().getRawClass();
362                         final String formatValue;
363                         if (Integer.class.equals(rawClass) || int.class.equals(rawClass)) {
364                             formatValue = "int32";
365                         } else if (Long.class.equals(rawClass) || long.class.equals(rawClass)) {
366                             formatValue = "int64";
367                         } else if (Double.class.equals(rawClass) || double.class.equals(rawClass)) {
368                             formatValue = "double";
369                         } else if (Float.class.equals(rawClass) || float.class.equals(rawClass)) {
370                             formatValue = "float";
371                         } else {
372                             return;
373                         }
374                         ((PropertyFormatSchema) schema).setPropertyFormat(formatValue);
375                     }
376                 }
377             }
378 
379             private void addAdditionalProperties(BeanProperty writer, JsonSchema schema) throws JsonMappingException {
380                 AdditionalProperties additionalProperties = annotationFor(writer, AdditionalProperties.class);
381                 if (additionalProperties != null && !additionalProperties.value().isInstance(Void.class)) {
382                     CrestPropertyDetailsSchemaFactoryWrapper visitor = new CrestPropertyDetailsSchemaFactoryWrapper();
383                     OBJECT_MAPPER.acceptJsonFormatVisitor(additionalProperties.value(), visitor);
384                     ObjectSchema.SchemaAdditionalProperties schemaAdditionalProperties =
385                             new ObjectSchema.SchemaAdditionalProperties(visitor.finalSchema());
386                     ((ObjectSchema) schema).setAdditionalProperties(schemaAdditionalProperties);
387                 }
388             }
389 
390             private Integer getMaxSize(BeanProperty writer) {
391                 Size size = writer.getAnnotation(Size.class);
392                 if (size != null) {
393                     int value = size.max();
394                     if (value != Integer.MAX_VALUE) {
395                         return value;
396                     }
397                 }
398                 return null;
399             }
400 
401             private Integer getMinSize(BeanProperty writer) {
402                 Size size = writer.getAnnotation(Size.class);
403                 if (size != null) {
404                     int value = size.min();
405                     if (value != 0) {
406                         return value;
407                     }
408                 }
409                 return null;
410             }
411 
412             /**
413              * Looks for annotations at the field/method/parameter-level of a Java class.
414              *
415              * @param writer Jackson {@code BeanProperty} representing the object-instance to scan for annotations
416              * @param type Annotation class to find
417              * @param <T> Annotation type to find
418              * @return Annotation or {@code null}
419              */
420             private <T extends Annotation> T annotationFor(BeanProperty writer, Class<T> type) {
421                 return writer.getMember().getAnnotation(type);
422             }
423 
424             private JsonSchema schemaFor(BeanProperty writer) {
425                 return getSchema().asObjectSchema().getProperties().get(writer.getName());
426             }
427         };
428     }
429 
430     private void setExample(Class<?> contextClass, Example annotation, WithExampleSchema<?> exampleSchema) {
431         String example = annotation.value();
432         if (example.startsWith(CLASSPATH_RESOURCE)) {
433             ClassLoader classLoader = contextClass.getClassLoader();
434             try {
435                 String name = example.substring(CLASSPATH_RESOURCE.length()).trim();
436                 URL resource = classLoader.getResource(name);
437                 if (resource != null) {
438                     example = Resources.toString(resource, UTF_8);
439                 } else {
440                     throw new IllegalStateException("Cannot read resource: " + example);
441                 }
442             } catch (IOException e) {
443                 throw new IllegalStateException("Cannot read resource: " + example, e);
444             }
445         }
446         try {
447             exampleSchema.setExample(example);
448         } catch (IOException e) {
449             throw new IllegalArgumentException("Could not parse example value to type of schema", e);
450         }
451     }
452 }