CrestPropertyDetailsSchemaFactoryWrapper.java
/*
* The contents of this file are subject to the terms of the Common Development and
* Distribution License (the License). You may not use this file except in compliance with the
* License.
*
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
* specific language governing permission and limitations under the License.
*
* When distributing Covered Software, include this CDDL Header Notice in each file and include
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2016 ForgeRock AS.
*/
package org.forgerock.api.jackson;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.forgerock.api.jackson.JacksonUtils.OBJECT_MAPPER;
import static org.forgerock.api.util.ValidationUtil.isEmpty;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.math.BigDecimal;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.validation.constraints.DecimalMax;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import org.forgerock.api.annotations.Default;
import org.forgerock.api.annotations.Description;
import org.forgerock.api.annotations.EnumTitle;
import org.forgerock.api.annotations.Example;
import org.forgerock.api.annotations.Format;
import org.forgerock.api.annotations.MultipleOf;
import org.forgerock.api.annotations.PropertyOrder;
import org.forgerock.api.annotations.PropertyPolicies;
import org.forgerock.api.annotations.ReadOnly;
import org.forgerock.api.annotations.Title;
import org.forgerock.api.annotations.UniqueItems;
import org.forgerock.api.enums.WritePolicy;
import org.wrensecurity.guava.common.io.Resources;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonObjectFormatVisitor;
import com.fasterxml.jackson.databind.type.SimpleType;
import com.fasterxml.jackson.module.jsonSchema.JsonSchema;
import com.fasterxml.jackson.module.jsonSchema.factories.ObjectVisitor;
import com.fasterxml.jackson.module.jsonSchema.factories.ObjectVisitorDecorator;
import com.fasterxml.jackson.module.jsonSchema.factories.SchemaFactoryWrapper;
import com.fasterxml.jackson.module.jsonSchema.factories.VisitorContext;
import com.fasterxml.jackson.module.jsonSchema.factories.WrapperFactory;
import com.fasterxml.jackson.module.jsonSchema.types.ArraySchema;
import com.fasterxml.jackson.module.jsonSchema.types.NumberSchema;
import com.fasterxml.jackson.module.jsonSchema.types.ObjectSchema;
import com.fasterxml.jackson.module.jsonSchema.types.SimpleTypeSchema;
import com.fasterxml.jackson.module.jsonSchema.types.StringSchema;
import org.forgerock.api.annotations.AdditionalProperties;
/**
* A {@code SchemaFactoryWrapper} that adds the extra CREST schema attributes once the Jackson schema generation has
* been completed.
*/
public class CrestPropertyDetailsSchemaFactoryWrapper extends SchemaFactoryWrapper {
private static final WrapperFactory WRAPPER_FACTORY = new WrapperFactory() {
@Override
public SchemaFactoryWrapper getWrapper(SerializerProvider provider) {
SchemaFactoryWrapper wrapper = new CrestPropertyDetailsSchemaFactoryWrapper();
wrapper.setProvider(provider);
return wrapper;
}
@Override
public SchemaFactoryWrapper getWrapper(SerializerProvider provider, VisitorContext rvc) {
SchemaFactoryWrapper wrapper = new CrestPropertyDetailsSchemaFactoryWrapper();
wrapper.setProvider(provider);
wrapper.setVisitorContext(rvc);
return wrapper;
}
};
private static final String CLASSPATH_RESOURCE = "classpath:";
/**
* Create a new wrapper. Sets the {@link CrestJsonSchemaFactory} in the parent class's {@code schemaProvider} so
* that all of the schema objects that are created support the appropriate API Descriptor extensions.
*/
public CrestPropertyDetailsSchemaFactoryWrapper() {
super(WRAPPER_FACTORY);
this.schemaProvider = new CrestJsonSchemaFactory();
}
@Override
public JsonObjectFormatVisitor expectObjectFormat(JavaType convertedType) {
final ObjectVisitor objectVisitor = (ObjectVisitor) super.expectObjectFormat(convertedType);
final Class<?> clazz = convertedType.getRawClass();
// look for type/class-level annotations
if (schema instanceof SimpleTypeSchema) {
final Title title = clazz.getAnnotation(Title.class);
if (title != null && !isEmpty(title.value())) {
((SimpleTypeSchema) schema).setTitle(title.value());
}
}
final Description description = clazz.getAnnotation(Description.class);
if (description != null && !isEmpty(description.value())) {
schema.setDescription(description.value());
}
final Set<String> requiredFieldNames;
if (schema instanceof RequiredFieldsSchema) {
requiredFieldNames = Collections.synchronizedSet(new HashSet<String>());
((RequiredFieldsSchema) schema).setRequiredFields(requiredFieldNames);
} else {
requiredFieldNames = null;
}
final Example example = clazz.getAnnotation(Example.class);
if (schema instanceof WithExampleSchema && example != null && !isEmpty(example.value())) {
setExample(clazz, example, (WithExampleSchema<?>) schema);
}
// look for field/parameter/method-level annotations
return new ObjectVisitorDecorator(objectVisitor) {
@Override
public JsonSchema getSchema() {
return super.getSchema();
}
@Override
public void optionalProperty(BeanProperty writer) throws JsonMappingException {
super.optionalProperty(writer);
JsonSchema schema = schemaFor(writer);
addFieldPolicies(writer, schema);
addPropertyOrder(writer, schema);
addEnumTitles(writer, schema);
addRequired(writer, schema);
addStringPattern(writer, schema);
addStringMinLength(writer, schema);
addStringMaxLength(writer, schema);
addArrayMinItems(writer, schema);
addArrayMaxItems(writer, schema);
addNumberMaximum(writer, schema);
addNumberMinimum(writer, schema);
addNumberExclusiveMinimum(writer, schema);
addNumberExclusiveMaximum(writer, schema);
addReadOnly(writer, schema);
addTitle(writer, schema);
addDescription(writer, schema);
addDefault(writer, schema);
addUniqueItems(writer, schema);
addMultipleOf(writer, schema);
addFormat(writer, schema);
addExample(writer, schema);
addAdditionalProperties(writer, schema);
}
private void addExample(BeanProperty writer, JsonSchema schema) {
Example annotation = annotationFor(writer, Example.class);
if (annotation != null) {
WithExampleSchema<?> exampleSchema = (WithExampleSchema<?>) schema;
setExample(writer.getType().getRawClass(), annotation, exampleSchema);
}
}
private void addEnumTitles(BeanProperty writer, JsonSchema schema) {
JavaType type = writer.getType();
if (type.isEnumType()) {
Class<? extends Enum> enumClass = type.getRawClass().asSubclass(Enum.class);
Enum[] enumConstants = enumClass.getEnumConstants();
List<String> titles = new ArrayList<>(enumConstants.length);
boolean foundTitle = false;
for (Enum<?> value : enumConstants) {
try {
EnumTitle title = enumClass.getField(value.name()).getAnnotation(EnumTitle.class);
if (title != null) {
titles.add(title.value());
foundTitle = true;
} else {
titles.add(null);
}
} catch (NoSuchFieldException e) {
throw new IllegalStateException("Enum doesn't have its own value as a field", e);
}
}
if (foundTitle) {
((EnumSchema) schema).setEnumTitles(titles);
}
}
}
private void addPropertyOrder(BeanProperty writer, JsonSchema schema) {
PropertyOrder order = annotationFor(writer, PropertyOrder.class);
if (order != null) {
((OrderedFieldSchema) schema).setPropertyOrder(order.value());
}
}
private void addFieldPolicies(BeanProperty writer, JsonSchema schema) {
PropertyPolicies policies = annotationFor(writer, PropertyPolicies.class);
if (policies != null) {
CrestReadWritePoliciesSchema schemaPolicies = (CrestReadWritePoliciesSchema) schema;
if (policies.write() != WritePolicy.WRITABLE) {
schemaPolicies.setWritePolicy(policies.write());
schemaPolicies.setErrorOnWritePolicyFailure(policies.errorOnWritePolicyFailure());
}
schemaPolicies.setReadPolicy(policies.read());
schemaPolicies.setReturnOnDemand(policies.returnOnDemand());
}
}
private void addRequired(BeanProperty writer, JsonSchema schema) {
NotNull notNull = annotationFor(writer, NotNull.class);
if (notNull != null) {
if (requiredFieldNames != null) {
requiredFieldNames.add(writer.getName());
} else {
// NOTE: this condition may never happen, but is here to deal with unknown edge cases
schema.setRequired(true);
}
}
}
private void addStringPattern(BeanProperty writer, JsonSchema schema) {
Pattern pattern = annotationFor(writer, Pattern.class);
if (pattern != null && !isEmpty(pattern.regexp())) {
((StringSchema) schema).setPattern(pattern.regexp());
}
}
private void addStringMinLength(BeanProperty writer, JsonSchema schema) {
Integer size = getMinSize(writer);
if (size != null && schema instanceof StringSchema) {
((StringSchema) schema).setMinLength(size);
}
}
private void addStringMaxLength(BeanProperty writer, JsonSchema schema) {
Integer size = getMaxSize(writer);
if (size != null && schema instanceof StringSchema) {
((StringSchema) schema).setMaxLength(size);
}
}
private void addArrayMinItems(BeanProperty writer, JsonSchema schema) {
Integer size = getMinSize(writer);
if (size != null && schema instanceof ArraySchema) {
((ArraySchema) schema).setMinItems(size);
}
}
private void addArrayMaxItems(BeanProperty writer, JsonSchema schema) {
Integer size = getMaxSize(writer);
if (size != null && schema instanceof ArraySchema) {
((ArraySchema) schema).setMaxItems(size);
}
}
private void addNumberMinimum(BeanProperty writer, JsonSchema schema) {
Min min = annotationFor(writer, Min.class);
if (min != null) {
((MinimumMaximumSchema) schema).setPropertyMinimum(new BigDecimal(min.value()));
}
DecimalMin decimalMin = annotationFor(writer, DecimalMin.class);
if (decimalMin != null) {
((MinimumMaximumSchema) schema).setPropertyMinimum(new BigDecimal(decimalMin.value()));
}
}
private void addNumberMaximum(BeanProperty writer, JsonSchema schema) {
Max max = annotationFor(writer, Max.class);
if (max != null) {
((MinimumMaximumSchema) schema).setPropertyMaximum(new BigDecimal(max.value()));
}
DecimalMax decimalMax = annotationFor(writer, DecimalMax.class);
if (decimalMax != null) {
((MinimumMaximumSchema) schema).setPropertyMaximum(new BigDecimal(decimalMax.value()));
}
}
private void addNumberExclusiveMinimum(BeanProperty writer, JsonSchema schema) {
DecimalMin decimalMin = annotationFor(writer, DecimalMin.class);
if (decimalMin != null && !decimalMin.inclusive()) {
((NumberSchema) schema).setExclusiveMinimum(true);
}
}
private void addNumberExclusiveMaximum(BeanProperty writer, JsonSchema schema) {
DecimalMax decimalMax = annotationFor(writer, DecimalMax.class);
if (decimalMax != null && !decimalMax.inclusive()) {
((NumberSchema) schema).setExclusiveMaximum(true);
}
}
private void addReadOnly(BeanProperty writer, JsonSchema schema) {
ReadOnly readOnly = annotationFor(writer, ReadOnly.class);
if (readOnly != null) {
schema.setReadonly(readOnly.value());
}
}
private void addTitle(BeanProperty writer, JsonSchema schema) {
Title title = annotationFor(writer, Title.class);
if (title != null && !isEmpty(title.value())) {
((SimpleTypeSchema) schema).setTitle(title.value());
}
}
private void addDescription(BeanProperty writer, JsonSchema schema) {
Description description = annotationFor(writer, Description.class);
if (description != null && !isEmpty(description.value())) {
schema.setDescription(description.value());
}
}
private void addDefault(BeanProperty writer, JsonSchema schema) {
Default defaultAnnotation = annotationFor(writer, Default.class);
if (defaultAnnotation != null && !isEmpty(defaultAnnotation.value())) {
((SimpleTypeSchema) schema).setDefault(defaultAnnotation.value());
}
}
private void addUniqueItems(BeanProperty writer, JsonSchema schema) {
UniqueItems uniqueItems = annotationFor(writer, UniqueItems.class);
if (uniqueItems != null) {
((ArraySchema) schema).setUniqueItems(uniqueItems.value());
}
}
private void addMultipleOf(BeanProperty writer, JsonSchema schema) {
MultipleOf multipleOf = annotationFor(writer, MultipleOf.class);
if (multipleOf != null) {
((MultipleOfSchema) schema).setMultipleOf(multipleOf.value());
}
}
private void addFormat(BeanProperty writer, JsonSchema schema) {
if (schema instanceof PropertyFormatSchema) {
Format format = annotationFor(writer, Format.class);
if (format != null && !isEmpty(format.value())) {
((PropertyFormatSchema) schema).setPropertyFormat(format.value());
} else if (writer.getType() instanceof SimpleType) {
// automatically assign 'format' to numeric types
final Class rawClass = writer.getType().getRawClass();
final String formatValue;
if (Integer.class.equals(rawClass) || int.class.equals(rawClass)) {
formatValue = "int32";
} else if (Long.class.equals(rawClass) || long.class.equals(rawClass)) {
formatValue = "int64";
} else if (Double.class.equals(rawClass) || double.class.equals(rawClass)) {
formatValue = "double";
} else if (Float.class.equals(rawClass) || float.class.equals(rawClass)) {
formatValue = "float";
} else {
return;
}
((PropertyFormatSchema) schema).setPropertyFormat(formatValue);
}
}
}
private void addAdditionalProperties(BeanProperty writer, JsonSchema schema) throws JsonMappingException {
AdditionalProperties additionalProperties = annotationFor(writer, AdditionalProperties.class);
if (additionalProperties != null && !additionalProperties.value().isInstance(Void.class)) {
CrestPropertyDetailsSchemaFactoryWrapper visitor = new CrestPropertyDetailsSchemaFactoryWrapper();
OBJECT_MAPPER.acceptJsonFormatVisitor(additionalProperties.value(), visitor);
ObjectSchema.SchemaAdditionalProperties schemaAdditionalProperties =
new ObjectSchema.SchemaAdditionalProperties(visitor.finalSchema());
((ObjectSchema) schema).setAdditionalProperties(schemaAdditionalProperties);
}
}
private Integer getMaxSize(BeanProperty writer) {
Size size = writer.getAnnotation(Size.class);
if (size != null) {
int value = size.max();
if (value != Integer.MAX_VALUE) {
return value;
}
}
return null;
}
private Integer getMinSize(BeanProperty writer) {
Size size = writer.getAnnotation(Size.class);
if (size != null) {
int value = size.min();
if (value != 0) {
return value;
}
}
return null;
}
/**
* Looks for annotations at the field/method/parameter-level of a Java class.
*
* @param writer Jackson {@code BeanProperty} representing the object-instance to scan for annotations
* @param type Annotation class to find
* @param <T> Annotation type to find
* @return Annotation or {@code null}
*/
private <T extends Annotation> T annotationFor(BeanProperty writer, Class<T> type) {
return writer.getMember().getAnnotation(type);
}
private JsonSchema schemaFor(BeanProperty writer) {
return getSchema().asObjectSchema().getProperties().get(writer.getName());
}
};
}
private void setExample(Class<?> contextClass, Example annotation, WithExampleSchema<?> exampleSchema) {
String example = annotation.value();
if (example.startsWith(CLASSPATH_RESOURCE)) {
ClassLoader classLoader = contextClass.getClassLoader();
try {
String name = example.substring(CLASSPATH_RESOURCE.length()).trim();
URL resource = classLoader.getResource(name);
if (resource != null) {
example = Resources.toString(resource, UTF_8);
} else {
throw new IllegalStateException("Cannot read resource: " + example);
}
} catch (IOException e) {
throw new IllegalStateException("Cannot read resource: " + example, e);
}
}
try {
exampleSchema.setExample(example);
} catch (IOException e) {
throw new IllegalArgumentException("Could not parse example value to type of schema", e);
}
}
}