DATAREST-354 - General rewrite of the JSONSchema support.

Significant overhaul of the JSONSchema support. This currently adds the following features:

- Complex nested types are exposed as descriptors with the properties pointing to them whenever necessary.
- Sets are treated as unique collections.
- Enums are handled as expected (enum values are listed).
- Renamings via @JsonProperty are considered.
- @JsonProperty(required = true) is considered and added to required properties.
- Date/time types (legacy Date, JSR-310, ThreeTenBP and Joda Time) are exposed with format "date-time".
- Objects with @JsonValue methods are considered to be rendered as String value.
- Formats and patterns can be manually configured on MetadataConfiguration.

TODOs:

- Implementation polish, JavaDoc
- Automatically inspect ObjectMapper to detect customizations made through Mixins and custom Serializers.
This commit is contained in:
Oliver Gierke
2015-02-06 09:36:26 +01:00
parent ed5bde10e7
commit def74e6618
14 changed files with 834 additions and 118 deletions

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.core.config;
import java.util.Locale;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* An enum to represent JSON Schema pre-defined formats.
*
* @author Oliver Gierke
* @since 2.3
*/
public enum JsonSchemaFormat {
EMAIL, DATE_TIME, HOSTNAME, IPV4, IPV6, URI;
/*
* (non-Javadoc)
* @see java.lang.Enum#toString()
*/
@JsonValue
public String toString() {
return name().toLowerCase(Locale.US).replaceAll("_", "-");
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2014 the original author or authors.
* Copyright 2014-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,6 +15,13 @@
*/
package org.springframework.data.rest.core.config;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.regex.Pattern;
import org.springframework.util.Assert;
/**
* Configuration for metadata exposure.
*
@@ -22,6 +29,8 @@ package org.springframework.data.rest.core.config;
*/
public class MetadataConfiguration {
private final Map<Class<?>, JsonSchemaFormat> schemaFormats = new HashMap<Class<?>, JsonSchemaFormat>();
private final Map<Class<?>, Pattern> patterns = new HashMap<Class<?>, Pattern>();
private boolean omitUnresolvableDescriptionKeys = true;
private boolean alpsEnabled = true;
@@ -63,4 +72,56 @@ public class MetadataConfiguration {
public boolean alpsEnabled() {
return alpsEnabled;
}
public void registerJsonSchemaFormat(JsonSchemaFormat format, Class<?>... types) {
Assert.notNull(format, "JsonSchemaFormat must not be null!");
for (Class<?> type : types) {
schemaFormats.put(type, format);
}
}
/**
* Returns the {@link JsonSchemaFormat} to be used for the given type.
*
* @param type must not be {@literal null}.
* @return
*/
public JsonSchemaFormat getSchemaFormatFor(Class<?> type) {
return schemaFormats.get(type);
}
/**
* Registers the given formatting patter for the given value type.
*
* @param pattern must not be {@literal null} or empty.
* @param type must not be {@literal null}.
*/
public void registerFormattingPatternFor(String pattern, Class<?> type) {
Assert.hasText(pattern, "Pattern must not be null or empty!");
Assert.notNull(type, "Type must not be null!");
this.patterns.put(type, Pattern.compile(pattern));
}
/**
* Returns the {@link Pattern} registered for the given value type.
*
* @param type must not be {@literal null}.
* @return
*/
public Pattern getPatternFor(Class<?> type) {
Assert.notNull(type, "Type must not be null!");
for (Entry<Class<?>, Pattern> entry : this.patterns.entrySet()) {
if (entry.getKey().isAssignableFrom(type)) {
return entry.getValue();
}
}
return this.patterns.get(type);
}
}

View File

@@ -1,3 +1,18 @@
/*
* Copyright 2013-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.rest.core.domain.jpa;
import java.util.ArrayList;
@@ -5,6 +20,7 @@ import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
@@ -15,6 +31,7 @@ import javax.persistence.PrePersist;
* An entity that represents a person.
*
* @author Jon Brisbin
* @author Oliver Gierke
*/
@Entity
public class Person {

View File

@@ -27,7 +27,7 @@ import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* Controller to expose a JSON schema via {@code / repository}/schema}.
* Controller to expose a JSON schema via {@code /repository/schema}.
*
* @author Jon Brisbin
* @author Oliver Gierke

View File

@@ -337,7 +337,7 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon
@Bean
public PersistentEntityToJsonSchemaConverter jsonSchemaConverter() {
return new PersistentEntityToJsonSchemaConverter(persistentEntities(), resourceMappings(),
resourceDescriptionMessageSourceAccessor(), entityLinks());
resourceDescriptionMessageSourceAccessor(), objectMapper(), config());
}
/**

View File

@@ -19,12 +19,17 @@ import java.util.Iterator;
import java.util.List;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.data.rest.core.annotation.Description;
import org.springframework.data.rest.core.mapping.AnnotationBasedResourceDescription;
import org.springframework.data.rest.core.mapping.ResourceDescription;
import org.springframework.data.rest.core.mapping.SimpleResourceDescription;
import org.springframework.util.Assert;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationConfig;
import com.fasterxml.jackson.databind.introspect.AnnotatedMember;
import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition;
/**
@@ -36,6 +41,7 @@ import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition;
public class JacksonMetadata implements Iterable<BeanPropertyDefinition> {
private final List<BeanPropertyDefinition> definitions;
private final boolean isValue;
/**
* Creates a new {@link JacksonMetadata} instance for the given {@link ObjectMapper} and type.
@@ -53,6 +59,7 @@ public class JacksonMetadata implements Iterable<BeanPropertyDefinition> {
BeanDescription description = serializationConfig.introspect(javaType);
this.definitions = description.findProperties();
this.isValue = description.findJsonValueMethod() != null;
}
/**
@@ -75,6 +82,23 @@ public class JacksonMetadata implements Iterable<BeanPropertyDefinition> {
return null;
}
/**
* Returns the fallback {@link ResourceDescription} to be used for the given {@link BeanPropertyDefinition}.
*
* @param definition must not be {@literal null}.
* @return
*/
public ResourceDescription getFallbackDescription(BeanPropertyDefinition definition) {
Assert.notNull(definition, "BeanPropertyDefinition must not be null!");
AnnotatedMember member = definition.getPrimaryMember();
Description description = member.getAnnotation(Description.class);
ResourceDescription fallback = SimpleResourceDescription.defaultFor(definition.getName());
return description == null ? null : new AnnotationBasedResourceDescription(description, fallback);
}
/**
* Check if a given property for a type is available to be exported, i.e. serialized via Jackson.
*
@@ -85,6 +109,15 @@ public class JacksonMetadata implements Iterable<BeanPropertyDefinition> {
return getDefinitionFor(property) != null;
}
/**
* Returns whether the backing type is considered a Jackson value type.
*
* @return the isValue
*/
public boolean isValueType() {
return isValue;
}
/*
* (non-Javadoc)
* @see java.lang.Iterable#iterator()

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2014 the original author or authors.
* Copyright 2012-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,13 +16,27 @@
package org.springframework.data.rest.webmvc.json;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.hateoas.Resource;
import org.springframework.data.rest.core.config.JsonSchemaFormat;
import org.springframework.data.util.ClassTypeInformation;
import org.springframework.data.util.TypeInformation;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
/**
* Model class to render JSON schema documents.
@@ -30,89 +44,353 @@ import com.fasterxml.jackson.annotation.JsonProperty;
* @author Jon Brisbin
* @author Oliver Gierke
*/
public class JsonSchema extends Resource<Map<String, JsonSchema.Property>> {
@JsonInclude(Include.NON_EMPTY)
public class JsonSchema {
private final String name;
private static List<Class<?>> INTEGER_TYPES = Arrays.<Class<?>> asList(Long.class, long.class, Integer.class,
int.class, Short.class, short.class);
private final String title;
private final String description;
private final PropertiesContainer container;
private final Descriptors descriptors;
public JsonSchema(String name, String description) {
super(new HashMap<String, Property>());
this.name = name;
/**
* Creates a new {@link JsonSchema} instance for the given title, description, {@link JsonSchemaProperty}s and
* {@link Descriptors}.
*
* @param title must not be {@literal null} or empty.
* @param description can be {@literal null}.
* @param properties must not be {@literal null}.
* @param descriptors must not be {@literal null}.
*/
public JsonSchema(String title, String description, Collection<JsonSchemaProperty> properties, Descriptors descriptors) {
Assert.hasText(title, "Title must not be null or empty!");
Assert.notNull(properties, "JsonSchemaProperties must not be null!");
Assert.notNull(descriptors, "Desciptors must not be null!");
this.title = title;
this.description = description;
this.container = new PropertiesContainer(properties);
this.descriptors = descriptors;
}
public String getName() {
return name;
@JsonProperty("$schema")
public String getSchema() {
return "http://json-schema.org/draft-04/schema#";
}
public String getType() {
return "object";
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
@JsonProperty("properties")
@Override
public Map<String, JsonSchema.Property> getContent() {
return super.getContent();
@JsonUnwrapped
public PropertiesContainer getContainer() {
return container;
}
public JsonSchema addProperty(String name, Property property) {
getContent().put(name, property);
return this;
/**
* @return the descriptors
*/
@JsonUnwrapped
public Descriptors getDescriptors() {
return descriptors;
}
public boolean isArrayProperty(String name) {
return (getContent().containsKey(name) && getContent().get(name) instanceof ArrayProperty);
/**
* Turns the given {@link TypeInformation} into a JSON Schema type string.
*
* @param typeInformation
* @return
* @see http://json-schema.org/latest/json-schema-core.html#anchor8
*/
private static String toJsonSchemaType(TypeInformation<?> typeInformation) {
Class<?> type = typeInformation.getType();
if (type == null) {
return null;
} else if (typeInformation.isCollectionLike()) {
return "array";
} else if (Boolean.class.equals(type) || boolean.class.equals(type)) {
return "boolean";
} else if (String.class.equals(type) || isDate(typeInformation) || type.isEnum()) {
return "string";
} else if (INTEGER_TYPES.contains(type)) {
return "integer";
} else if (Number.class.isAssignableFrom(type)) {
return "number";
} else {
return "object";
}
}
public ArrayProperty getArrayProperty(String name) {
return (ArrayProperty) getContent().get(name);
/**
* Returns whether the given {@link TypeInformation} represents a date.
*
* @param type must not be {@literal null}.
* @return
*/
private static boolean isDate(TypeInformation<?> type) {
Class<?> rawType = type.getType();
if (Date.class.equals(rawType)) {
return true;
}
for (String datePackage : Arrays.asList("java.time", "org.threeten.bp", "org.joda.time")) {
if (rawType.getName().startsWith(datePackage)) {
return true;
}
}
return false;
}
public static class Property {
/**
* A JSON Schema item.
*
* @author Oliver Gierke
*/
static class Item {
private final String type;
private final String description;
private final boolean required;
private final PropertiesContainer properties;
public Property(String type, String description, boolean required) {
this.type = type;
this.description = description;
this.required = required;
/**
* Creates a new {@link Item} for the given {@link TypeInformation} and properties.
*
* @param type must not be {@literal null}.
* @param properties must not be {@literal null}.
*/
public Item(TypeInformation<?> type, Collection<JsonSchemaProperty> properties) {
this.type = toJsonSchemaType(type);
this.properties = new PropertiesContainer(properties);
}
public String getType() {
return type;
}
public String getDescription() {
return description;
@JsonUnwrapped
public PropertiesContainer getProperties() {
return properties;
}
}
/**
* Value object to represent a generic container of properties.
*
* @author Oliver Gierke
* @since 2.3
*/
@JsonInclude(Include.NON_EMPTY)
static class PropertiesContainer {
public final Map<String, JsonSchemaProperty> properties;
public final Collection<String> requiredProperties;
/**
* Creates a new {@link PropertiesContainer} for the given {@link JsonSchemaProperty}s.
*
* @param properties must not be {@literal null}.
*/
public PropertiesContainer(Collection<JsonSchemaProperty> properties) {
Assert.notNull(properties, "JsonSchemaPropertys must not be null!");
this.properties = new HashMap<String, JsonSchema.JsonSchemaProperty>();
this.requiredProperties = new ArrayList<String>();
for (JsonSchemaProperty property : properties) {
this.properties.put(property.getName(), property);
if (property.isRequired()) {
this.requiredProperties.add(property.name);
}
}
}
}
/**
* Value object to abstract a {@link Map} of JSON Schema descriptors.
*
* @author Oliver Gierke
*/
static class Descriptors {
private final Map<String, Item> descriptors;
public Descriptors() {
this.descriptors = new HashMap<String, Item>();
}
public boolean isRequired() {
/**
* @return the descriptors
*/
public Map<String, Item> getDescriptors() {
return descriptors;
}
boolean hasDescriptorFor(TypeInformation<?> type) {
return this.descriptors.containsKey(typeKey(type));
}
String addDescriptor(TypeInformation<?> type, Item item) {
String reference = typeKey(type);
this.descriptors.put(reference, item);
return reference;
}
static String getReference(TypeInformation<?> type) {
return String.format("#/descriptors/%s", typeKey(type));
}
static String typeKey(TypeInformation<?> type) {
return StringUtils.uncapitalize(type.getActualType().getType().getSimpleName());
}
}
/**
* Base class for all property implementations.
*
* @author Oliver Gierke
* @since 2.3
*/
@JsonInclude(Include.NON_EMPTY)
abstract static class JsonSchemaProperty {
private final String name;
private final boolean required;
protected JsonSchemaProperty(String name, boolean required) {
this.name = name;
this.required = required;
}
@JsonIgnore
public String getName() {
return name;
}
private boolean isRequired() {
return required;
}
}
public static class ArrayProperty extends Property {
private List<Property> items = new ArrayList<Property>();
/**
* A JSON Schema property
*
* @author Oliver Gierke
* @since 2.3
*/
static class Property extends JsonSchemaProperty {
public ArrayProperty(String type, String description, boolean required) {
super(type, description, required);
private static final TypeInformation<?> STRING_TYPE_INFORMATION = ClassTypeInformation.from(String.class);
public String description;
public String type;
public JsonSchemaFormat format;
public String pattern;
public Boolean uniqueItems;
public @JsonProperty("$ref") String reference;
public Map<String, String> items;
public Property(String name, String description, boolean required) {
super(name, required);
this.description = description;
}
public List<? extends Property> getItems() {
return items;
}
Property with(TypeInformation<?> type) {
this.type = toJsonSchemaType(type);
if (isDate(type)) {
return with(JsonSchemaFormat.DATE_TIME);
}
if (type.isCollectionLike()) {
if (Set.class.equals(type.getType())) {
this.uniqueItems = true;
}
this.items = Collections.singletonMap("type", toJsonSchemaType(type.getActualType()));
}
public ArrayProperty setItems(List<Property> items) {
this.items = items;
return this;
}
public <P extends Property> ArrayProperty addItem(P item) {
this.items.add(item);
return this;
Property with(JsonSchemaFormat format) {
this.format = format;
return with(STRING_TYPE_INFORMATION);
}
Property with(Pattern pattern) {
this.pattern = pattern.toString();
return with(STRING_TYPE_INFORMATION);
}
Property with(TypeInformation<?> type, String reference) {
if (type.isCollectionLike()) {
if (Set.class.equals(type.getType())) {
this.uniqueItems = true;
}
this.type = toJsonSchemaType(type);
this.items = Collections.singletonMap("$ref", reference);
return this;
} else {
this.reference = reference;
return this;
}
}
}
/**
* A {@link Property} representing enumerations. Will cause all valid values to be rendered in a nested
* {@literal enum} property.
*
* @author Oliver Gierke
* @since 2.3
*/
static class EnumProperty extends Property {
private final List<String> values;
public EnumProperty(String name, Class<?> type, String description, boolean required) {
super(name, description, required);
this.values = new ArrayList<String>();
for (Object value : type.getEnumConstants()) {
this.values.add(value.toString());
}
}
@JsonProperty("enum")
public List<String> getValues() {
return values;
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2014 the original author or authors.
* Copyright 2012-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,34 +15,45 @@
*/
package org.springframework.data.rest.webmvc.json;
import static org.springframework.util.StringUtils.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.context.NoSuchMessageException;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.core.convert.converter.ConditionalGenericConverter;
import org.springframework.data.mapping.PersistentEntity;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.data.mapping.SimplePropertyHandler;
import org.springframework.data.mapping.context.PersistentEntities;
import org.springframework.data.rest.core.Path;
import org.springframework.data.rest.core.config.JsonSchemaFormat;
import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
import org.springframework.data.rest.core.mapping.MappingResourceMetadata;
import org.springframework.data.rest.core.mapping.ResourceDescription;
import org.springframework.data.rest.core.mapping.ResourceMapping;
import org.springframework.data.rest.core.mapping.ResourceMappings;
import org.springframework.data.rest.core.mapping.ResourceMetadata;
import org.springframework.data.rest.webmvc.json.JsonSchema.ArrayProperty;
import org.springframework.data.rest.webmvc.json.JsonSchema.Descriptors;
import org.springframework.data.rest.webmvc.json.JsonSchema.EnumProperty;
import org.springframework.data.rest.webmvc.json.JsonSchema.Item;
import org.springframework.data.rest.webmvc.json.JsonSchema.JsonSchemaProperty;
import org.springframework.data.rest.webmvc.json.JsonSchema.Property;
import org.springframework.data.rest.webmvc.mapping.AssociationLinks;
import org.springframework.data.rest.webmvc.mapping.LinkCollectingAssociationHandler;
import org.springframework.data.util.ClassTypeInformation;
import org.springframework.data.util.TypeInformation;
import org.springframework.hateoas.EntityLinks;
import org.springframework.hateoas.Link;
import org.springframework.util.Assert;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition;
/**
* Converter to create {@link JsonSchema} instances for {@link PersistentEntity}s.
*
* @author Jon Brisbin
* @author Oliver Gierke
*/
@@ -50,12 +61,14 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric
private static final TypeDescriptor STRING_TYPE = TypeDescriptor.valueOf(String.class);
private static final TypeDescriptor SCHEMA_TYPE = TypeDescriptor.valueOf(JsonSchema.class);
private static final TypeInformation<?> STRING_TYPE_INFORMATION = ClassTypeInformation.from(String.class);
private final Set<ConvertiblePair> convertiblePairs = new HashSet<ConvertiblePair>();
private final ResourceMappings mappings;
private final PersistentEntities repositories;
private final PersistentEntities entities;
private final MessageSourceAccessor accessor;
private final EntityLinks entityLinks;
private final ObjectMapper objectMapper;
private final RepositoryRestConfiguration configuration;
/**
* Creates a new {@link PersistentEntityToJsonSchemaConverter} for the given {@link PersistentEntities} and
@@ -63,20 +76,24 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric
*
* @param entities must not be {@literal null}.
* @param mappings must not be {@literal null}.
* @param accessor
* @param accessor must not be {@literal null}.
* @param objectMapper must not be {@literal null}.
* @param configuration must not be {@literal null}.
*/
public PersistentEntityToJsonSchemaConverter(PersistentEntities entities, ResourceMappings mappings,
MessageSourceAccessor accessor, EntityLinks entityLinks) {
MessageSourceAccessor accessor, ObjectMapper objectMapper, RepositoryRestConfiguration configuration) {
Assert.notNull(entities, "PersistentEntities must not be null!");
Assert.notNull(mappings, "ResourceMappings must not be null!");
Assert.notNull(accessor, "MessageSourceAccessor must not be null!");
Assert.notNull(entityLinks, "EntityLinks must not be null!");
Assert.notNull(objectMapper, "ObjectMapper must not be null!");
Assert.notNull(configuration, "RepositoryRestConfiguration must not be null!");
this.repositories = entities;
this.entities = entities;
this.mappings = mappings;
this.accessor = accessor;
this.entityLinks = entityLinks;
this.objectMapper = objectMapper;
this.configuration = configuration;
for (TypeInformation<?> domainType : entities.getManagedTypes()) {
convertiblePairs.add(new ConvertiblePair(domainType.getType(), JsonSchema.class));
@@ -102,6 +119,12 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric
return convertiblePairs;
}
/**
* Converts the given type into a {@link JsonSchema} instance.
*
* @param domainType must not be {@literal null}.
* @return
*/
public JsonSchema convert(Class<?> domainType) {
return (JsonSchema) convert(domainType, STRING_TYPE, SCHEMA_TYPE);
}
@@ -111,54 +134,144 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric
* @see org.springframework.core.convert.converter.GenericConverter#convert(java.lang.Object, org.springframework.core.convert.TypeDescriptor, org.springframework.core.convert.TypeDescriptor)
*/
@Override
public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
public JsonSchema convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
final PersistentEntity<?, ?> persistentEntity = repositories.getPersistentEntity((Class<?>) source);
final PersistentEntity<?, ?> persistentEntity = entities.getPersistentEntity((Class<?>) source);
final ResourceMetadata metadata = mappings.getMappingFor(persistentEntity.getType());
final JsonSchema jsonSchema = new JsonSchema(persistentEntity.getName(),
resolveMessage(metadata.getItemResourceDescription()));
persistentEntity.doWithProperties(new SimplePropertyHandler() {
Descriptors descriptors = new Descriptors();
List<JsonSchemaProperty> propertiesFor = getPropertiesFor(persistentEntity.getType(), metadata, descriptors);
/*
* (non-Javadoc)
* @see org.springframework.data.mapping.PropertyHandler#doWithPersistentProperty(org.springframework.data.mapping.PersistentProperty)
*/
@Override
public void doWithPersistentProperty(PersistentProperty<?> persistentProperty) {
Class<?> propertyType = persistentProperty.getType();
String type = uncapitalize(propertyType.getSimpleName());
ResourceMapping propertyMapping = metadata.getMappingFor(persistentProperty);
ResourceDescription description = propertyMapping.getDescription();
String message = resolveMessage(description);
Property property = persistentProperty.isCollectionLike() ? //
new ArrayProperty("array", message, false)
: new Property(type, message, false);
jsonSchema.addProperty(persistentProperty.getName(), property);
}
});
Link link = entityLinks.linkToCollectionResource(persistentEntity.getType()).expand();
LinkCollectingAssociationHandler associationHandler = new LinkCollectingAssociationHandler(repositories, new Path(
link.getHref()), new AssociationLinks(mappings));
persistentEntity.doWithAssociations(associationHandler);
jsonSchema.add(associationHandler.getLinks());
return jsonSchema;
return new JsonSchema(persistentEntity.getName(), resolveMessage(metadata.getItemResourceDescription()),
propertiesFor, descriptors);
}
private String resolveMessage(ResourceDescription description) {
private List<JsonSchemaProperty> getPropertiesFor(Class<?> type, final ResourceMetadata metadata,
final Descriptors descriptors) {
final PersistentEntity<?, ?> entity = entities.getPersistentEntity(type);
final JacksonMetadata jackson = new JacksonMetadata(objectMapper, type);
final AssociationLinks associationLinks = new AssociationLinks(mappings);
if (entity == null) {
return Collections.<JsonSchemaProperty> emptyList();
}
final List<JsonSchemaProperty> properties = new ArrayList<JsonSchema.JsonSchemaProperty>();
for (BeanPropertyDefinition definition : jackson) {
PersistentProperty<?> persistentProperty = entity.getPersistentProperty(definition.getInternalName());
TypeInformation<?> propertyType = persistentProperty == null ? ClassTypeInformation.from(definition
.getPrimaryMember().getRawType()) : persistentProperty.getTypeInformation();
Class<?> rawPropertyType = propertyType.getType();
JsonSchemaFormat format = configuration.metadataConfiguration().getSchemaFormatFor(rawPropertyType);
ResourceDescription description = persistentProperty == null ? jackson.getFallbackDescription(definition)
: getDescriptionFor(persistentProperty, metadata);
Property property = getSchemaProperty(definition, propertyType, description);
if (format != null) {
// Types with explicitly registered format -> value object with format
properties.add(property.with(format));
continue;
}
Pattern pattern = configuration.metadataConfiguration().getPatternFor(rawPropertyType);
if (pattern != null) {
properties.add(property.with(pattern));
continue;
}
if (jackson.isValueType()) {
properties.add(property.with(STRING_TYPE_INFORMATION));
continue;
}
if (persistentProperty == null) {
properties.add(property);
continue;
}
if (persistentProperty.isIdProperty() && !configuration.isIdExposedFor(rawPropertyType)) {
continue;
}
if (associationLinks.isLinkableAssociation(persistentProperty)) {
properties.add(property.with(JsonSchemaFormat.URI));
} else {
if (persistentProperty.isEntity()) {
if (!descriptors.hasDescriptorFor(propertyType)) {
descriptors.addDescriptor(propertyType,
new Item(propertyType, getNestedPropertiesFor(persistentProperty, descriptors)));
}
properties.add(property.with(propertyType, Descriptors.getReference(propertyType)));
} else {
properties.add(property.with(propertyType));
}
}
}
return properties;
}
private Collection<JsonSchemaProperty> getNestedPropertiesFor(PersistentProperty<?> property, Descriptors descriptors) {
if (!property.isEntity()) {
return Collections.emptyList();
}
Class<?> actualType = property.getActualType();
PersistentEntity<?, ?> propertyEntity = entities.getPersistentEntity(actualType);
MappingResourceMetadata propertyMetadata = new MappingResourceMetadata(propertyEntity);
return getPropertiesFor(actualType, propertyMetadata, descriptors);
}
private Property getSchemaProperty(BeanPropertyDefinition definition, TypeInformation<?> type,
ResourceDescription description) {
String name = definition.getName();
String resolvedDescription = resolveMessage(description);
boolean required = definition.isRequired();
Class<?> rawType = type.getType();
if (!rawType.isEnum()) {
return new Property(name, resolvedDescription, required);
}
return new EnumProperty(name, rawType, description.getDefaultMessage().equals(resolvedDescription) ? null
: resolvedDescription, required);
}
private ResourceDescription getDescriptionFor(PersistentProperty<?> property, ResourceMetadata metadata) {
ResourceMapping propertyMapping = metadata.getMappingFor(property);
return propertyMapping.getDescription();
}
private String resolveMessage(MessageSourceResolvable resolvable) {
if (resolvable == null) {
return null;
}
try {
return accessor.getMessage(description);
return accessor.getMessage(resolvable);
} catch (NoSuchMessageException o_O) {
return description.getMessage();
if (configuration.metadataConfiguration().omitUnresolvableDescriptionKeys()) {
return null;
} else {
throw o_O;
}
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2013 the original author or authors.
* Copyright 2013-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -23,6 +23,9 @@ import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
/**
* @author Oliver Gierke
*/
@Entity
public class Author {
@@ -31,7 +34,7 @@ public class Author {
public String name;
@ManyToMany(mappedBy = "authors")//
Set<Book> books = new HashSet<Book>();
public Set<Book> books = new HashSet<Book>();
protected Author() {}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2013-2014 the original author or authors.
* Copyright 2013-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -24,16 +24,19 @@ import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
/**
* @author Oliver Gierke
*/
@Entity
public class Book {
@Id @GeneratedValue Long id;
String isbn;
public String isbn;
@ManyToMany(cascade = { CascadeType.MERGE })//
Set<Author> authors;
public Set<Author> authors;
String title;
public String title;
protected Book() {}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2014 the original author or authors.
* Copyright 2014-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,29 +15,155 @@
*/
package org.springframework.data.rest.webmvc.json;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import java.util.ArrayList;
import java.util.List;
import org.hamcrest.Matcher;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.rest.webmvc.AbstractControllerIntegrationTests;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.data.mapping.context.PersistentEntities;
import org.springframework.data.rest.core.config.JsonSchemaFormat;
import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
import org.springframework.data.rest.core.mapping.RepositoryResourceMappings;
import org.springframework.data.rest.webmvc.TestMvcClient;
import org.springframework.data.rest.webmvc.config.RepositoryRestMvcConfiguration;
import org.springframework.data.rest.webmvc.json.PersistentEntityToJsonSchemaConverterUnitTests.TestConfiguration;
import org.springframework.data.rest.webmvc.mongodb.MongoDbRepositoryConfig;
import org.springframework.data.rest.webmvc.mongodb.Profile;
import org.springframework.data.rest.webmvc.mongodb.User;
import org.springframework.data.rest.webmvc.mongodb.User.EmailAddress;
import org.springframework.data.rest.webmvc.mongodb.User.TypeWithPattern;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jayway.jsonpath.JsonPath;
/**
* @author Oliver Gierke
*/
@ContextConfiguration(classes = MongoDbRepositoryConfig.class)
public class PersistentEntityToJsonSchemaConverterUnitTests extends AbstractControllerIntegrationTests {
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { MongoDbRepositoryConfig.class, TestConfiguration.class })
public class PersistentEntityToJsonSchemaConverterUnitTests {
@Autowired PersistentEntityToJsonSchemaConverter converter;
@Autowired MessageSourceAccessor accessor;
@Autowired RepositoryResourceMappings mappings;
@Autowired RepositoryRestConfiguration configuration;
@Autowired PersistentEntities entities;
@Autowired @Qualifier("objectMapper") ObjectMapper objectMapper;
@Configuration
static class TestConfiguration extends RepositoryRestMvcConfiguration {
@Override
protected void configureRepositoryRestConfiguration(RepositoryRestConfiguration config) {
config.metadataConfiguration().registerJsonSchemaFormat(JsonSchemaFormat.EMAIL, EmailAddress.class);
config.metadataConfiguration().registerFormattingPatternFor("[A-Z]+", TypeWithPattern.class);
}
}
PersistentEntityToJsonSchemaConverter converter;
@Before
public void setUp() {
TestMvcClient.initWebTest();
converter = new PersistentEntityToJsonSchemaConverter(entities, mappings, accessor, objectMapper, configuration);
}
@Test
public void addsDescriptionToSchemaRoot() {
public void fulfillsConstraintsForProfile() {
JsonSchema schema = converter.convert(Profile.class);
List<Constraint> constraints = new ArrayList<Constraint>();
constraints.add(new Constraint("$.description", is("Profile description"), "Adds description to schema root"));
constraints.add(new Constraint("$.properties.renamed", is(notNullValue()), "Has descriptor for renamed property"));
constraints.add(new Constraint("$.properties.aliased", is(nullValue()),
"No descriptor for original name of renamed property"));
assertThat(schema.getDescription(), is("Profile description"));
assertConstraints(Profile.class, constraints);
}
@Test
public void fulfilsConstraintsForUser() throws Exception {
List<Constraint> constraints = new ArrayList<Constraint>();
constraints.add(new Constraint("$.properties.firstname.type", is("string"), "Exposes firstname as String"));
constraints.add(new Constraint("$.descriptors.address", is(notNullValue()),
"Exposes nested objects as descriptors."));
constraints.add(new Constraint("$.descriptors.address.type", is("object"), "Nested entity is of type 'object'"));
constraints.add(new Constraint("$.descriptors.address.properties.zipCode", is(notNullValue()),
"Exposes nested properties"));
constraints.add(new Constraint("$.descriptors.address.requiredProperties[0]", is("zipCode"),
"Lists nested required property"));
constraints.add(new Constraint("$.properties.gender.type", is("string"), "Enums are strings."));
constraints.add(new Constraint("$.properties.gender.enum", is(notNullValue()), "Exposes enum values."));
constraints.add(new Constraint("$.properties.jodaDateTime.format", is("date-time"),
"Exposes JodaTime dates in format."));
constraints.add(new Constraint("$.properties.java8DateTime.format", is("date-time"),
"Exposes Java 8 dates in format."));
constraints.add(new Constraint("$.properties.nicknames.type", is("array"), "Exposes collection of simple types."));
constraints.add(new Constraint("$.properties.nicknames.items.type", is("string"),
"Exposes element type of collection of simple types."));
constraints.add(new Constraint("$.properties.email.format", is("email"), "Uses manually configured format."));
constraints.add(new Constraint("$.properties.email.type", is("string"), "Treats types with format as String."));
constraints.add(new Constraint("$.properties.shippingAddresses.type", is("array"),
"Exposes collection of complex types."));
constraints.add(new Constraint("$.properties.shippingAddresses.uniqueItems", is(true),
"Exposes uniqueness for Sets."));
constraints.add(new Constraint("$.properties.shippingAddresses.items['$ref']", is("#/descriptors/address"),
"References descriptor of complex element type."));
assertConstraints(User.class, constraints);
}
@SuppressWarnings("unchecked")
private void assertConstraints(Class<?> type, Iterable<Constraint> constraints) {
String writeSchemaFor = writeSchemaFor(type);
System.out.println(writeSchemaFor);
for (Constraint constraint : constraints) {
try {
assertThat(constraint.description, JsonPath.read(writeSchemaFor, constraint.selector), constraint.matcher);
} catch (RuntimeException e) {
assertThat(e, constraint.matcher);
}
}
}
private String writeSchemaFor(Class<?> type) {
try {
return objectMapper.writeValueAsString(converter.convert(type));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
@SuppressWarnings("rawtypes")
private static class Constraint {
String selector;
Matcher matcher;
String description;
public Constraint(String selector, Matcher matcher, String description) {
this.selector = selector;
this.matcher = matcher;
this.description = description;
}
}
}

View File

@@ -15,11 +15,13 @@
*/
package org.springframework.data.rest.webmvc.mongodb;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* @author Oliver Gierke
*/
public class Address {
public String street, zipCode;
public String street;
public @JsonProperty(required = true) String zipCode;
}

View File

@@ -7,6 +7,7 @@ import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.mongodb.core.mapping.Document;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* @author Jon Brisbin
@@ -16,8 +17,9 @@ public class Profile {
@Id private String id;
private Long person;
private String type;
private @JsonProperty(required = true) String type;
private @LastModifiedDate Date lastModifiedDate;
private @JsonProperty("renamed") String aliased;
public String getId() {
return id;
@@ -50,4 +52,8 @@ public class Profile {
public Date getLastModifiedDate() {
return lastModifiedDate;
}
public String getAliased() {
return aliased;
}
}

View File

@@ -16,20 +16,54 @@
package org.springframework.data.rest.webmvc.mongodb;
import java.math.BigInteger;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import org.springframework.data.mongodb.core.mapping.DBRef;
import org.springframework.data.mongodb.core.mapping.Document;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* @author Oliver Gierke
*/
@Document
public class User {
public static enum Gender {
MALE, FEMALE;
}
public BigInteger id;
public String firstname, lastname;
public Address address;
public Set<Address> shippingAddresses;
public List<String> nicknames;
public Gender gender;
public EmailAddress email;
public LocalDateTime java8DateTime;
public org.joda.time.LocalDateTime jodaDateTime;
public TypeWithPattern pattern;
public @DBRef(lazy = true) List<User> colleagues;
public static class EmailAddress {
private final String value;
/**
* @param value
*/
public EmailAddress(String value) {
this.value = value;
}
@Override
@JsonValue
public String toString() {
return value;
}
}
public static class TypeWithPattern {}
}