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); }