diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/JsonSchemaFormat.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/JsonSchemaFormat.java new file mode 100644 index 000000000..ff86b2fb3 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/JsonSchemaFormat.java @@ -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("_", "-"); + } +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/MetadataConfiguration.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/MetadataConfiguration.java index 95c5a01f3..77545695d 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/MetadataConfiguration.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/config/MetadataConfiguration.java @@ -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, JsonSchemaFormat> schemaFormats = new HashMap, JsonSchemaFormat>(); + private final Map, Pattern> patterns = new HashMap, 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, Pattern> entry : this.patterns.entrySet()) { + if (entry.getKey().isAssignableFrom(type)) { + return entry.getValue(); + } + } + + return this.patterns.get(type); + } } diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/domain/jpa/Person.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/domain/jpa/Person.java index 891e41f48..208b28593 100644 --- a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/domain/jpa/Person.java +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/domain/jpa/Person.java @@ -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 { diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java index d0644ceed..77d41ae86 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java @@ -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 diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java index 955972a1c..3a90b8bed 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java @@ -337,7 +337,7 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon @Bean public PersistentEntityToJsonSchemaConverter jsonSchemaConverter() { return new PersistentEntityToJsonSchemaConverter(persistentEntities(), resourceMappings(), - resourceDescriptionMessageSourceAccessor(), entityLinks()); + resourceDescriptionMessageSourceAccessor(), objectMapper(), config()); } /** diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonMetadata.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonMetadata.java index 51200c7c5..92642264d 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonMetadata.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonMetadata.java @@ -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 { private final List 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 { BeanDescription description = serializationConfig.introspect(javaType); this.definitions = description.findProperties(); + this.isValue = description.findJsonValueMethod() != null; } /** @@ -75,6 +82,23 @@ public class JacksonMetadata implements Iterable { 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 { 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() diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JsonSchema.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JsonSchema.java index 19a239d76..235b973fe 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JsonSchema.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JsonSchema.java @@ -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> { +@JsonInclude(Include.NON_EMPTY) +public class JsonSchema { - private final String name; + private static List> INTEGER_TYPES = Arrays.> 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()); - 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 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 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 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 properties; + public final Collection requiredProperties; + + /** + * Creates a new {@link PropertiesContainer} for the given {@link JsonSchemaProperty}s. + * + * @param properties must not be {@literal null}. + */ + public PropertiesContainer(Collection properties) { + + Assert.notNull(properties, "JsonSchemaPropertys must not be null!"); + + this.properties = new HashMap(); + this.requiredProperties = new ArrayList(); + + 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 descriptors; + + public Descriptors() { + this.descriptors = new HashMap(); } - public boolean isRequired() { + /** + * @return the descriptors + */ + public Map 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 items = new ArrayList(); + /** + * 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 items; + + public Property(String name, String description, boolean required) { + + super(name, required); + + this.description = description; } - public List 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 items) { - this.items = items; return this; } - public

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 values; + + public EnumProperty(String name, Class type, String description, boolean required) { + + super(name, description, required); + + this.values = new ArrayList(); + + for (Object value : type.getEnumConstants()) { + this.values.add(value.toString()); + } + } + + @JsonProperty("enum") + public List getValues() { + return values; + } + } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java index 67467f084..bf7c49a6c 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverter.java @@ -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 convertiblePairs = new HashSet(); 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 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 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. emptyList(); + } + + final List properties = new ArrayList(); + + 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 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; + } } } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Author.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Author.java index b6ca61e4d..4ff8be183 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Author.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Author.java @@ -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 books = new HashSet(); + public Set books = new HashSet(); protected Author() {} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Book.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Book.java index 78adeea89..9e29e1dc8 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Book.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Book.java @@ -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 authors; + public Set authors; - String title; + public String title; protected Book() {} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverterUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverterUnitTests.java index 22eb1df85..7f0758bab 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverterUnitTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntityToJsonSchemaConverterUnitTests.java @@ -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 constraints = new ArrayList(); + 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 constraints = new ArrayList(); + 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 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; + } } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Address.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Address.java index 17e932664..41c5f6ca4 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Address.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Address.java @@ -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; } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Profile.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Profile.java index e2589c024..02e2d0eaa 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Profile.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Profile.java @@ -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; + } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/User.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/User.java index fae002b2f..a893b9a99 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/User.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/User.java @@ -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

shippingAddresses; + public List nicknames; + public Gender gender; + public EmailAddress email; + public LocalDateTime java8DateTime; + public org.joda.time.LocalDateTime jodaDateTime; + public TypeWithPattern pattern; public @DBRef(lazy = true) List 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 {} }