From b4b8fdfee0c67c163e9f5251fff45b0e0bd6660a Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Wed, 7 Apr 2021 17:01:55 +0200 Subject: [PATCH] Support aggregate reference resolution for DTOs. Introduce AggregateReferenceResolvingModule to be registered with the default ObjectMapper instance that will allow to materialize aggregate instances from URIs for incoming web requests. We do not apply this for aggregate roots themselves as they're already handled by the AssociationUriResolvingDeserializerModifier. --- .../RepositoryRestMvcConfiguration.java | 3 + .../AggregateReferenceResolvingModule.java | 138 ++++++++++++++++++ .../json/PersistentEntityJackson2Module.java | 42 +++--- .../json/UriStringDeserializerUnitTests.java | 6 +- 4 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/AggregateReferenceResolvingModule.java 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 6b67bf9e0..add729a44 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 @@ -921,6 +921,9 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon configurerDelegate.get().configureJacksonObjectMapper(objectMapper); objectMapper.registerModule(geoModule.getObject()); + objectMapper.registerModule(new AggregateReferenceResolvingModule( + new UriToEntityConverter(persistentEntities.get(), repositoryInvokerFactory.get(), repositories.get()), + resourceMappings.get())); if (repositoryRestConfiguration.get().isEnableEnumTranslation()) { objectMapper.registerModule(new JacksonSerializers(enumTranslator.get())); diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/AggregateReferenceResolvingModule.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/AggregateReferenceResolvingModule.java new file mode 100644 index 000000000..c02f91411 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/AggregateReferenceResolvingModule.java @@ -0,0 +1,138 @@ +/* + * Copyright 2021 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 + * + * https://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 java.util.Iterator; + +import org.springframework.data.rest.core.UriToEntityConverter; +import org.springframework.data.rest.core.mapping.ResourceMappings; +import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module.AssociationUriResolvingDeserializerModifier.ValueInstantiatorCustomizer; +import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module.CollectionValueInstantiator; +import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module.UriStringDeserializer; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.data.util.TypeInformation; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.JsonDeserializer; +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.std.CollectionDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.type.CollectionLikeType; + +/** + * Jackson module to enable aggregate reference resolution for non-aggregate root types. This is primarily useful for + * any kind of payload mapping DTO that is supposed to be able to map URIs to aggregate roots. + * + * @author Oliver Drotbohm + * @since 3.5 + */ +public class AggregateReferenceResolvingModule extends SimpleModule { + + private static final long serialVersionUID = 6002883434719869173L; + + /** + * Creates a new {@link AggregateReferenceResolvingModule} using the given {@link UriToEntityConverter} and + * {@link ResourceMappings}. + * + * @param converter must not be {@literal null}. + * @param mappings must not be {@literal null}. + */ + public AggregateReferenceResolvingModule(UriToEntityConverter converter, ResourceMappings mappings) { + setDeserializerModifier(new AggregateReferenceDeserializerModifier(converter, mappings)); + } + + /** + * {@link BeanDeserializerModifier} implementation to support URI deserialization into aggregate roots + * + * @author Oliver Drotbohm + */ + static class AggregateReferenceDeserializerModifier extends BeanDeserializerModifier { + + private final UriToEntityConverter converter; + private final ResourceMappings mappings; + + /** + * Creates a new {@link AggregateReferenceDeserializerModifier} for the given {@link UriToEntityConverter} and + * {@link ResourceMappings}. + * + * @param converter must not be {@literal null}. + * @param mappings must not be {@literal null}. + */ + public AggregateReferenceDeserializerModifier(UriToEntityConverter converter, ResourceMappings mappings) { + + Assert.notNull(converter, "UriToEntityConverter must not be null!"); + Assert.notNull(mappings, "ResourceMappings must not be null!"); + + this.converter = converter; + this.mappings = mappings; + } + + /* + * (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) { + + // Type is aggregate itself, already handled by AssociationUriResolvingDeserializerModifier + if (mappings.hasMappingFor(beanDesc.getBeanClass())) { + return builder; + } + + TypeInformation type = ClassTypeInformation.from(beanDesc.getBeanClass()); + ValueInstantiatorCustomizer customizer = new ValueInstantiatorCustomizer(builder.getValueInstantiator(), config); + Iterator properties = builder.getProperties(); + + while (properties.hasNext()) { + + SettableBeanProperty property = properties.next(); + + TypeInformation propertyType = type.getProperty(property.getName()); + TypeInformation actualType = propertyType.getActualType(); + + if (!mappings.exportsMappingFor(actualType.getType())) { + continue; + } + + UriStringDeserializer uriStringDeserializer = new UriStringDeserializer(actualType.getType(), converter); + JsonDeserializer deserializer = wrapIfCollection(propertyType, uriStringDeserializer, config); + + customizer.replacePropertyIfNeeded(builder, property.withValueDeserializer(deserializer)); + } + + return builder; + } + + private static JsonDeserializer wrapIfCollection(TypeInformation type, + JsonDeserializer elementDeserializer, DeserializationConfig config) { + + if (!type.isCollectionLike()) { + return elementDeserializer; + } + + CollectionLikeType collectionType = config.getTypeFactory() // + .constructCollectionLikeType(type.getType(), type.getActualType().getType()); + CollectionValueInstantiator instantiator = new CollectionValueInstantiator(type); + + return new CollectionDeserializer(collectionType, elementDeserializer, null, instantiator); + } + } +} 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 a7aca2675..4b25f15e7 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 @@ -48,6 +48,7 @@ import org.springframework.data.rest.webmvc.PersistentEntityResource; import org.springframework.data.rest.webmvc.mapping.Associations; import org.springframework.data.rest.webmvc.mapping.LinkCollector; import org.springframework.data.util.CastUtils; +import org.springframework.data.util.TypeInformation; import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.Link; import org.springframework.hateoas.Links; @@ -427,7 +428,6 @@ public class PersistentEntityJackson2Module extends SimpleModule { private final UriToEntityConverter converter; private final RepositoryInvokerFactory factory; - @java.lang.SuppressWarnings("all") public AssociationUriResolvingDeserializerModifier(PersistentEntities entities, Associations associations, UriToEntityConverter converter, RepositoryInvokerFactory factory) { @@ -451,7 +451,6 @@ public class PersistentEntityJackson2Module extends SimpleModule { BeanDeserializerBuilder builder) { ValueInstantiatorCustomizer customizer = new ValueInstantiatorCustomizer(builder.getValueInstantiator(), config); - Iterator properties = builder.getProperties(); entities.getPersistentEntity(beanDesc.getBeanClass()).ifPresent(entity -> { @@ -459,19 +458,19 @@ public class PersistentEntityJackson2Module extends SimpleModule { while (properties.hasNext()) { SettableBeanProperty property = properties.next(); - PersistentProperty persistentProperty = entity.getPersistentProperty(property.getName()); if (persistentProperty == null) { continue; } + TypeInformation propertyType = persistentProperty.getTypeInformation(); + if (associationLinks.isLookupType(persistentProperty)) { RepositoryInvokingDeserializer repositoryInvokingDeserializer = new RepositoryInvokingDeserializer(factory, persistentProperty); - JsonDeserializer deserializer = wrapIfCollection(persistentProperty, repositoryInvokingDeserializer, - config); + JsonDeserializer deserializer = wrapIfCollection(propertyType, repositoryInvokingDeserializer, config); builder.addOrReplaceProperty(property.withValueDeserializer(deserializer), false); continue; @@ -481,8 +480,9 @@ public class PersistentEntityJackson2Module extends SimpleModule { continue; } - UriStringDeserializer uriStringDeserializer = new UriStringDeserializer(persistentProperty, converter); - JsonDeserializer deserializer = wrapIfCollection(persistentProperty, uriStringDeserializer, config); + Class actualPropertyType = persistentProperty.getActualType(); + UriStringDeserializer uriStringDeserializer = new UriStringDeserializer(actualPropertyType, converter); + JsonDeserializer deserializer = wrapIfCollection(propertyType, uriStringDeserializer, config); customizer.replacePropertyIfNeeded(builder, property.withValueDeserializer(deserializer)); } @@ -519,7 +519,7 @@ public class PersistentEntityJackson2Module extends SimpleModule { /** * Replaces the logically same property with the given {@link SettableBeanProperty} on the given - * {@link BeanDeserializerBuilder}. In case we get a {@link CreatorProperty} we als register that one to be later + * {@link BeanDeserializerBuilder}. In case we get a {@link CreatorProperty} we also register that one to be later * exposed via the {@link ValueInstantiator} backing the {@link BeanDeserializerBuilder}. * * @param builder must not be {@literal null}. @@ -559,7 +559,7 @@ public class PersistentEntityJackson2Module extends SimpleModule { } } - private static JsonDeserializer wrapIfCollection(PersistentProperty property, + private static JsonDeserializer wrapIfCollection(TypeInformation property, JsonDeserializer elementDeserializer, DeserializationConfig config) { if (!property.isCollectionLike()) { @@ -567,7 +567,7 @@ public class PersistentEntityJackson2Module extends SimpleModule { } CollectionLikeType collectionType = config.getTypeFactory().constructCollectionLikeType(property.getType(), - property.getActualType()); + property.getActualType().getType()); CollectionValueInstantiator instantiator = new CollectionValueInstantiator(property); return new CollectionDeserializer(collectionType, elementDeserializer, null, instantiator); } @@ -580,26 +580,26 @@ public class PersistentEntityJackson2Module extends SimpleModule { * @author Oliver Gierke * @author Valentin Rentschler */ - static class UriStringDeserializer extends StdDeserializer { + public static class UriStringDeserializer extends StdDeserializer { private static final long serialVersionUID = -2175900204153350125L; private static final String UNEXPECTED_VALUE = "Expected URI cause property %s points to the managed domain type!"; - private final PersistentProperty property; + private final Class type; 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 type must not be {@literal null}. * @param converter must not be {@literal null}. */ - public UriStringDeserializer(PersistentProperty property, UriToEntityConverter converter) { + public UriStringDeserializer(Class type, UriToEntityConverter converter) { - super(property.getActualType()); + super(type); - this.property = property; + this.type = type; this.converter = converter; } @@ -618,11 +618,11 @@ public class PersistentEntityJackson2Module extends SimpleModule { try { URI uri = UriTemplate.of(source).expand(); - TypeDescriptor typeDescriptor = TypeDescriptor.valueOf(property.getActualType()); + TypeDescriptor typeDescriptor = TypeDescriptor.valueOf(type); return converter.convert(uri, URI_DESCRIPTOR, typeDescriptor); } catch (IllegalArgumentException o_O) { - throw ctxt.weirdStringException(source, URI.class, String.format(UNEXPECTED_VALUE, property)); + throw ctxt.weirdStringException(source, URI.class, String.format(UNEXPECTED_VALUE, type)); } } @@ -813,16 +813,16 @@ public class PersistentEntityJackson2Module extends SimpleModule { * * @author Oliver Gierke */ - private static class CollectionValueInstantiator extends ValueInstantiator { + static class CollectionValueInstantiator extends ValueInstantiator { - private final PersistentProperty property; + private final TypeInformation 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) { + public CollectionValueInstantiator(TypeInformation property) { Assert.notNull(property, "Property must not be null!"); Assert.isTrue(property.isCollectionLike() || property.isMap(), "Property must be a collection or map property!"); diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/UriStringDeserializerUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/UriStringDeserializerUnitTests.java index 41e4fe078..bcd8fc23b 100755 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/UriStringDeserializerUnitTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/UriStringDeserializerUnitTests.java @@ -30,7 +30,6 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.core.convert.TypeDescriptor; -import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.rest.core.UriToEntityConverter; import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module.UriStringDeserializer; import org.springframework.test.util.ReflectionTestUtils; @@ -51,7 +50,6 @@ public class UriStringDeserializerUnitTests { public @Rule ExpectedException exception = ExpectedException.none(); @Mock UriToEntityConverter converter; - @Mock PersistentProperty property; @Mock JsonParser parser; DeserializationContext context; @@ -61,7 +59,7 @@ public class UriStringDeserializerUnitTests { @Before public void setUp() { - this.deserializer = new UriStringDeserializer(property, converter); + this.deserializer = new UriStringDeserializer(Object.class, converter); // Need to hack the context as there's virtually no way wo set up a combined parser and context easily this.context = new ObjectMapper().getDeserializationContext(); @@ -94,10 +92,8 @@ public class UriStringDeserializerUnitTests { invokeConverterWith("{ \"foo\" : \"bar\" }"); } - @SuppressWarnings({ "unchecked", "rawtypes" }) private Object invokeConverterWith(String source) throws Exception { - when(property.getActualType()).thenReturn((Class) Object.class); when(parser.getValueAsString()).thenReturn(source); return deserializer.deserialize(parser, context);