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