From 3e5914d84f9708015b36a8840a25e9fa10a7f6ae Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Wed, 19 Feb 2014 11:20:43 +0100 Subject: [PATCH] DATAREST-248 - Significant overhaul in entity serialization and deserialization. Refactored PersistentEntityJackson2Module to move a lot of the customization logic into Bean(De)SerializerModifiers. Those allow to modify the Jackson metadata for a given type programmatically so that we can register the appropriate (de)serializers for associations. On the serializing side of things this allows us to easily turn associations into links and simply delegate to object serialization as the delegation will simply ignore the association properties as they have been dropped using the modifier. This solves the advanced requirements of DATAREST-117 nicely as all non-association properties are serialized using standard Jackson means so that all customizations apply. On the deserialization side, we now support URIs as values for association properties to be able to submit references for non-optional associations on creation. Related issue: DATAREST-117. --- ...nverter.java => UriToEntityConverter.java} | 15 +- .../data/rest/core/RepositoryTestsConfig.java | 21 +- .../RepositoryRestMvcConfiguration.java | 10 +- .../json/PersistentEntityJackson2Module.java | 436 +++++++++++++----- .../data/rest/webmvc/jpa/JpaWebTests.java | 27 +- .../PersistentEntitySerializationTests.java | 47 ++ .../webmvc/json/RepositoryTestsConfig.java | 11 +- .../data/rest/webmvc/util/TestUtils.java | 47 ++ 8 files changed, 458 insertions(+), 156 deletions(-) rename spring-data-rest-core/src/main/java/org/springframework/data/rest/core/{UriDomainClassConverter.java => UriToEntityConverter.java} (88%) create mode 100644 spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/TestUtils.java diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/UriDomainClassConverter.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/UriToEntityConverter.java similarity index 88% rename from spring-data-rest-core/src/main/java/org/springframework/data/rest/core/UriDomainClassConverter.java rename to spring-data-rest-core/src/main/java/org/springframework/data/rest/core/UriToEntityConverter.java index f430b51cb..e86cf16c5 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/UriDomainClassConverter.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/UriToEntityConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2013 the original author or authors. + * Copyright 2012-2014 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. @@ -25,6 +25,7 @@ import org.springframework.core.convert.converter.ConditionalGenericConverter; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.repository.support.DomainClassConverter; import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.util.Assert; /** @@ -33,7 +34,7 @@ import org.springframework.util.Assert; * @author Jon Brisbin * @author Oliver Gierke */ -public class UriDomainClassConverter implements ConditionalGenericConverter { +public class UriToEntityConverter implements ConditionalGenericConverter { private static final TypeDescriptor STRING_TYPE = TypeDescriptor.valueOf(String.class); @@ -41,14 +42,15 @@ public class UriDomainClassConverter implements ConditionalGenericConverter { private final DomainClassConverter domainClassConverter; private final Set convertiblePairs; + private ResourceMappings mappings; + /** - * Creates a new {@link UriDomainClassConverter} using the given {@link Repositories} and {@link DomainClassConverter} - * . + * Creates a new {@link UriToEntityConverter} using the given {@link Repositories} and {@link DomainClassConverter}. * * @param repositories must not be {@literal null}. * @param domainClassConverter must not be {@literal null}. */ - public UriDomainClassConverter(Repositories repositories, DomainClassConverter domainClassConverter) { + public UriToEntityConverter(Repositories repositories, DomainClassConverter domainClassConverter) { Assert.notNull(repositories, "Repositories must not be null!"); Assert.notNull(domainClassConverter, "DomainClassConverter must not be null!"); @@ -69,6 +71,8 @@ public class UriDomainClassConverter implements ConditionalGenericConverter { @Override public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + mappings.exportsMappingFor(targetType.getType()); + return URI.class.isAssignableFrom(sourceType.getType()) && repositories.getPersistentEntity(targetType.getType()) != null; } @@ -106,5 +110,4 @@ public class UriDomainClassConverter implements ConditionalGenericConverter { return domainClassConverter.convert(parts[parts.length - 1], STRING_TYPE, targetType); } - } diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/RepositoryTestsConfig.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/RepositoryTestsConfig.java index 886e89568..24e3c1674 100644 --- a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/RepositoryTestsConfig.java +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/RepositoryTestsConfig.java @@ -1,3 +1,18 @@ +/* + * Copyright 2012-2014 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; import org.springframework.beans.factory.annotation.Autowired; @@ -7,7 +22,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.data.repository.support.DomainClassConverter; import org.springframework.data.repository.support.Repositories; -import org.springframework.data.rest.core.UriDomainClassConverter; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.domain.jpa.ConfiguredPersonRepository; import org.springframework.data.rest.core.domain.jpa.JpaRepositoryConfig; @@ -17,6 +31,7 @@ import org.springframework.format.support.DefaultFormattingConversionService; /** * @author Jon Brisbin + * @author Oliver Gierke */ @Configuration @Import({ JpaRepositoryConfig.class }) @@ -56,7 +71,7 @@ public class RepositoryTestsConfig { } @Bean - public UriDomainClassConverter uriDomainClassConverter() { - return new UriDomainClassConverter(repositories(), domainClassConverter()); + public UriToEntityConverter uriToEntityConverter() { + return new UriToEntityConverter(repositories(), domainClassConverter()); } } 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 03f8ae594..7c0223745 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 @@ -36,7 +36,7 @@ import org.springframework.context.support.ReloadableResourceBundleMessageSource import org.springframework.core.convert.support.ConfigurableConversionService; import org.springframework.data.repository.support.DomainClassConverter; import org.springframework.data.repository.support.Repositories; -import org.springframework.data.rest.core.UriDomainClassConverter; +import org.springframework.data.rest.core.UriToEntityConverter; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.event.AnnotatedHandlerBeanPostProcessor; import org.springframework.data.rest.core.event.ValidatingRepositoryEventListener; @@ -51,8 +51,8 @@ import org.springframework.data.rest.webmvc.PersistentEntityResourceHandlerMetho import org.springframework.data.rest.webmvc.RepositoryRestController; import org.springframework.data.rest.webmvc.RepositoryRestHandlerAdapter; import org.springframework.data.rest.webmvc.RepositoryRestHandlerMapping; -import org.springframework.data.rest.webmvc.RootResourceInformationHandlerMethodArgumentResolver; import org.springframework.data.rest.webmvc.ResourceMetadataHandlerMethodArgumentResolver; +import org.springframework.data.rest.webmvc.RootResourceInformationHandlerMethodArgumentResolver; import org.springframework.data.rest.webmvc.ServerHttpRequestMethodArgumentResolver; import org.springframework.data.rest.webmvc.convert.UriListHttpMessageConverter; import org.springframework.data.rest.webmvc.json.Jackson2DatatypeHelper; @@ -148,8 +148,8 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon } @Bean - public UriDomainClassConverter uriDomainClassConverter() { - return new UriDomainClassConverter(repositories(), domainClassConverter()); + public UriToEntityConverter uriToEntityConverter() { + return new UriToEntityConverter(repositories(), domainClassConverter()); } /** @@ -428,7 +428,7 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon */ @Bean public Module persistentEntityJackson2Module() { - return new PersistentEntityJackson2Module(resourceMappings(), defaultConversionService()); + return new PersistentEntityJackson2Module(resourceMappings(), repositories(), config(), uriToEntityConverter()); } /** diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java index f1b93ae4c..4a4ffd7ad 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/PersistentEntityJackson2Module.java @@ -16,25 +16,22 @@ package org.springframework.data.rest.webmvc.json; import java.io.IOException; +import java.net.URI; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashSet; -import java.util.LinkedHashMap; +import java.util.Iterator; import java.util.List; -import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.convert.ConversionService; +import org.springframework.core.CollectionFactory; +import org.springframework.core.convert.TypeDescriptor; import org.springframework.data.mapping.Association; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.SimpleAssociationHandler; -import org.springframework.data.mapping.SimplePropertyHandler; import org.springframework.data.mapping.model.BeanWrapper; import org.springframework.data.repository.support.Repositories; +import org.springframework.data.rest.core.UriToEntityConverter; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.mapping.ResourceMapping; import org.springframework.data.rest.core.mapping.ResourceMappings; @@ -45,17 +42,34 @@ import org.springframework.hateoas.Link; import org.springframework.hateoas.Resource; import org.springframework.util.Assert; -import com.fasterxml.jackson.annotation.JsonAnyGetter; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.core.JsonGenerationException; import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializationConfig; import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.deser.BeanDeserializerBuilder; +import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier; +import com.fasterxml.jackson.databind.deser.SettableBeanProperty; +import com.fasterxml.jackson.databind.deser.ValueInstantiator; +import com.fasterxml.jackson.databind.deser.std.CollectionDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; +import com.fasterxml.jackson.databind.ser.BeanSerializerBuilder; +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.databind.type.CollectionLikeType; /** + * Jackson 2 module to serialize and deserialize {@link PersistentEntityResource}s. + * * @author Jon Brisbin * @author Oliver Gierke * @author Greg Turnquist @@ -64,22 +78,30 @@ public class PersistentEntityJackson2Module extends SimpleModule { private static final long serialVersionUID = -7289265674870906323L; private static final Logger LOG = LoggerFactory.getLogger(PersistentEntityJackson2Module.class); + private static final TypeDescriptor URI_DESCRIPTOR = TypeDescriptor.valueOf(URI.class); - private final ResourceMappings mappings; + /** + * Creates a new {@link PersistentEntityJackson2Module} using the given {@link ResourceMappings}, {@link Repositories} + * , {@link RepositoryRestConfiguration} and {@link UriToEntityConverter}. + * + * @param mappings must not be {@literal null}. + * @param repositories must not be {@literal null}. + * @param config must not be {@literal null}. + * @param converter must not be {@literal null}. + */ + public PersistentEntityJackson2Module(ResourceMappings mappings, Repositories repositories, + RepositoryRestConfiguration config, UriToEntityConverter converter) { - @Autowired private Repositories repositories; - @Autowired private RepositoryRestConfiguration config; + super(new Version(2, 0, 0, null, "org.springframework.data.rest", "jackson-module")); - public PersistentEntityJackson2Module(ResourceMappings resourceMappings, ConversionService conversionService) { + Assert.notNull(mappings, "ResourceMappings must not be null!"); + Assert.notNull(repositories, "Repositories must not be null!"); + Assert.notNull(config, "RepositoryRestConfiguration must not be null!"); + Assert.notNull(converter, "UriToEntityConverter must not be null!"); - super(new Version(1, 1, 0, "BUILD-SNAPSHOT", "org.springframework.data.rest", "jackson-module")); - - Assert.notNull(resourceMappings, "ResourceMappings must not be null!"); - Assert.notNull(conversionService, "ConversionService must not be null!"); - - this.mappings = resourceMappings; - - addSerializer(new ResourceSerializer()); + addSerializer(new PersistentEntityResourceSerializer(mappings, config)); + setSerializerModifier(new AssociationOmittingSerializerModifier(repositories, mappings, config)); + setDeserializerModifier(new AssociationUriResolvingDeserializerModifier(repositories, converter, mappings)); } public static boolean maybeAddAssociationLink(RepositoryLinkBuilder builder, ResourceMappings mappings, @@ -104,11 +126,34 @@ public class PersistentEntityJackson2Module extends SimpleModule { return false; } - private class ResourceSerializer extends StdSerializer> { + /** + * Custom {@link JsonSerializer} for {@link PersistentEntityResource}s to turn associations into {@link Link}s. + * Delegates to standard {@link Resource} serialization afterwards. + * + * @author Oliver Gierke + */ + private static class PersistentEntityResourceSerializer extends StdSerializer> { + private final ResourceMappings mappings; + private final RepositoryRestConfiguration configuration; + + /** + * Creates a new {@link PersistentEntityResourceSerializer} using the given {@link ResourceMappings} and + * {@link RepositoryRestConfiguration}. + * + * @param mappings must not be {@literal null}. + * @param configuration must not be {@literal null}. + */ @SuppressWarnings({ "unchecked", "rawtypes" }) - private ResourceSerializer() { + private PersistentEntityResourceSerializer(ResourceMappings mappings, RepositoryRestConfiguration configuration) { + super((Class) PersistentEntityResource.class); + + Assert.notNull(mappings, "ResourceMappings must not be null!"); + Assert.notNull(configuration, "RepositoryRestConfiguration must not be null!"); + + this.mappings = mappings; + this.configuration = configuration; } /* @@ -124,117 +169,282 @@ public class PersistentEntityJackson2Module extends SimpleModule { } Object obj = resource.getContent(); + PersistentEntity entity = resource.getPersistentEntity(); + BeanWrapper, Object> wrapper = BeanWrapper.create(obj, null); + Object entityId = wrapper.getProperty(entity.getIdProperty()); + ResourceMetadata metadata = mappings.getMappingFor(entity.getType()); + URI baseUri = configuration.getBaseUri(); - final PersistentEntity entity = resource.getPersistentEntity(); - final BeanWrapper, Object> wrapper = BeanWrapper.create(obj, null); - final Object entityId = wrapper.getProperty(entity.getIdProperty()); - final ResourceMetadata metadata = mappings.getMappingFor(entity.getType()); - final RepositoryLinkBuilder builder = new RepositoryLinkBuilder(metadata, config.getBaseUri()).slash(entityId); - + final RepositoryLinkBuilder builder = new RepositoryLinkBuilder(metadata, baseUri).slash(entityId); final List links = new ArrayList(); - // Start with ResourceProcessor-added links links.addAll(resource.getLinks()); - final Map model = new LinkedHashMap(); - final Collection fieldsToIgnore = new HashSet(); + // Add associations as links + entity.doWithAssociations(new SimpleAssociationHandler() { - JsonIgnoreProperties ann = entity.getType().getAnnotation(JsonIgnoreProperties.class); + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.SimpleAssociationHandler#doWithAssociation(org.springframework.data.mapping.Association) + */ + @Override + public void doWithAssociation(Association> association) { - if (ann != null) { - fieldsToIgnore.addAll(Arrays.asList(ann.value())); - } + PersistentProperty property = association.getInverse(); - try { - - entity.doWithProperties(new SimplePropertyHandler() { - - /* - * (non-Javadoc) - * @see org.springframework.data.mapping.SimplePropertyHandler#doWithPersistentProperty(org.springframework.data.mapping.PersistentProperty) - */ - @Override - public void doWithPersistentProperty(PersistentProperty property) { - - boolean idAvailableAndShallNotBeExposed = property.isIdProperty() - && !config.isIdExposedFor(entity.getType()); - - if (idAvailableAndShallNotBeExposed) { - return; - } - - if (property.isAnnotationPresent(JsonIgnore.class)) { - return; - } - - if (fieldsToIgnore.contains(property.getName())) { - return; - } - - // Property is a normal or non-managed property. - model.put(property.getName(), wrapper.getProperty(property)); + if (maybeAddAssociationLink(builder, mappings, property, links)) { + return; } - }); + } + }); - // Add associations as links - entity.doWithAssociations(new SimpleAssociationHandler() { - - /* - * (non-Javadoc) - * @see org.springframework.data.mapping.SimpleAssociationHandler#doWithAssociation(org.springframework.data.mapping.Association) - */ - @Override - public void doWithAssociation(Association> association) { - - PersistentProperty property = association.getInverse(); - - if (maybeAddAssociationLink(builder, mappings, property, links)) { - return; - } - - if (property.isAnnotationPresent(JsonIgnore.class)) { - return; - } - - // Association Link was not added, probably because this isn't a managed type. Add value of property inline. - if (metadata.isExported(property)) { - model.put(property.getName(), wrapper.getProperty(property)); - } - - } - }); - - MapResource mapResource = new MapResource(model, links); - jgen.writeObject(mapResource); - - } catch (IllegalStateException e) { - throw (IOException) e.getCause(); - } + Resource resourceToRender = new Resource(obj, links); + provider.defaultSerializeValue(resourceToRender, jgen); } } - private static class MapResource extends Resource> { + /** + * {@link BeanSerializerModifier} to drop the property descriptors for associations. + * + * @author Oliver Gierke + */ + private static class AssociationOmittingSerializerModifier extends BeanSerializerModifier { + + private final Repositories repositories; + private final ResourceMappings mappings; + private final RepositoryRestConfiguration configuration; /** - * @param content - * @param links + * Creates a new {@link AssociationOmittingSerializerModifier} for the given {@link Repositories}, + * {@link ResourceMappings} and {@link RepositoryRestConfiguration}. + * + * @param repositories must not be {@literal null}. + * @param mappings must not be {@literal null}. + * @param configuration must not be {@literal null}. */ - public MapResource(Map content, Iterable links) { - super(content, links); + private AssociationOmittingSerializerModifier(Repositories repositories, ResourceMappings mappings, + RepositoryRestConfiguration configuration) { + + this.repositories = repositories; + this.mappings = mappings; + this.configuration = configuration; } /* * (non-Javadoc) - * @see org.springframework.hateoas.Resource#getContent() + * @see com.fasterxml.jackson.databind.ser.BeanSerializerModifier#updateBuilder(com.fasterxml.jackson.databind.SerializationConfig, com.fasterxml.jackson.databind.BeanDescription, com.fasterxml.jackson.databind.ser.BeanSerializerBuilder) */ @Override - @JsonIgnore - public Map getContent() { - return super.getContent(); + public BeanSerializerBuilder updateBuilder(SerializationConfig config, BeanDescription beanDesc, + BeanSerializerBuilder builder) { + + PersistentEntity entity = repositories.getPersistentEntity(beanDesc.getBeanClass()); + + if (entity == null) { + return builder; + } + + List result = new ArrayList(); + ResourceMetadata resourceMetadata = mappings.getMappingFor(entity.getType()); + + for (BeanPropertyWriter writer : builder.getProperties()) { + + PersistentProperty persistentProperty = entity.getPersistentProperty(writer.getName()); + + if (persistentProperty.isAssociation()) { + + if (!resourceMetadata.isManagedResource(persistentProperty)) { + continue; + } + + if (mappings.getMappingFor(persistentProperty.getActualType()).isExported()) { + continue; + } + + ResourceMapping propertyMapping = resourceMetadata.getMappingFor(persistentProperty); + + if (!propertyMapping.isExported()) { + continue; + } + } + + if (persistentProperty.isIdProperty() && !configuration.isIdExposedFor(entity.getType())) { + continue; + } + + result.add(writer); + } + + builder.setProperties(result); + + return builder; + } + } + + /** + * A {@link BeanDeserializerModifier} that registers a custom {@link UriStringDeserializer} for association properties + * of {@link PersistentEntity}s. This allows to submit URIs for those properties in request payloads, so that + * non-optional associations can be populated on resource creation. + * + * @author Oliver Gierke + */ + private static class AssociationUriResolvingDeserializerModifier extends BeanDeserializerModifier { + + private final UriToEntityConverter converter; + private final Repositories repositories; + private final ResourceMappings mappings; + + /** + * Creates a new {@link AssociationUriResolvingDeserializerModifier} using the given {@link Repositories}, + * {@link UriToEntityConverter} and {@link ResourceMappings}. + * + * @param repositories must not be {@literal null}. + * @param converter must not be {@literal null}. + * @param mappings must not be {@literal null}. + */ + public AssociationUriResolvingDeserializerModifier(Repositories repositories, UriToEntityConverter converter, + ResourceMappings mappings) { + + Assert.notNull(repositories, "Repositories must not be null!"); + Assert.notNull(converter, "UriToEntityConverter must not be null!"); + Assert.notNull(mappings, "ResourceMappings must not be null!"); + + this.repositories = repositories; + this.converter = converter; + this.mappings = mappings; } - @JsonAnyGetter - public Map any() { - return getContent(); + /* + * (non-Javadoc) + * @see com.fasterxml.jackson.databind.deser.BeanDeserializerModifier#updateBuilder(com.fasterxml.jackson.databind.DeserializationConfig, com.fasterxml.jackson.databind.BeanDescription, com.fasterxml.jackson.databind.deser.BeanDeserializerBuilder) + */ + @Override + public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanDescription beanDesc, + BeanDeserializerBuilder builder) { + + Iterator properties = builder.getProperties(); + PersistentEntity entity = repositories.getPersistentEntity(beanDesc.getBeanClass()); + ResourceMetadata metadata = mappings.getMappingFor(beanDesc.getBeanClass()); + + if (entity == null) { + return builder; + } + + while (properties.hasNext()) { + + SettableBeanProperty property = properties.next(); + PersistentProperty persistentProperty = entity.getPersistentProperty(property.getName()); + ResourceMapping propertyMapping = metadata.getMappingFor(persistentProperty); + + if (!persistentProperty.isAssociation() || !propertyMapping.isExported()) { + continue; + } + + UriStringDeserializer uriStringDeserializer = new UriStringDeserializer(persistentProperty, converter); + + if (persistentProperty.isCollectionLike()) { + + CollectionLikeType collectionType = config.getTypeFactory().constructCollectionLikeType( + persistentProperty.getType(), persistentProperty.getActualType()); + CollectionValueInstantiator instantiator = new CollectionValueInstantiator(persistentProperty); + CollectionDeserializer collectionDeserializer = new CollectionDeserializer(collectionType, + uriStringDeserializer, null, instantiator); + + builder.addOrReplaceProperty(property.withValueDeserializer(collectionDeserializer), false); + + } else { + builder.addOrReplaceProperty(property.withValueDeserializer(uriStringDeserializer), false); + } + } + + return builder; + } + } + + /** + * Custom {@link JsonDeserializer} to interpret {@link String} values as URIs and resolve them using a + * {@link UriToEntityConverter}. + * + * @author Oliver Gierke + */ + private static class UriStringDeserializer extends StdDeserializer { + + private static final long serialVersionUID = -2175900204153350125L; + + private final PersistentProperty property; + private final UriToEntityConverter converter; + + /** + * Creates a new {@link UriStringDeserializer} for the given {@link PersistentProperty} using the given + * {@link UriToEntityConverter}. + * + * @param property must not be {@literal null}. + * @param converter must not be {@literal null}. + */ + public UriStringDeserializer(PersistentProperty property, UriToEntityConverter converter) { + + super(property.getActualType()); + + this.property = property; + this.converter = converter; + } + + /* + * (non-Javadoc) + * @see com.fasterxml.jackson.databind.JsonDeserializer#deserialize(com.fasterxml.jackson.core.JsonParser, com.fasterxml.jackson.databind.DeserializationContext) + */ + @Override + public Object deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException { + + String uriString = jp.getValueAsString(); + TypeDescriptor typeDescriptor = TypeDescriptor.valueOf(property.getActualType()); + + return converter.convert(URI.create(uriString), URI_DESCRIPTOR, typeDescriptor); + } + } + + /** + * {@link ValueInstantiator} to create collection or map instances based on the type of the configured + * {@link PersistentProperty}. + * + * @author Oliver Gierke + */ + private static class CollectionValueInstantiator extends ValueInstantiator { + + private final PersistentProperty property; + + /** + * Creates a new {@link CollectionValueInstantiator} for the given {@link PersistentProperty}. + * + * @param property must not be {@literal null} and must be a collection. + */ + public CollectionValueInstantiator(PersistentProperty property) { + + Assert.notNull(property, "Property must not be null!"); + Assert.isTrue(property.isCollectionLike() || property.isMap(), "Property must be a collection or map property!"); + + this.property = property; + } + + /* + * (non-Javadoc) + * @see com.fasterxml.jackson.databind.deser.ValueInstantiator#getValueTypeDesc() + */ + @Override + public String getValueTypeDesc() { + return property.getType().getName(); + } + + /* + * (non-Javadoc) + * @see com.fasterxml.jackson.databind.deser.ValueInstantiator#createUsingDefault(com.fasterxml.jackson.databind.DeserializationContext) + */ + @Override + public Object createUsingDefault(DeserializationContext ctxt) throws IOException, JsonProcessingException { + + Class collectionOrMapType = property.getType(); + + return property.isMap() ? CollectionFactory.createMap(collectionOrMapType, 0) : CollectionFactory + .createCollection(collectionOrMapType, 0); } } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java index 97ff8cf7c..f1a49c92c 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java @@ -17,6 +17,7 @@ package org.springframework.data.rest.webmvc.jpa; import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; +import static org.springframework.data.rest.webmvc.util.TestUtils.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -25,12 +26,10 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Scanner; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.ClassPathResource; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.webmvc.AbstractWebIntegrationTests; import org.springframework.hateoas.Link; @@ -90,7 +89,7 @@ public class JpaWebTests extends AbstractWebIntegrationTests { */ @Override protected Map getPayloadToPost() throws Exception { - return Collections.singletonMap("people", readFile("person.json")); + return Collections.singletonMap("people", readFileFromClasspath("person.json")); } /* @@ -172,7 +171,7 @@ public class JpaWebTests extends AbstractWebIntegrationTests { mvc.perform(// put("/orders/{id}", 4711).// - content(readFile("order.json")).contentType(MediaType.APPLICATION_JSON)// + content(readFileFromClasspath("order.json")).contentType(MediaType.APPLICATION_JSON)// ).andExpect(status().isCreated()); } @@ -447,26 +446,6 @@ public class JpaWebTests extends AbstractWebIntegrationTests { assertThat(persons, hasItems(siblingNames)); } - private static String readFile(String name) throws Exception { - - ClassPathResource file = new ClassPathResource(name, JpaWebTests.class); - StringBuilder builder = new StringBuilder(); - - Scanner scanner = new Scanner(file.getFile(), "UTF-8"); - - try { - - while (scanner.hasNextLine()) { - builder.append(scanner.nextLine()); - } - - } finally { - scanner.close(); - } - - return builder.toString(); - } - private static String toUriList(Link... links) { List uris = new ArrayList(links.length); diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntitySerializationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntitySerializationTests.java index 856ffe51a..013f2388b 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntitySerializationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/PersistentEntitySerializationTests.java @@ -29,8 +29,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.repository.support.Repositories; import org.springframework.data.rest.webmvc.PersistentEntityResource; +import org.springframework.data.rest.webmvc.jpa.Order; import org.springframework.data.rest.webmvc.jpa.Person; import org.springframework.data.rest.webmvc.jpa.PersonRepository; +import org.springframework.data.rest.webmvc.util.TestUtils; import org.springframework.hateoas.Link; import org.springframework.hateoas.LinkDiscoverer; import org.springframework.hateoas.hal.HalLinkDiscoverer; @@ -42,8 +44,11 @@ import org.springframework.web.util.UriTemplate; import com.fasterxml.jackson.databind.ObjectMapper; /** + * Integration tests for entity (de)serialization. + * * @author Jon Brisbin * @author Greg Turnquist + * @author Oliver Gierke */ @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = RepositoryTestsConfig.class) @@ -111,4 +116,46 @@ public class PersistentEntitySerializationTests { Link siblingLink = linkDiscoverer.findLinkWithRel("siblings", s); assertThat(siblingLink.getHref(), endsWith(new UriTemplate("/{id}/siblings").expand(person.getId()).toString())); } + + /** + * @see DATAREST-248 + */ + @Test + public void deserializesPersonWithLinkToOtherPersonCorrectly() throws Exception { + + Person father = people.save(new Person("John", "Doe")); + + String child = String.format("{ \"firstName\" : \"Bilbo\", \"father\" : \"/persons/%s\"}", father.getId()); + Person result = mapper.readValue(child, Person.class); + + assertThat(result.getFather(), is(father)); + } + + /** + * @see DATAREST-248 + */ + @Test + public void deserializesPersonWithLinkToOtherPersonsCorrectly() throws Exception { + + Person firstSibling = people.save(new Person("John", "Doe")); + Person secondSibling = people.save(new Person("Dave", "Doe")); + + String child = String.format("{ \"firstName\" : \"Bilbo\", \"siblings\" : [\"/persons/%s\", \"/persons/%s\"]}", + firstSibling.getId(), secondSibling.getId()); + Person result = mapper.readValue(child, Person.class); + + assertThat(result.getSiblings(), hasItems(firstSibling, secondSibling)); + } + + /** + * @see DATAREST-248 + */ + @Test + public void deserializesEmbeddedAssociationsCorrectly() throws Exception { + + String content = TestUtils.readFileFromClasspath("order.json"); + + Order order = mapper.readValue(content, Order.class); + assertThat(order.getLineItems(), hasSize(2)); + } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/RepositoryTestsConfig.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/RepositoryTestsConfig.java index 44caeb9ec..5832600aa 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/RepositoryTestsConfig.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/RepositoryTestsConfig.java @@ -24,7 +24,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.data.repository.support.DomainClassConverter; import org.springframework.data.repository.support.Repositories; -import org.springframework.data.rest.core.UriDomainClassConverter; +import org.springframework.data.rest.core.UriToEntityConverter; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.webmvc.jpa.JpaRepositoryConfig; @@ -42,6 +42,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; /** * @author Jon Brisbin * @author Greg Trunquist + * @author Oliver Gierke */ @Configuration @Import({ JpaRepositoryConfig.class }) @@ -85,14 +86,14 @@ public class RepositoryTestsConfig { } @Bean - public UriDomainClassConverter uriDomainClassConverter() { - return new UriDomainClassConverter(repositories(), domainClassConverter()); + public UriToEntityConverter uriToEntityConverter() { + return new UriToEntityConverter(repositories(), domainClassConverter()); } @Bean public Module persistentEntityModule() { - return new PersistentEntityJackson2Module(new ResourceMappings(config(), repositories()), - defaultConversionService()); + return new PersistentEntityJackson2Module(new ResourceMappings(config(), repositories()), repositories(), config(), + uriToEntityConverter()); } @Bean diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/TestUtils.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/TestUtils.java new file mode 100644 index 000000000..c8c0513c9 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/TestUtils.java @@ -0,0 +1,47 @@ +/* + * Copyright 2014 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.util; + +import java.util.Scanner; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.rest.webmvc.jpa.JpaWebTests; + +/** + * @author Oliver Gierke + */ +public class TestUtils { + + public static String readFileFromClasspath(String name) throws Exception { + + ClassPathResource file = new ClassPathResource(name, JpaWebTests.class); + StringBuilder builder = new StringBuilder(); + + Scanner scanner = new Scanner(file.getFile(), "UTF-8"); + + try { + + while (scanner.hasNextLine()) { + builder.append(scanner.nextLine()); + } + + } finally { + scanner.close(); + } + + return builder.toString(); + } +}