From d4dbeb93a034888da68be48d5a76ee1e906434e6 Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Fri, 14 Aug 2015 18:48:30 +0200 Subject: [PATCH] DATAREST-644 - Improvements to JSON Schema output. Fixed JSON Schema output use "definitions" keyword instead of the previously used wrong "descriptors". We now also include the title attribute for properties, using rest.description.$type.$property._title as i18n key. Titles are now also defaulted to the camel-case property name split up, lowercased and capitalized, e.g. "orderDate" will become "Order date". JacksonMetadata now allows obtaining the Jackson serializer being used for a given type and exposes whether a property is considered read-only for Jackson. Introduced JsonSchemaPropertyCustomizer to potentially tweak the JSON schema property definition. This is helpful in case custom JsonSerializers are implemented to also reflect the change in representation in the schema. --- .../rest/core/annotation/Description.java | 4 +- .../mapping/SimpleResourceDescription.java | 2 +- .../mapping/TypedResourceDescription.java | 8 +- .../rest/webmvc/json/JacksonMetadata.java | 96 +++++++- .../data/rest/webmvc/json/JsonSchema.java | 148 ++++++++---- .../json/JsonSchemaPropertyCustomizer.java | 38 ++++ ...PersistentEntityToJsonSchemaConverter.java | 214 +++++++++++++++--- .../webmvc/json/JacksonMetadataUnitTests.java | 99 ++++++++ .../rest/webmvc/json/JsonSchemaUnitTests.java | 4 +- ...tEntityToJsonSchemaConverterUnitTests.java | 16 +- 10 files changed, 523 insertions(+), 106 deletions(-) create mode 100644 spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JsonSchemaPropertyCustomizer.java create mode 100644 spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/JacksonMetadataUnitTests.java diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/annotation/Description.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/annotation/Description.java index f65002de1..f621926f0 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/annotation/Description.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/annotation/Description.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. @@ -22,7 +22,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Annotation to descibe semantics of a resource. + * Annotation to describe semantics of a resource. * * @author Jon Brisbin * @author Oliver Gierke diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/SimpleResourceDescription.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/SimpleResourceDescription.java index 58f69e71b..05e8ab2ee 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/SimpleResourceDescription.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/SimpleResourceDescription.java @@ -24,7 +24,7 @@ import org.springframework.util.StringUtils; */ public class SimpleResourceDescription extends ResolvableResourceDescriptionSupport { - protected static final String DEFAULT_KEY_PREFIX = "rest.description"; + public static final String DEFAULT_KEY_PREFIX = "rest.description"; protected static final MediaType DEFAULT_MEDIA_TYPE = MediaType.TEXT_PLAIN; private final String message; diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/TypedResourceDescription.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/TypedResourceDescription.java index 1e22a3685..fd0eba424 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/TypedResourceDescription.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/mapping/TypedResourceDescription.java @@ -47,9 +47,13 @@ public class TypedResourceDescription extends SimpleResourceDescription { } public static ResourceDescription defaultFor(String rel, PersistentProperty property) { + return defaultFor(rel, property.getName(), property.getType()); + } - String message = String.format("%s.%s.%s", DEFAULT_KEY_PREFIX, rel, property.getName()); - return new TypedResourceDescription(message, DEFAULT_MEDIA_TYPE, property.getType()); + public static ResourceDescription defaultFor(String rel, String name, Class type) { + + String message = String.format("%s.%s.%s", DEFAULT_KEY_PREFIX, rel, name); + return new TypedResourceDescription(message, DEFAULT_MEDIA_TYPE, type); } public static ResourceDescription defaultFor(String rel, Class type) { 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 92642264d..cbc36d7e0 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 @@ -22,15 +22,21 @@ 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.data.rest.core.mapping.ResourceMetadata; +import org.springframework.data.rest.core.mapping.TypedResourceDescription; import org.springframework.util.Assert; import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.DeserializationConfig; import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.introspect.AnnotatedMember; import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; +import com.fasterxml.jackson.databind.ser.DefaultSerializerProvider; /** * Value object to abstract Jackson based bean metadata of a given type. @@ -40,7 +46,9 @@ import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; */ public class JacksonMetadata implements Iterable { + private final ObjectMapper mapper; private final List definitions; + private final List deserializationDefinitions; private final boolean isValue; /** @@ -54,12 +62,19 @@ public class JacksonMetadata implements Iterable { Assert.notNull(mapper, "ObjectMapper must not be null!"); Assert.notNull(type, "Type must not be null!"); + this.mapper = mapper; + SerializationConfig serializationConfig = mapper.getSerializationConfig(); JavaType javaType = serializationConfig.constructType(type); BeanDescription description = serializationConfig.introspect(javaType); this.definitions = description.findProperties(); this.isValue = description.findJsonValueMethod() != null; + + DeserializationConfig deserializationConfig = mapper.getDeserializationConfig(); + JavaType deserializationType = deserializationConfig.constructType(type); + + this.deserializationDefinitions = deserializationConfig.introspect(deserializationType).findProperties(); } /** @@ -73,30 +88,27 @@ public class JacksonMetadata implements Iterable { Assert.notNull(property, "PersistentProperty must not be null!"); - for (BeanPropertyDefinition definition : definitions) { - if (definition.getInternalName().equals(property.getName())) { - return definition; - } - } - - return null; + return getDefinitionFor(property, definitions); } /** * Returns the fallback {@link ResourceDescription} to be used for the given {@link BeanPropertyDefinition}. * + * @param ownerMetadata must not be {@literal null}. * @param definition must not be {@literal null}. * @return */ - public ResourceDescription getFallbackDescription(BeanPropertyDefinition definition) { + public ResourceDescription getFallbackDescription(ResourceMetadata ownerMetadata, BeanPropertyDefinition definition) { + Assert.notNull(ownerMetadata, "Owner's resource metadata must not be null!"); Assert.notNull(definition, "BeanPropertyDefinition must not be null!"); AnnotatedMember member = definition.getPrimaryMember(); Description description = member.getAnnotation(Description.class); - ResourceDescription fallback = SimpleResourceDescription.defaultFor(definition.getName()); + ResourceDescription fallback = TypedResourceDescription.defaultFor(ownerMetadata.getItemResourceRel(), + definition.getInternalName(), definition.getPrimaryMember().getRawType()); - return description == null ? null : new AnnotationBasedResourceDescription(description, fallback); + return description == null ? fallback : new AnnotationBasedResourceDescription(description, fallback); } /** @@ -106,6 +118,9 @@ public class JacksonMetadata implements Iterable { * @return */ public boolean isExported(PersistentProperty property) { + + Assert.notNull(property, "PersistentProperty must not be null!"); + return getDefinitionFor(property) != null; } @@ -118,6 +133,46 @@ public class JacksonMetadata implements Iterable { return isValue; } + /** + * Returns whether the given {@link PersistentProperty} is considered read-only by Jackson. + * + * @param property must not be {@literal null}. + * @return + */ + public boolean isReadOnly(PersistentProperty property) { + + BeanPropertyDefinition definition = getDefinitionFor(property, deserializationDefinitions); + return definition == null ? false : !definition.couldDeserialize(); + } + + /** + * Returns the {@link JsonSerializer} for the given type, or {@literal null} if none available. + * + * @param type must not be {@literal null}. + * @return + */ + public JsonSerializer getTypeSerializer(Class type) { + + Assert.notNull(type, "Type must not be null!"); + + try { + + SerializerProvider provider = mapper.getSerializerProvider(); + + if (!(provider instanceof DefaultSerializerProvider)) { + return null; + } + + provider = ((DefaultSerializerProvider) provider).createInstance(mapper.getSerializationConfig(), + mapper.getSerializerFactory()); + + return provider.findValueSerializer(type); + + } catch (JsonMappingException o_O) { + return null; + } + } + /* * (non-Javadoc) * @see java.lang.Iterable#iterator() @@ -126,4 +181,23 @@ public class JacksonMetadata implements Iterable { public Iterator iterator() { return definitions.iterator(); } + + /** + * Finds the {@link BeanPropertyDefinition} for the given {@link PersistentProperty} within the given definitions. + * + * @param property must not be {@literal null}. + * @param definitions must not be {@literal null}. + * @return + */ + private static BeanPropertyDefinition getDefinitionFor(PersistentProperty property, + Iterable definitions) { + + for (BeanPropertyDefinition definition : definitions) { + if (definition.getInternalName().equals(property.getName())) { + return definition; + } + } + + return null; + } } 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 a47df0fc7..fede16987 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 @@ -54,28 +54,28 @@ public class JsonSchema { private final String title; private final String description; private final PropertiesContainer container; - private final Descriptors descriptors; + private final Definitions definitions; /** - * Creates a new {@link JsonSchema} instance for the given title, description, {@link JsonSchemaProperty}s and - * {@link Descriptors}. + * Creates a new {@link JsonSchema} instance for the given title, description, {@link AbstractJsonSchemaProperty}s and + * {@link Definitions}. * * @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}. + * @param definitions must not be {@literal null}. */ - public JsonSchema(String title, String description, Collection> properties, - Descriptors descriptors) { + public JsonSchema(String title, String description, Collection> properties, + Definitions definitions) { 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!"); + Assert.notNull(definitions, "Definitions must not be null!"); this.title = title; this.description = description; this.container = new PropertiesContainer(properties); - this.descriptors = descriptors; + this.definitions = definitions; } @JsonProperty("$schema") @@ -100,12 +100,9 @@ public class JsonSchema { return container; } - /** - * @return the descriptors - */ @JsonUnwrapped - public Descriptors getDescriptors() { - return descriptors; + public Definitions getDefinitions() { + return definitions; } /** @@ -175,9 +172,9 @@ public class JsonSchema { * @param type must not be {@literal null}. * @param properties must not be {@literal null}. */ - public Item(TypeInformation type, Collection> properties) { + public Item(TypeInformation type, Collection> properties) { - this.type = toJsonSchemaType(type); + this.type = toJsonSchemaType(type.getActualType()); this.properties = new PropertiesContainer(properties); } @@ -200,22 +197,22 @@ public class JsonSchema { @JsonInclude(Include.NON_EMPTY) static class PropertiesContainer { - public final Map> properties; + public final Map> properties; public final Collection requiredProperties; /** - * Creates a new {@link PropertiesContainer} for the given {@link JsonSchemaProperty}s. + * Creates a new {@link PropertiesContainer} for the given {@link AbstractJsonSchemaProperty}s. * * @param properties must not be {@literal null}. */ - public PropertiesContainer(Collection> properties) { + public PropertiesContainer(Collection> properties) { Assert.notNull(properties, "JsonSchemaPropertys must not be null!"); - this.properties = new HashMap>(); + this.properties = new HashMap>(); this.requiredProperties = new ArrayList(); - for (JsonSchemaProperty property : properties) { + for (AbstractJsonSchemaProperty property : properties) { this.properties.put(property.getName(), property); if (property.isRequired()) { @@ -226,39 +223,39 @@ public class JsonSchema { } /** - * Value object to abstract a {@link Map} of JSON Schema descriptors. + * Value object to abstract a {@link Map} of JSON Schema definitions. * * @author Oliver Gierke */ - static class Descriptors { + public static class Definitions { - private final Map descriptors; + private final Map definitions; - public Descriptors() { - this.descriptors = new HashMap(); + public Definitions() { + this.definitions = new HashMap(); } /** * @return the descriptors */ - public Map getDescriptors() { - return descriptors; + public Map getDefinitions() { + return definitions; } - boolean hasDescriptorFor(TypeInformation type) { - return this.descriptors.containsKey(typeKey(type)); + boolean hasDefinitionFor(TypeInformation type) { + return this.definitions.containsKey(typeKey(type)); } - String addDescriptor(TypeInformation type, Item item) { + String addDefinition(TypeInformation type, Item item) { String reference = typeKey(type); - this.descriptors.put(reference, item); + this.definitions.put(reference, item); return reference; } static String getReference(TypeInformation type) { - return String.format("#/descriptors/%s", typeKey(type)); + return String.format("#/definitions/%s", typeKey(type)); } static String typeKey(TypeInformation type) { @@ -273,16 +270,22 @@ public class JsonSchema { * @since 2.3 */ @JsonInclude(Include.NON_EMPTY) - abstract static class JsonSchemaProperty> { + abstract static class AbstractJsonSchemaProperty> { private final String name; + private final String title; private final boolean required; private boolean readOnly; - protected JsonSchemaProperty(String name, boolean required) { + protected AbstractJsonSchemaProperty(String name, boolean required) { + this(name, null, required); + } + + protected AbstractJsonSchemaProperty(String name, String title, boolean required) { this.name = name; + this.title = title; this.required = required; this.readOnly = false; } @@ -292,6 +295,10 @@ public class JsonSchema { return name; } + public String getTitle() { + return title; + } + private boolean isRequired() { return required; } @@ -313,7 +320,7 @@ public class JsonSchema { * @author Oliver Gierke * @since 2.3 */ - static class Property extends JsonSchemaProperty { + public static class JsonSchemaProperty extends AbstractJsonSchemaProperty { private static final TypeInformation STRING_TYPE_INFORMATION = ClassTypeInformation.from(String.class); @@ -325,19 +332,38 @@ public class JsonSchema { public @JsonProperty("$ref") String reference; public Map items; - public Property(String name, String description, boolean required) { + JsonSchemaProperty(String name, String title, String description, boolean required) { - super(name, required); + super(name, title, required); this.description = description; } - Property with(TypeInformation type) { + /** + * Configures the {@link JsonSchemaProperty} to reflect the given type. + * + * @param type must not be {@literal null}. + * @return + */ + public JsonSchemaProperty withType(Class type) { + Assert.notNull(type, "Type must not be null!"); + return with(ClassTypeInformation.from(type)); + } + + /** + * Configures the {@link JsonSchemaProperty} to reflect the given type. + * + * @param type must not be {@literal null}. + * @return + */ + public JsonSchemaProperty with(TypeInformation type) { + + Assert.notNull(type, "Type must not be null!"); this.type = toJsonSchemaType(type); if (isDate(type)) { - return with(JsonSchemaFormat.DATE_TIME); + return withFormat(JsonSchemaFormat.DATE_TIME); } if (type.isCollectionLike()) { @@ -352,17 +378,47 @@ public class JsonSchema { return this; } - Property with(JsonSchemaFormat format) { + /** + * Configures the given {@link JsonSchemaFormat} to be exposed on the current {@link JsonSchemaProperty}. + * + * @param format must not be {@literal null}. + * @return + */ + public JsonSchemaProperty withFormat(JsonSchemaFormat format) { + + Assert.notNull(format, "Format must not be null!"); + this.format = format; return with(STRING_TYPE_INFORMATION); } - Property with(Pattern pattern) { + /** + * Configures the {@link JsonSchemaProperty} to require the given regular expression as pattern. + * + * @param regex must not be {@literal null}. + * @return + */ + public JsonSchemaProperty withRegex(String regex) { + + Assert.hasText(regex, "Regular expression must not be null or empty!"); + return withPattern(Pattern.compile(regex)); + } + + /** + * Configures the {@link JsonSchemaProperty} to require the given {@link Pattern}. + * + * @param pattern must not be {@literal null}. + * @return + */ + public JsonSchemaProperty withPattern(Pattern pattern) { + + Assert.notNull(pattern, "Pattern must not be null!"); + this.pattern = pattern.toString(); return with(STRING_TYPE_INFORMATION); } - Property with(TypeInformation type, String reference) { + JsonSchemaProperty with(TypeInformation type, String reference) { if (type.isCollectionLike()) { @@ -383,19 +439,19 @@ public class JsonSchema { } /** - * A {@link Property} representing enumerations. Will cause all valid values to be rendered in a nested + * A {@link JsonSchemaProperty} 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 { + static class EnumProperty extends JsonSchemaProperty { private final List values; - public EnumProperty(String name, Class type, String description, boolean required) { + public EnumProperty(String name, String title, Class type, String description, boolean required) { - super(name, description, required); + super(name, title, description, required); this.values = new ArrayList(); diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JsonSchemaPropertyCustomizer.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JsonSchemaPropertyCustomizer.java new file mode 100644 index 000000000..ff97f3530 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JsonSchemaPropertyCustomizer.java @@ -0,0 +1,38 @@ +/* + * 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.webmvc.json; + +import org.springframework.data.rest.webmvc.json.JsonSchema.JsonSchemaProperty; +import org.springframework.data.util.TypeInformation; + +/** + * Callback interface to customize the {@link JsonSchemaProperty} created by default for a given type. + * + * @author Oliver Gierke + * @since 2.4 + * @soundtrack Superflight - Four Sided Cube (Bellyjam) + */ +public interface JsonSchemaPropertyCustomizer { + + /** + * Returns the customized {@link JsonSchemaProperty} based on the given one and the given type. + * + * @param property will never be {@literal null}. + * @param type will never be {@literal null}. + * @return + */ + JsonSchemaProperty customize(JsonSchemaProperty property, TypeInformation type); +} 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 aec3e60fa..2fd00931b 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 @@ -16,15 +16,18 @@ 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.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.regex.Pattern; import org.springframework.context.MessageSourceResolvable; import org.springframework.context.NoSuchMessageException; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.ConditionalGenericConverter; @@ -37,16 +40,19 @@ 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.Descriptors; +import org.springframework.data.rest.core.mapping.SimpleResourceDescription; +import org.springframework.data.rest.webmvc.json.JsonSchema.AbstractJsonSchemaProperty; +import org.springframework.data.rest.webmvc.json.JsonSchema.Definitions; 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.util.ClassTypeInformation; import org.springframework.data.util.TypeInformation; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; @@ -138,95 +144,115 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric final PersistentEntity persistentEntity = entities.getPersistentEntity((Class) source); final ResourceMetadata metadata = mappings.getMetadataFor(persistentEntity.getType()); - Descriptors descriptors = new Descriptors(); - List> propertiesFor = getPropertiesFor(persistentEntity.getType(), metadata, descriptors); + Definitions descriptors = new Definitions(); + List> propertiesFor = getPropertiesFor(persistentEntity.getType(), metadata, + descriptors); - return new JsonSchema(persistentEntity.getName(), resolveMessage(metadata.getItemResourceDescription()), - propertiesFor, descriptors); + String title = resolveMessageWithDefault(new DefaultMessageSourceResolvable( + SimpleResourceDescription.DEFAULT_KEY_PREFIX.concat(".").concat(persistentEntity.getType().getSimpleName()))); + + return new JsonSchema(title, resolveMessage(metadata.getItemResourceDescription()), propertiesFor, descriptors); } - private List> getPropertiesFor(Class type, final ResourceMetadata metadata, - final Descriptors descriptors) { + private List> getPropertiesFor(Class type, final ResourceMetadata metadata, + final Definitions 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(); + return Collections.> emptyList(); } - final List> properties = new ArrayList>(); + JsonSchemaPropertyRegistrar registrar = new JsonSchemaPropertyRegistrar(jackson); + + // 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(); + + // First pass, early drops to avoid unnecessary calculation + if (persistentProperty != null) { + + if (persistentProperty.isIdProperty() && !configuration.isIdExposedFor(type)) { + continue; + } + + if (persistentProperty.isVersionProperty()) { + continue; + } + } + + TypeInformation propertyType = persistentProperty == null + ? ClassTypeInformation.from(definition.getPrimaryMember().getRawType()) + : persistentProperty.getTypeInformation(); + TypeInformation actualPropertyType = propertyType.getActualType(); 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); + ResourceDescription description = persistentProperty == null + ? jackson.getFallbackDescription(metadata, definition) : getDescriptionFor(persistentProperty, metadata); + JsonSchemaProperty property = getSchemaProperty(definition, propertyType, description); - if (persistentProperty != null && !persistentProperty.isWritable()) { + boolean isSyntheticProperty = persistentProperty == null; + boolean isNotWritable = !isSyntheticProperty && !persistentProperty.isWritable(); + boolean isJacksonReadOnly = !isSyntheticProperty && jackson.isReadOnly(persistentProperty); + + if (isSyntheticProperty || isNotWritable || isJacksonReadOnly) { property = property.withReadOnly(); } if (format != null) { // Types with explicitly registered format -> value object with format - properties.add(property.with(format)); + registrar.register(property.withFormat(format), actualPropertyType); continue; } Pattern pattern = configuration.metadataConfiguration().getPatternFor(rawPropertyType); if (pattern != null) { - properties.add(property.with(pattern)); + registrar.register(property.withPattern(pattern), actualPropertyType); continue; } if (jackson.isValueType()) { - properties.add(property.with(STRING_TYPE_INFORMATION)); + registrar.register(property.with(STRING_TYPE_INFORMATION), actualPropertyType); continue; } if (persistentProperty == null) { - properties.add(property); - continue; - } - - if (persistentProperty.isIdProperty() && !configuration.isIdExposedFor(type)) { + registrar.register(property, actualPropertyType); continue; } if (associationLinks.isLinkableAssociation(persistentProperty)) { - properties.add(property.with(JsonSchemaFormat.URI)); + registrar.register(property.withFormat(JsonSchemaFormat.URI), null); } else { if (persistentProperty.isEntity()) { - if (!descriptors.hasDescriptorFor(propertyType)) { - descriptors.addDescriptor(propertyType, + if (!descriptors.hasDefinitionFor(propertyType)) { + descriptors.addDefinition(propertyType, new Item(propertyType, getNestedPropertiesFor(persistentProperty, descriptors))); } - properties.add(property.with(propertyType, Descriptors.getReference(propertyType))); + registrar.register(property.with(propertyType, Definitions.getReference(propertyType)), actualPropertyType); } else { - properties.add(property.with(propertyType)); + registrar.register(property.with(propertyType), actualPropertyType); } } } - return properties; + return registrar.getProperties(); } - private Collection> getNestedPropertiesFor(PersistentProperty property, - Descriptors descriptors) { + private Collection> getNestedPropertiesFor(PersistentProperty property, + Definitions descriptors) { if (!property.isEntity()) { return Collections.emptyList(); @@ -235,20 +261,24 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric return getPropertiesFor(property.getActualType(), mappings.getMetadataFor(property.getActualType()), descriptors); } - private Property getSchemaProperty(BeanPropertyDefinition definition, TypeInformation type, + private JsonSchemaProperty getSchemaProperty(BeanPropertyDefinition definition, TypeInformation type, ResourceDescription description) { String name = definition.getName(); + String title = resolveMessageWithDefault( + new DefaultMessageSourceResolvable(description.getMessage().concat("._title"))); String resolvedDescription = resolveMessage(description); boolean required = definition.isRequired(); Class rawType = type.getType(); if (!rawType.isEnum()) { - return new Property(name, resolvedDescription, required); + return new JsonSchemaProperty(name, title, resolvedDescription, required).with(type); } - return new EnumProperty(name, rawType, description.getDefaultMessage().equals(resolvedDescription) ? null - : resolvedDescription, required); + String message = resolveMessage(new DefaultMessageSourceResolvable(description.getMessage())); + + return new EnumProperty(name, title, rawType, + description.getDefaultMessage().equals(resolvedDescription) ? message : resolvedDescription, required); } private ResourceDescription getDescriptionFor(PersistentProperty property, ResourceMetadata metadata) { @@ -257,6 +287,10 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric return propertyMapping.getDescription(); } + private String resolveMessageWithDefault(MessageSourceResolvable resolvable) { + return resolveMessage(new DefaultingMessageSourceResolvable(resolvable)); + } + private String resolveMessage(MessageSourceResolvable resolvable) { if (resolvable == null) { @@ -274,4 +308,112 @@ public class PersistentEntityToJsonSchemaConverter implements ConditionalGeneric } } } + + /** + * Helper to register {@link JsonSchemaProperty} instances after post-processing them. + * + * @author Oliver Gierke + * @since 2.4 + */ + private static class JsonSchemaPropertyRegistrar { + + private final JacksonMetadata metadata; + private final List> properties; + + /** + * Creates a new {@link JsonSchemaPropertyRegistrar} using the given {@link JacksonMetadata}. + * + * @param metadata must not be {@literal null}. + */ + public JsonSchemaPropertyRegistrar(JacksonMetadata metadata) { + + Assert.notNull(metadata, "Metadata must not be null!"); + + this.metadata = metadata; + this.properties = new ArrayList>(); + } + + public void register(JsonSchemaProperty property, TypeInformation type) { + + if (type == null) { + properties.add(property); + return; + } + + JsonSerializer serializer = metadata.getTypeSerializer(type.getType()); + + if (!(serializer instanceof JsonSchemaPropertyCustomizer)) { + properties.add(property); + return; + } + + properties.add(((JsonSchemaPropertyCustomizer) serializer).customize(property, type)); + } + + public List> getProperties() { + return properties; + } + } + + /** + * Message source resolvable that defaults the messages to the last segment of the dot-separated code in case the + * configured delegate doesn't return a default message itself. + * + * @author Oliver Gierke + * @since 2.4 + */ + private static class DefaultingMessageSourceResolvable implements MessageSourceResolvable { + + private static Pattern SPLIT_CAMEL_CASE = Pattern.compile("(? entity = context.getPersistentEntity(User.class); + + ObjectMapper mapper = new ObjectMapper(); + + JacksonMetadata metadata = new JacksonMetadata(mapper, User.class); + + MongoPersistentProperty property = entity.getPersistentProperty("username"); + + assertThat(metadata.isExported(property), is(true)); + assertThat(metadata.isReadOnly(property), is(true)); + } + + /** + * @see DATAREST-644 + */ + @Test + public void detectsCustomSerializerFortType() { + + JsonSerializer serializer = new JacksonMetadata(new ObjectMapper(), SomeBean.class) + .getTypeSerializer(SomeBean.class); + + assertThat(serializer, is(instanceOf(SomeBeanSerializer.class))); + } + + static class User { + + private String username; + + @JsonProperty(access = Access.READ_ONLY) + public String getUsername() { + return username; + } + } + + @JsonSerialize(using = SomeBeanSerializer.class) + static class SomeBean {} + + @SuppressWarnings("serial") + static class SomeBeanSerializer extends StdSerializer { + + public SomeBeanSerializer() { + super(SomeBean.class); + } + + @Override + public void serialize(SomeBean value, JsonGenerator gen, SerializerProvider provider) throws IOException {} + } +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/JsonSchemaUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/JsonSchemaUnitTests.java index 0128dbcc4..f0808bc7f 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/JsonSchemaUnitTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/JsonSchemaUnitTests.java @@ -19,7 +19,7 @@ import static org.hamcrest.CoreMatchers.*; import static org.junit.Assert.*; import org.junit.Test; -import org.springframework.data.rest.webmvc.json.JsonSchema.Property; +import org.springframework.data.rest.webmvc.json.JsonSchema.JsonSchemaProperty; import org.springframework.data.util.ClassTypeInformation; import org.springframework.data.util.TypeInformation; @@ -38,7 +38,7 @@ public class JsonSchemaUnitTests { @Test public void considersNumberPrimitivesJsonSchemaNumbers() { - Property property = new JsonSchema.Property("foo", "bar", false); + JsonSchemaProperty property = new JsonSchemaProperty("foo", null, "bar", false); assertThat(property.with(type.getProperty("foo")).type, is("number")); } 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 bb2828e71..b3bc1dfd3 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 @@ -114,12 +114,12 @@ public class PersistentEntityToJsonSchemaConverterUnitTests { constraints.add(new Constraint("$.properties.id", is(nullValue()), "Does NOT have descriptor for id property")); 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'")); + .add(new Constraint("$.definitions.address", is(notNullValue()), "Exposes nested objects as definitions.")); + constraints.add(new Constraint("$.definitions.address.type", is("object"), "Nested entity is of type 'object'")); constraints.add( - new Constraint("$.descriptors.address.properties.zipCode", is(notNullValue()), "Exposes nested properties")); + new Constraint("$.definitions.address.properties.zipCode", is(notNullValue()), "Exposes nested properties")); constraints.add( - new Constraint("$.descriptors.address.requiredProperties[0]", is("zipCode"), "Lists nested required property")); + new Constraint("$.definitions.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 @@ -136,12 +136,16 @@ public class PersistentEntityToJsonSchemaConverterUnitTests { 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.")); + constraints.add(new Constraint("$.properties.shippingAddresses.items['$ref']", is("#/definitions/address"), + "References definition of complex element type.")); // DATAREST-531 constraints.add(new Constraint("$.properties.email.readOnly", is(true), "Email is read-only property")); + // DATAREST-644 + constraints.add(new Constraint("$.properties.shippingAddresses.title", is("Shipping addresses"), + "Defaults titles correctly (split at camel case)")); + assertConstraints(User.class, constraints); }