From e4bca534bf0e0bd7c9da374b63cfb7285294dc7a Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Sat, 11 Mar 2023 23:50:05 +0100 Subject: [PATCH] Support to receive aggregate references as request parameters. We now support using AggregateReference as type to bind request parameters taking URIs pointing to related aggregates. The default resolution will try to resolve the entire URI via UriToEntityConverter but one can also provide a function that can extract any part of the URI to be then resolved into either an identifier, aggregate instance or jMolecules Association against the ConversionService. Fixes #2239. --- spring-data-rest-core/pom.xml | 14 ++ .../data/rest/core/AggregateReference.java | 100 +++++++++++ .../core/AssociationAggregateReference.java | 59 +++++++ .../core/ResolvingAggregateReference.java | 114 ++++++++++++ .../data/rest/core/UriToEntityConverter.java | 82 ++++++--- .../data/rest/core/package-info.java | 2 + .../ResolvingAggregateReferenceUnitTests.java | 48 ++++++ .../core/UriToEntityConverterUnitTests.java | 107 ++++++++---- .../rest/tests/RepositoryTestsConfig.java | 6 +- .../webmvc/json/RepositoryTestsConfig.java | 6 +- .../spring-data-rest-tests-shop/pom.xml | 27 +-- .../rest/tests/shop/CustomController.java | 63 +++++++ .../data/rest/tests/shop/Order.java | 14 +- .../data/rest/tests/shop/OrderRepository.java | 5 +- .../rest/tests/shop/ShopConfiguration.java | 49 ++++-- .../rest/tests/shop/ShopIntegrationTests.java | 37 +++- .../webmvc/config/JMoleculesConfigurer.java | 11 +- .../RepositoryRestMvcConfiguration.java | 19 +- .../StringToAggregateReferenceConverter.java | 162 ++++++++++++++++++ ...oAggregateReferenceConverterUnitTests.java | 141 +++++++++++++++ .../overriding-sdr-response-handlers.adoc | 88 +++++++--- 21 files changed, 1025 insertions(+), 129 deletions(-) create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/AggregateReference.java create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/AssociationAggregateReference.java create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/ResolvingAggregateReference.java create mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/package-info.java create mode 100644 spring-data-rest-core/src/test/java/org/springframework/data/rest/core/ResolvingAggregateReferenceUnitTests.java create mode 100644 spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/CustomController.java create mode 100644 spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/StringToAggregateReferenceConverter.java create mode 100644 spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/StringToAggregateReferenceConverterUnitTests.java diff --git a/spring-data-rest-core/pom.xml b/spring-data-rest-core/pom.xml index 125249b94..f54a0ac5f 100644 --- a/spring-data-rest-core/pom.xml +++ b/spring-data-rest-core/pom.xml @@ -62,6 +62,13 @@ jackson-datatype-jdk8 + + org.jmolecules + jmolecules-ddd + ${jmolecules} + true + + com.google.guava guava @@ -76,6 +83,13 @@ test + + org.jmolecules.integrations + jmolecules-spring + ${jmolecules-integration} + test + + diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/AggregateReference.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/AggregateReference.java new file mode 100644 index 000000000..80b969711 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/AggregateReference.java @@ -0,0 +1,100 @@ +/* + * Copyright 2023 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.core; + +import java.net.URI; +import java.util.function.Function; + +import org.springframework.lang.Nullable; +import org.springframework.web.util.UriComponents; + +/** + * Represents a reference to an aggregate backed by a URI. It can be resolved into an aggregate identifier or the + * aggregate instance itself. + * + * @author Oliver Drotbohm + * @since 4.1 + */ +public interface AggregateReference { + + /** + * Returns the source {@link URI}. + * + * @return will never be {@literal null}. + */ + URI getUri(); + + /** + * Creates a new {@link AggregateReference} resolving the identifier source value from the given + * {@link UriComponents}. + * + * @param extractor must not be {@literal null}. + * @return will never be {@literal null}. + */ + AggregateReference withIdSource(Function extractor); + + /** + * Resolves the underlying URI into a full aggregate, potentially applying the configured identifier extractor. + * + * @return can be {@literal null}. + * @see #withIdSource(Function) + */ + @Nullable + T resolveAggregate(); + + /** + * Resolves the underlying URI into an aggregate identifier, potentially applying the registered identifier extractor. + * + * @return can be {@literal null}. + * @see #withIdSource(Function) + */ + @Nullable + ID resolveId(); + + /** + * Resolves the underlying URI into a full aggregate, potentially applying the configured identifier extractor. + * + * @return will never be {@literal null}. + * @throws IllegalStateException in case the value resolved is {@literal null}. + */ + default T resolveRequiredAggregate() { + + T result = resolveAggregate(); + + if (result == null) { + throw new IllegalStateException("Resolving the aggregate resulted in null"); + } + + return result; + } + + /** + * Resolves the underlying URI into an aggregate identifier, potentially applying the registered identifier extractor. + * + * @return will never be {@literal null}. + * @throws IllegalStateException in case the value resolved is {@literal null}. + */ + default ID resolveRequiredId() { + + ID result = resolveId(); + + if (result == null) { + throw new IllegalStateException("Resolving the aggregate identifier resulted in null"); + } + + return result; + } +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/AssociationAggregateReference.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/AssociationAggregateReference.java new file mode 100644 index 000000000..bbb9a76ed --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/AssociationAggregateReference.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023 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.core; + +import java.util.function.Function; + +import org.jmolecules.ddd.types.AggregateRoot; +import org.jmolecules.ddd.types.Association; +import org.jmolecules.ddd.types.Identifier; +import org.springframework.lang.Nullable; +import org.springframework.web.util.UriComponents; + +/** + * An {@link AggregateReference} that can also resolve into jMolecules {@link Association} instances. + * + * @author Oliver Drotbohm + * @since 4.1 + */ +public interface AssociationAggregateReference, ID extends Identifier> + extends AggregateReference { + + /** + * Resolves the underlying URI into an {@link Association}, potentially applying the configured identifier extractor. + * + * @return can be {@literal null}. + * @see #withIdSource(Function) + */ + @Nullable + default Association resolveAssociation() { + return Association.forId(resolveId()); + } + + /** + * Resolves the underlying URI into an {@link Association}, potentially applying the configured identifier extractor. + * + * @return will never be {@literal null}. + * @throws IllegalStateException in case the value resolved is {@literal null}. + */ + @SuppressWarnings("null") + default Association resolveRequiredAssociation() { + return Association.forId(resolveRequiredId()); + } + + @Override + AssociationAggregateReference withIdSource(Function extractor); +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/ResolvingAggregateReference.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/ResolvingAggregateReference.java new file mode 100644 index 000000000..081d198e0 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/ResolvingAggregateReference.java @@ -0,0 +1,114 @@ +/* + * Copyright 2023 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.core; + +import java.net.URI; +import java.util.function.Function; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * An {@link AggregateReference} implementation that resolves the source URI given a {@link Function} or into a fixed + * value. + * + * @author Oliver Drotbohm + * @since 4.1 + */ +public class ResolvingAggregateReference implements AggregateReference { + + private static final Function STARTER = it -> UriComponentsBuilder.fromUri(it).build(); + + private final URI source; + private final Function extractor; + private final Function aggregateResolver; + private final Function identifierResolver; + + /** + * Creates a new {@link ResolvingAggregateReference} for the given {@link URI} to eventually resolve the final value + * against the given resolver function. + * + * @param source must not be {@literal null}. + * @param resolver must not be {@literal null}. + */ + public ResolvingAggregateReference(URI source, Function aggregateResolver, + Function identifierResolver) { + + this(source, aggregateResolver, identifierResolver, it -> it); + } + + protected ResolvingAggregateReference(URI source, Function aggregateResolver, + Function identifierResolver, Function extractor) { + + Assert.notNull(source, "Source URI must not be null!"); + Assert.notNull(aggregateResolver, "Aggregate resolver must not be null!"); + Assert.notNull(identifierResolver, "Identifier resolver must not be null!"); + + this.source = source; + this.aggregateResolver = aggregateResolver; + this.identifierResolver = identifierResolver; + this.extractor = extractor; + } + + /** + * Creates a new {@link ResolvingAggregateReference} for the given {@link URI} resolving in the given fixed value. + * Primarily for testing purposes. + * + * @param source must not be {@literal null}. + * @param value can be {@literal null}. + */ + public ResolvingAggregateReference(URI source, @Nullable T value, ID identifier) { + this(source, __ -> value, __ -> identifier, it -> it); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.core.Foo#getURI() + */ + @Override + public URI getUri() { + return source; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.core.AggregateReference#resolveId() + */ + @Override + public ID resolveId() { + return extractor.andThen(identifierResolver).apply(source); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.core.AggregateReference#resolveAggregate() + */ + @Override + public T resolveAggregate() { + return extractor.andThen(aggregateResolver).apply(source); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.rest.core.AggregateReference#withExtractor(java.util.function.Function) + */ + @Override + public AggregateReference withIdSource(Function extractor) { + return new ResolvingAggregateReference<>(source, aggregateResolver, identifierResolver, STARTER.andThen(extractor)); + } +} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/UriToEntityConverter.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/UriToEntityConverter.java index d16f50a51..23747b2f3 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/UriToEntityConverter.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/UriToEntityConverter.java @@ -16,21 +16,21 @@ package org.springframework.data.rest.core; import java.net.URI; -import java.util.Collections; import java.util.HashSet; -import java.util.Optional; import java.util.Set; +import java.util.function.Supplier; import org.springframework.core.convert.ConversionFailedException; +import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; -import org.springframework.core.convert.converter.ConditionalGenericConverter; import org.springframework.core.convert.converter.GenericConverter; -import org.springframework.data.mapping.PersistentEntity; -import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.context.PersistentEntities; import org.springframework.data.repository.support.Repositories; import org.springframework.data.repository.support.RepositoryInvokerFactory; +import org.springframework.data.util.ReflectionUtils; import org.springframework.data.util.TypeInformation; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -39,14 +39,17 @@ import org.springframework.util.Assert; * @author Jon Brisbin * @author Oliver Gierke */ -public class UriToEntityConverter implements ConditionalGenericConverter { +public class UriToEntityConverter implements GenericConverter { - private static final TypeDescriptor URI_TYPE = TypeDescriptor.valueOf(URI.class); + private static final Class ASSOCIATION_TYPE = ReflectionUtils + .loadIfPresent("org.jmolecules.ddd.types.Association", UriToEntityConverter.class.getClassLoader()); private final PersistentEntities entities; private final RepositoryInvokerFactory invokerFactory; - private final Repositories repositories; + private final Supplier conversionService; + private final Set convertiblePairs; + private final Set> identifierTypes; /** * Creates a new {@link UriToEntityConverter} using the given {@link PersistentEntities}, @@ -57,52 +60,79 @@ public class UriToEntityConverter implements ConditionalGenericConverter { * @param repositories must not be {@literal null}. */ public UriToEntityConverter(PersistentEntities entities, RepositoryInvokerFactory invokerFactory, - Repositories repositories) { + Supplier conversionService) { Assert.notNull(entities, "PersistentEntities must not be null"); Assert.notNull(invokerFactory, "RepositoryInvokerFactory must not be null"); - Assert.notNull(repositories, "Repositories must not be null"); + Assert.notNull(conversionService, "ConversionService must not be null!"); - Set convertiblePairs = new HashSet(); + this.convertiblePairs = new HashSet(); + this.identifierTypes = new HashSet<>(); for (TypeInformation domainType : entities.getManagedTypes()) { - Class rawType = domainType.getType(); - Optional>> entity = entities.getPersistentEntity(rawType); + var rawType = domainType.getType(); + var entity = entities.getPersistentEntity(rawType); - if (entity.map(it -> it.hasIdProperty()).orElse(false)) { + entity.filter(it -> it.hasIdProperty()).ifPresent(it -> { convertiblePairs.add(new ConvertiblePair(URI.class, domainType.getType())); - } + registerIdentifierType(it.getRequiredIdProperty().getType()); + }); } - this.convertiblePairs = Collections.unmodifiableSet(convertiblePairs); this.entities = entities; this.invokerFactory = invokerFactory; - this.repositories = repositories; + this.conversionService = conversionService; + + if (ASSOCIATION_TYPE != null) { + registerIdentifierType(ASSOCIATION_TYPE); + } } - @Override - public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { - return !sourceType.equals(URI_TYPE) ? false - : repositories.getRepositoryInformationFor(targetType.getType()).isPresent(); + private void registerIdentifierType(Class type) { + + convertiblePairs.add(new ConvertiblePair(URI.class, type)); + identifierTypes.add(type); } + @NonNull @Override public Set getConvertibleTypes() { return convertiblePairs; } @Override - public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + @Nullable + public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { - Optional>> entity = entities - .getPersistentEntity(targetType.getType()); + if (source == null) { + return null; + } + + if (identifierTypes.contains(targetType.getType())) { + + var segment = getIdentifierSegment(source, sourceType, targetType); + + return conversionService.get().convert(segment, TypeDescriptor.valueOf(String.class), targetType); + } + + var entity = entities.getPersistentEntity(targetType.getType()); if (!entity.isPresent()) { throw new ConversionFailedException(sourceType, targetType, source, - new IllegalArgumentException("No PersistentEntity information available for " + targetType.getType())); + new IllegalArgumentException( + "No PersistentEntity information available for " + targetType.getType())); } + var segment = getIdentifierSegment(source, sourceType, targetType); + + return invokerFactory.getInvokerFor(targetType.getType()) + .invokeFindById(segment) + .orElse(null); + } + + private static String getIdentifierSegment(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + URI uri = (URI) source; String[] parts = uri.getPath().split("/"); @@ -111,6 +141,6 @@ public class UriToEntityConverter implements ConditionalGenericConverter { "Cannot resolve URI " + uri + "; Is it local or remote; Only local URIs are resolvable")); } - return invokerFactory.getInvokerFor(targetType.getType()).invokeFindById(parts[parts.length - 1]).orElse(null); + return parts[parts.length - 1]; } } diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/package-info.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/package-info.java new file mode 100644 index 000000000..09ceb680a --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package org.springframework.data.rest.core; diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/ResolvingAggregateReferenceUnitTests.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/ResolvingAggregateReferenceUnitTests.java new file mode 100644 index 000000000..03f426fbc --- /dev/null +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/ResolvingAggregateReferenceUnitTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2023 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.core; + +import static org.assertj.core.api.Assertions.*; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ResolvingAggregateReference}. + * + * @author Oliver Drotbohm + */ +class ResolvingAggregateReferenceUnitTests { + + @Test // GH-2239 + void usesResolverForFinalInstanceLookup() { + + var reference = new ResolvingAggregateReference<>(URI.create("/foo/42"), it -> "aggregate", it -> 42L); + + assertThat(reference.resolveAggregate()).isEqualTo("aggregate"); + assertThat(reference.resolveId()).isEqualTo(42); + } + + @Test // GH-2239 + void appliesCustomExtractor() { + + var reference = new ResolvingAggregateReference<>(URI.create("/foo/42"), it -> "aggregate", + it -> Long.valueOf(it.toString())).withIdSource(it -> it.getPathSegments().get(1)); + + assertThat(reference.resolveId()).isEqualTo(42); + } +} diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/UriToEntityConverterUnitTests.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/UriToEntityConverterUnitTests.java index 7b9c500e6..7a71d936b 100755 --- a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/UriToEntityConverterUnitTests.java +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/UriToEntityConverterUnitTests.java @@ -18,29 +18,37 @@ package org.springframework.data.rest.core; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import lombok.Value; + import java.net.URI; -import java.util.Arrays; -import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.UUID; +import org.jmolecules.ddd.types.AggregateRoot; +import org.jmolecules.ddd.types.Association; +import org.jmolecules.ddd.types.Identifier; +import org.jmolecules.spring.PrimitivesToAssociationConverter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.ResolvableType; import org.springframework.core.convert.ConversionFailedException; +import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.GenericConverter.ConvertiblePair; import org.springframework.data.annotation.Id; import org.springframework.data.keyvalue.core.mapping.context.KeyValueMappingContext; import org.springframework.data.mapping.context.PersistentEntities; -import org.springframework.data.repository.core.RepositoryInformation; -import org.springframework.data.repository.support.Repositories; import org.springframework.data.repository.support.RepositoryInvoker; import org.springframework.data.repository.support.RepositoryInvokerFactory; -import org.springframework.data.util.ClassTypeInformation; +import org.springframework.data.rest.core.UriToEntityConverterUnitTests.JMoleculesAggregateRoot.JMoleculesIdentifier; import org.springframework.data.util.Streamable; +import org.springframework.data.util.TypeInformation; +import org.springframework.format.support.DefaultFormattingConversionService; /** * Unit tests for {@link UriToEntityConverter}. @@ -53,22 +61,28 @@ class UriToEntityConverterUnitTests { static final TypeDescriptor URI_TYPE = TypeDescriptor.valueOf(URI.class); static final TypeDescriptor STRING_TYPE = TypeDescriptor.valueOf(String.class); static final TypeDescriptor ENTITY_TYPE = TypeDescriptor.valueOf(Entity.class); + static final TypeDescriptor UUID_ENTITY_TYPE = TypeDescriptor.valueOf(UuidEntity.class); + static final TypeDescriptor UUID_TYPE = TypeDescriptor.valueOf(UUID.class); + static final TypeDescriptor UNKNOWN_TYPE = TypeDescriptor.valueOf(Override.class); - @Mock Repositories repositories; @Mock RepositoryInvokerFactory invokerFactory; KeyValueMappingContext context; UriToEntityConverter converter; + ConversionService conversionService; @BeforeEach void setUp() { + var conversionService = new DefaultFormattingConversionService(); + conversionService.addConverter(new PrimitivesToAssociationConverter(() -> conversionService)); + this.context = new KeyValueMappingContext<>(); - this.context.setInitialEntitySet(new HashSet>(Arrays.asList(Entity.class, NonEntity.class))); + this.context.setInitialEntitySet(Set.of(Entity.class, NonEntity.class, UuidEntity.class)); this.context.afterPropertiesSet(); - this.converter = new UriToEntityConverter(new PersistentEntities(Arrays.asList(this.context)), invokerFactory, - repositories); + this.converter = new UriToEntityConverter(new PersistentEntities(List.of(this.context)), invokerFactory, + () -> conversionService); } @Test // DATAREST-427 @@ -80,25 +94,6 @@ class UriToEntityConverterUnitTests { assertThat(result).doesNotContain(new ConvertiblePair(URI.class, NonEntity.class)); } - @Test // DATAREST-427 - void cannotConvertEntityWithIdPropertyIfStringConversionMissing() { - assertThat(converter.matches(URI_TYPE, ENTITY_TYPE)).isFalse(); - } - - @Test // DATAREST-427 - void canConvertEntityWithIdPropertyAndFromStringConversionPossible() { - - doReturn(Optional.of(mock(RepositoryInformation.class))).when(repositories) - .getRepositoryInformationFor(ENTITY_TYPE.getType()); - - assertThat(converter.matches(URI_TYPE, ENTITY_TYPE)).isTrue(); - } - - @Test // DATAREST-427 - void cannotConvertEntityWithoutIdentifier() { - assertThat(converter.matches(URI_TYPE, TypeDescriptor.valueOf(NonEntity.class))).isFalse(); - } - @Test // DATAREST-427 void invokesConverterWithLastUriPathSegment() { @@ -115,7 +110,7 @@ class UriToEntityConverterUnitTests { void rejectsUnknownType() { assertThatExceptionOfType(ConversionFailedException.class) // - .isThrownBy(() -> converter.convert(URI.create("/foo/1"), URI_TYPE, STRING_TYPE)); + .isThrownBy(() -> converter.convert(URI.create("/foo/1"), URI_TYPE, UNKNOWN_TYPE)); } @Test // DATAREST-427 @@ -129,18 +124,19 @@ class UriToEntityConverterUnitTests { void rejectsNullPersistentEntities() { assertThatIllegalArgumentException() // - .isThrownBy(() -> new UriToEntityConverter(null, invokerFactory, repositories)); + .isThrownBy( + () -> new UriToEntityConverter(null, invokerFactory, () -> conversionService)); } @Test // DATAREST-741 void rejectsNullRepositoryInvokerFactory() { assertThatIllegalArgumentException() // - .isThrownBy(() -> new UriToEntityConverter(mock(PersistentEntities.class), null, repositories)); + .isThrownBy(() -> new UriToEntityConverter(mock(PersistentEntities.class), null, () -> conversionService)); } @Test // DATAREST-741 - void rejectsNullRepositories() { + void rejectsNullConversionService() { assertThatIllegalArgumentException() // .isThrownBy(() -> new UriToEntityConverter(mock(PersistentEntities.class), invokerFactory, null)); @@ -153,16 +149,59 @@ class UriToEntityConverterUnitTests { void doesNotRegisterTypeWithUnmanagedRawType() { PersistentEntities entities = mock(PersistentEntities.class); - doReturn(Streamable.of(ClassTypeInformation.OBJECT)).when(entities).getManagedTypes(); + doReturn(Streamable.of(TypeInformation.OBJECT)).when(entities).getManagedTypes(); - new UriToEntityConverter(entities, invokerFactory, repositories); + new UriToEntityConverter(entities, invokerFactory, () -> conversionService); + } + + @Test + void resolvesIdentifierType() { + + var uuid = UUID.randomUUID(); + + assertThat(converter.convert(URI.create("/foo/" + uuid), STRING_TYPE, UUID_TYPE)).isEqualTo(uuid); + } + + @Test + void resolvesAssociations() { + + var typeDescriptor = new TypeDescriptor( + ResolvableType.forClassWithGenerics(Association.class, JMoleculesAggregateRoot.class, + JMoleculesIdentifier.class), + null, null); + + var uuid = UUID.randomUUID(); + + assertThat(converter.convert(URI.create("/foo/" + uuid), URI_TYPE, typeDescriptor)) + .isInstanceOfSatisfying(Association.class, it -> { + assertThat(it.getId()).isEqualTo(JMoleculesIdentifier.of(uuid)); + }); } static class Entity { @Id String id; } + static class UuidEntity { + @Id UUID id; + } + static class NonEntity { String value; } + + static class JMoleculesAggregateRoot implements AggregateRoot { + + @Override + public JMoleculesIdentifier getId() { + return JMoleculesIdentifier.of(UUID.randomUUID()); + } + + @Value(staticConstructor = "of") + static class JMoleculesIdentifier implements Identifier { + UUID id; + } + } + + private static void someMethod(Association association) {} } diff --git a/spring-data-rest-tests/spring-data-rest-tests-core/src/test/java/org/springframework/data/rest/tests/RepositoryTestsConfig.java b/spring-data-rest-tests/spring-data-rest-tests-core/src/test/java/org/springframework/data/rest/tests/RepositoryTestsConfig.java index 8f91857a4..cfade1dba 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-core/src/test/java/org/springframework/data/rest/tests/RepositoryTestsConfig.java +++ b/spring-data-rest-tests/spring-data-rest-tests-core/src/test/java/org/springframework/data/rest/tests/RepositoryTestsConfig.java @@ -109,16 +109,18 @@ public class RepositoryTestsConfig { @Bean public Module persistentEntityModule() { + var conversionService = new DefaultConversionService(); + RepositoryResourceMappings mappings = new RepositoryResourceMappings(repositories(), persistentEntities(), config()); EntityLinks entityLinks = new RepositoryEntityLinks(repositories(), mappings, config(), mock(PagingAndSortingTemplateVariables.class), PluginRegistry.of(DefaultIdConverter.INSTANCE)); SelfLinkProvider selfLinkProvider = new DefaultSelfLinkProvider(persistentEntities(), entityLinks, - Collections.> emptyList(), new DefaultConversionService()); + Collections.> emptyList(), conversionService); DefaultRepositoryInvokerFactory invokerFactory = new DefaultRepositoryInvokerFactory(repositories()); UriToEntityConverter uriToEntityConverter = new UriToEntityConverter(persistentEntities(), invokerFactory, - repositories()); + () -> conversionService); Associations associations = new Associations(mappings, config()); LinkCollector collector = new DefaultLinkCollector(persistentEntities(), selfLinkProvider, associations); diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/json/RepositoryTestsConfig.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/json/RepositoryTestsConfig.java index 2ab9561ac..549028d2d 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/json/RepositoryTestsConfig.java +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/json/RepositoryTestsConfig.java @@ -116,16 +116,18 @@ class RepositoryTestsConfig { @Bean public Module persistentEntityModule() { + var conversionService = new DefaultConversionService(); + RepositoryResourceMappings mappings = new RepositoryResourceMappings(repositories(), persistentEntities(), config()); EntityLinks entityLinks = new RepositoryEntityLinks(repositories(), mappings, config(), mock(PagingAndSortingTemplateVariables.class), PluginRegistry.of(DefaultIdConverter.INSTANCE)); SelfLinkProvider selfLinkProvider = new DefaultSelfLinkProvider(persistentEntities(), entityLinks, - Collections.emptyList(), new DefaultConversionService()); + Collections.emptyList(), conversionService); DefaultRepositoryInvokerFactory invokerFactory = new DefaultRepositoryInvokerFactory(repositories()); UriToEntityConverter uriToEntityConverter = new UriToEntityConverter(persistentEntities(), invokerFactory, - repositories()); + () -> conversionService); Associations associations = new Associations(mappings, config()); LinkCollector collector = new DefaultLinkCollector(persistentEntities(), selfLinkProvider, associations); diff --git a/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml b/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml index 806ea827f..f82774b04 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml +++ b/spring-data-rest-tests/spring-data-rest-tests-shop/pom.xml @@ -1,44 +1,51 @@ 4.0.0 - org.springframework.data spring-data-rest-tests 4.1.0-SNAPSHOT - Spring Data REST Tests - Shop spring-data-rest-tests-shop - spring.data.rest.tests.shop - - ${project.groupId} spring-data-rest-tests-core ${project.version} test-jar - org.springframework.data spring-data-keyvalue ${springdata.keyvalue} - - jakarta.annotation jakarta.annotation-api 2.0.0 test - + + org.jmolecules + jmolecules-ddd + ${jmolecules} + + + org.jmolecules.integrations + jmolecules-spring + ${jmolecules-integration} + test + + + org.jmolecules.integrations + jmolecules-jackson + ${jmolecules-integration} + test + - diff --git a/spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/CustomController.java b/spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/CustomController.java new file mode 100644 index 000000000..304def55f --- /dev/null +++ b/spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/CustomController.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023 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.tests.shop; + +import org.springframework.data.rest.core.AggregateReference; +import org.springframework.data.rest.core.AssociationAggregateReference; +import org.springframework.data.rest.tests.shop.Order.OrderIdentifier; +import org.springframework.data.rest.webmvc.BasePathAwareController; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +/** + * Custom controller to mimic a user-defined one using {@link AggregateReference}s to receive references to other Spring + * Data REST managed aggregates. + * + * @author Oliver Drotbohm + * @since 4.1 + */ +@ResponseBody +@BasePathAwareController +class CustomController { + + @GetMapping("/order-custom-id") + OrderIdentifier customOrderId(@RequestParam("order") AggregateReference reference) { + + return reference // + .withIdSource(it -> it.getPathSegments().get(3)) // + .resolveId(); + } + + @GetMapping("/order-custom-association") + OrderIdentifier customOrderAssociation( + @RequestParam("order") AssociationAggregateReference reference) { + + return reference // + .withIdSource(it -> it.getPathSegments().get(3)) // + .resolveAssociation() // + .getId(); + } + + @GetMapping("/order-custom") + OrderIdentifier customOrder(@RequestParam("order") AggregateReference reference) { + + return reference // + .withIdSource(it -> it.getPathSegments().get(3)) // + .resolveAggregate() // + .getId(); + } +} diff --git a/spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/Order.java b/spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/Order.java index e81b3827b..1fe779cf5 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/Order.java +++ b/spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/Order.java @@ -17,28 +17,32 @@ package org.springframework.data.rest.tests.shop; import lombok.Value; +import java.io.Serializable; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import org.jmolecules.ddd.types.AggregateRoot; +import org.jmolecules.ddd.types.Identifier; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Reference; import org.springframework.data.rest.core.config.Projection; import org.springframework.data.rest.tests.shop.LineItem.LineItemProductsOnlyProjection; +import org.springframework.data.rest.tests.shop.Order.OrderIdentifier; /** * @author Oliver Gierke * @author Craig Andrews */ @Value -public class Order { +public class Order implements AggregateRoot { @Projection(name = "itemsOnly", types = Order.class) public interface OrderItemsOnlyProjection { List getItems(); } - private final @Id UUID id = UUID.randomUUID(); + private final @Id OrderIdentifier id = new OrderIdentifier(UUID.randomUUID()); private final List items = new ArrayList<>(); private final @Reference Customer customer; @@ -47,4 +51,10 @@ public class Order { this.items.add(item); return this; } + + @Value + static class OrderIdentifier implements Identifier, Serializable { + private static final long serialVersionUID = -3362660123468974881L; + UUID id; + } } diff --git a/spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/OrderRepository.java b/spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/OrderRepository.java index ba986ef68..f8f16767f 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/OrderRepository.java +++ b/spring-data-rest-tests/spring-data-rest-tests-shop/src/main/java/org/springframework/data/rest/tests/shop/OrderRepository.java @@ -15,13 +15,12 @@ */ package org.springframework.data.rest.tests.shop; -import java.util.UUID; - import org.springframework.data.repository.CrudRepository; +import org.springframework.data.rest.tests.shop.Order.OrderIdentifier; /** * @author Oliver Gierke */ -public interface OrderRepository extends CrudRepository { +public interface OrderRepository extends CrudRepository { } diff --git a/spring-data-rest-tests/spring-data-rest-tests-shop/src/test/java/org/springframework/data/rest/tests/shop/ShopConfiguration.java b/spring-data-rest-tests/spring-data-rest-tests-shop/src/test/java/org/springframework/data/rest/tests/shop/ShopConfiguration.java index c8b2b44ca..d57943169 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-shop/src/test/java/org/springframework/data/rest/tests/shop/ShopConfiguration.java +++ b/spring-data-rest-tests/spring-data-rest-tests-shop/src/test/java/org/springframework/data/rest/tests/shop/ShopConfiguration.java @@ -19,17 +19,22 @@ import jakarta.annotation.PostConstruct; import java.math.BigDecimal; +import org.jmolecules.jackson.JMoleculesModule; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.map.repository.config.EnableMapRepositories; import org.springframework.data.rest.core.config.EntityLookupRegistrar; +import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.tests.shop.Customer.Gender; import org.springframework.data.rest.tests.shop.Product.ProductNameOnlyProjection; import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer; import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.Link; import org.springframework.hateoas.server.RepresentationModelProcessor; +import org.springframework.web.servlet.config.annotation.CorsRegistry; + +import com.fasterxml.jackson.databind.ObjectMapper; /** * @author Oliver Gierke @@ -68,6 +73,33 @@ class ShopConfiguration { }; } + @Bean + CustomController customController() { + return new CustomController(); + } + + @Bean + RepositoryRestConfigurer repositoryRestConfigurer() { + + return new RepositoryRestConfigurer() { + + @Override + public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config, CorsRegistry cors) { + + EntityLookupRegistrar lookup = config.withEntityLookup(); + + lookup.forRepository(ProductRepository.class, Product::getName, ProductRepository::findByName); + lookup.forValueRepository(LineItemTypeRepository.class, LineItemType::getName, + LineItemTypeRepository::findByName); + } + + @Override + public void configureJacksonObjectMapper(ObjectMapper objectMapper) { + objectMapper.registerModule(new JMoleculesModule()); + } + }; + } + @PostConstruct void init() { @@ -86,21 +118,4 @@ class ShopConfiguration { orders.save(order); } - - @Configuration - static class SpringDataRestConfiguration implements RepositoryRestConfigurer { - - @Bean - RepositoryRestConfigurer configurer() { - - return RepositoryRestConfigurer.withConfig(config -> { - - EntityLookupRegistrar lookup = config.withEntityLookup(); - - lookup.forRepository(ProductRepository.class, Product::getName, ProductRepository::findByName); - lookup.forValueRepository(LineItemTypeRepository.class, LineItemType::getName, - LineItemTypeRepository::findByName); - }); - } - } } diff --git a/spring-data-rest-tests/spring-data-rest-tests-shop/src/test/java/org/springframework/data/rest/tests/shop/ShopIntegrationTests.java b/spring-data-rest-tests/spring-data-rest-tests-shop/src/test/java/org/springframework/data/rest/tests/shop/ShopIntegrationTests.java index f1c3e62aa..54011e50d 100755 --- a/spring-data-rest-tests/spring-data-rest-tests-shop/src/test/java/org/springframework/data/rest/tests/shop/ShopIntegrationTests.java +++ b/spring-data-rest-tests/spring-data-rest-tests-shop/src/test/java/org/springframework/data/rest/tests/shop/ShopIntegrationTests.java @@ -16,19 +16,21 @@ package org.springframework.data.rest.tests.shop; import static org.hamcrest.CoreMatchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.util.Collections; import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.rest.tests.AbstractWebIntegrationTests; import org.springframework.hateoas.Link; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.ResultActions; -import org.springframework.test.web.servlet.result.MockMvcResultHandlers; import com.jayway.jsonpath.JsonPath; @@ -42,6 +44,8 @@ import com.jayway.jsonpath.JsonPath; @ContextConfiguration(classes = ShopConfiguration.class) class ShopIntegrationTests extends AbstractWebIntegrationTests { + @Autowired OrderRepository orders; + @Test void rendersRepresentationCorrectly() throws Exception { @@ -70,7 +74,6 @@ class ShopIntegrationTests extends AbstractWebIntegrationTests { client.follow(client.discoverUnique("products").expand(arguments))// .andExpect(status().isOk())// - .andDo(MockMvcResultHandlers.print()) // .andExpect(jsonPath("$._embedded.products[0].name", notNullValue()))// .andExpect(jsonPath("$._embedded.products[0].price").doesNotExist()); } @@ -96,6 +99,36 @@ class ShopIntegrationTests extends AbstractWebIntegrationTests { .andExpect(jsonPath("$._embedded.orders[0].items[0].products[0]._links.beta").exists()); } + @Test // GH-2239 + void triggersCustomControllerWithAggregateReferenceToId() throws Exception { + + var uuid = UUID.randomUUID(); + + mvc.perform(get("/order-custom-id?order=/order/foo/bar/{id}", uuid)) + .andExpect(status().is2xxSuccessful()) + .andExpect(content().string("\"%s\"".formatted(uuid.toString()))); + } + + @Test // GH-2239 + void triggersCustomControllerWithAggregateReferenceToAggregate() throws Exception { + + var uuid = orders.findAll().iterator().next().getId().getId(); + + mvc.perform(get("/order-custom?order=/order/foo/bar/{id}", uuid)) + .andExpect(status().is2xxSuccessful()) + .andExpect(content().string("\"%s\"".formatted(uuid.toString()))); + } + + @Test // GH-2239 + void triggersCustomControllerWithAggregateReferenceToAssociation() throws Exception { + + var uuid = UUID.randomUUID(); + + mvc.perform(get("/order-custom-association?order=/order/foo/bar/{id}", uuid)) + .andExpect(status().is2xxSuccessful()) + .andExpect(content().string("\"%s\"".formatted(uuid.toString()))); + } + private static void expectRelatedResource(String name, ResultActions actions) throws Exception { int dotIndex = name.lastIndexOf('.'); diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/JMoleculesConfigurer.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/JMoleculesConfigurer.java index 73ab49529..40ca72ea1 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/JMoleculesConfigurer.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/JMoleculesConfigurer.java @@ -20,7 +20,9 @@ import java.util.function.Supplier; import org.jmolecules.ddd.types.Entity; import org.jmolecules.ddd.types.Identifier; +import org.jmolecules.spring.AssociationToPrimitivesConverter; import org.jmolecules.spring.IdentifierToPrimitivesConverter; +import org.jmolecules.spring.PrimitivesToAssociationConverter; import org.jmolecules.spring.PrimitivesToIdentifierConverter; import org.springframework.beans.factory.ObjectFactory; import org.springframework.beans.factory.annotation.Qualifier; @@ -73,8 +75,13 @@ class JMoleculesConfigurer implements WebMvcConfigurer, RepositoryRestConfigurer Supplier supplier = () -> conversionService; - conversionService.addConverter(new PrimitivesToIdentifierConverter(supplier)); - conversionService.addConverter(new IdentifierToPrimitivesConverter(supplier)); + var primitivesToIdentifierConverter = new PrimitivesToIdentifierConverter(supplier); + var identifierToPrimitivesConverter = new IdentifierToPrimitivesConverter(supplier); + + conversionService.addConverter(primitivesToIdentifierConverter); + conversionService.addConverter(identifierToPrimitivesConverter); + conversionService.addConverter(new AssociationToPrimitivesConverter<>(identifierToPrimitivesConverter)); + conversionService.addConverter(new PrimitivesToAssociationConverter<>(primitivesToIdentifierConverter)); } @Lazy 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 4b977dfe4..290ccef1e 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 @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.springframework.beans.factory.BeanClassLoaderAware; @@ -320,13 +321,15 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon @Bean @Qualifier public DefaultFormattingConversionService defaultConversionService(PersistentEntities persistentEntities, - RepositoryInvokerFactory repositoryInvokerFactory, Repositories repositories) { + RepositoryInvokerFactory repositoryInvokerFactory) { - DefaultFormattingConversionService conversionService = (DefaultFormattingConversionService) defaultConversionService; + var conversionService = (DefaultFormattingConversionService) defaultConversionService; + Supplier supplier = () -> conversionService; // Add Spring Data Commons formatters conversionService - .addConverter(new UriToEntityConverter(persistentEntities, repositoryInvokerFactory, repositories)); + .addConverter(new UriToEntityConverter(persistentEntities, repositoryInvokerFactory, supplier)); + conversionService.addConverter(new StringToAggregateReferenceConverter(supplier)); conversionService.addConverter(StringToLdapNameConverter.INSTANCE); addFormatters(conversionService); @@ -638,8 +641,8 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon } /** - * Special {@link org.springframework.web.servlet.HandlerAdapter} that only recognizes handler methods defined in - * the provided controller classes. + * Special {@link org.springframework.web.servlet.HandlerAdapter} that only recognizes handler methods defined in the + * provided controller classes. * * @return */ @@ -734,7 +737,8 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon PluginRegistry.of(getEntityLookups())); return new PersistentEntityJackson2Module(associationLinks.get(), persistentEntities.get(), - new UriToEntityConverter(persistentEntities.get(), repositoryInvokerFactory.get(), repositories.get()), + new UriToEntityConverter(persistentEntities.get(), repositoryInvokerFactory.get(), + () -> defaultConversionService), linkCollector, repositoryInvokerFactory.get(), lookupObjectSerializer, invoker.getObject(), assembler); } @@ -954,7 +958,8 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon objectMapper.registerModule(geoModule.getObject()); objectMapper.registerModule(new AggregateReferenceResolvingModule( - new UriToEntityConverter(persistentEntities.get(), repositoryInvokerFactory.get(), repositories.get()), + new UriToEntityConverter(persistentEntities.get(), repositoryInvokerFactory.get(), + () -> defaultConversionService), resourceMappings.get())); if (repositoryRestConfiguration.get().isEnableEnumTranslation()) { diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/StringToAggregateReferenceConverter.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/StringToAggregateReferenceConverter.java new file mode 100644 index 000000000..374dd7205 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/StringToAggregateReferenceConverter.java @@ -0,0 +1,162 @@ +/* + * Copyright 2023 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.config; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.jmolecules.ddd.types.AggregateRoot; +import org.jmolecules.ddd.types.Identifier; +import org.springframework.core.convert.ConversionFailedException; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.data.rest.core.AggregateReference; +import org.springframework.data.rest.core.AssociationAggregateReference; +import org.springframework.data.rest.core.ResolvingAggregateReference; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.web.util.UriComponents; + +/** + * A {@link GenericConverter} to convert {@link String}s into {@link AggregateReference} instance for the latter to be + * injectable into Spring MVC controller methods. + * + * @author Oliver Drotbohm + * @since 4.1 + */ +class StringToAggregateReferenceConverter implements GenericConverter { + + private static final boolean JMOLECULES_PRESENT = ClassUtils.isPresent( + "org.jmolecules.spring.IdentifierToPrimitivesConverter", + StringToAggregateReferenceConverter.class.getClassLoader()); + private static final Class ASSOCIATION_AGGREGATE_REFERENCE_TYPE = tryToLoadAssociationReferenceClass(); + + private final Supplier conversionService; + + /** + * Creates a new {@link StringToAggregateReferenceConverter} for the given {@link ConversionService}. + * + * @param conversionService must not be {@literal null}. + */ + StringToAggregateReferenceConverter(Supplier conversionService) { + + Assert.notNull(conversionService, "ConversionService must not be null!"); + + this.conversionService = conversionService; + } + + private static Class tryToLoadAssociationReferenceClass() { + + var classLoader = StringToAggregateReferenceConverter.class.getClassLoader(); + + if (!ClassUtils.isPresent("org.jmolecules.ddd.types.Association", classLoader)) { + return null; + } + + try { + return ClassUtils.forName("org.springframework.data.rest.core.AssociationAggregateReference", classLoader); + } catch (ClassNotFoundException o_O) { + return null; + } + } + + @NonNull + @Override + public Set getConvertibleTypes() { + return Set.of(new ConvertiblePair(String.class, AggregateReference.class)); + } + + @NonNull + @Override + public AggregateReference convert(@Nullable Object source, TypeDescriptor sourceType, + TypeDescriptor targetType) { + + if (source == null) { + throw new ConversionFailedException(sourceType, targetType, source, + new IllegalArgumentException("Source value must not be null")); + } + + try { + + var uri = new URI(source.toString()); + var resolvableType = targetType.getResolvableType(); + + var aggregateDescriptor = new TypeDescriptor(resolvableType.getGeneric(0), null, targetType.getAnnotations()); + var identifierDescriptor = new TypeDescriptor(resolvableType.getGeneric(1), null, targetType.getAnnotations()); + + Function aggregateResolver = it -> conversionService.get().convert(it, sourceType, + aggregateDescriptor); + Function identifierResolver = it -> conversionService.get().convert(it, sourceType, + identifierDescriptor); + + var result = new ResolvingAggregateReference<>(uri, aggregateResolver, identifierResolver); + + return JMOLECULES_PRESENT && resolvableType.toClass().equals(ASSOCIATION_AGGREGATE_REFERENCE_TYPE) // + ? withJMolecules(result) // + : result; + + } catch (URISyntaxException e) { + throw new ConversionFailedException(sourceType, targetType, source, e); + } + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static AggregateReference withJMolecules(AggregateReference source) { + return new ResolvingAssociationAggregateReference(source); + } + + /** + * An {@link AssociationAggregateReference} delegating to a simple {@link AggregateReference}. + * + * @author Oliver Drotbohm + * @since 4.1 + */ + private static class ResolvingAssociationAggregateReference, ID extends Identifier> + implements AssociationAggregateReference { + + private AggregateReference delegate; + + ResolvingAssociationAggregateReference(AggregateReference delegate) { + this.delegate = delegate; + } + + @Override + public URI getUri() { + return delegate.getUri(); + } + + @Override + public ID resolveId() { + return delegate.resolveId(); + } + + @Override + public T resolveAggregate() { + return delegate.resolveAggregate(); + } + + @Override + public AssociationAggregateReference withIdSource(Function extractor) { + return new ResolvingAssociationAggregateReference(delegate.withIdSource(extractor)); + } + } +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/StringToAggregateReferenceConverterUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/StringToAggregateReferenceConverterUnitTests.java new file mode 100644 index 000000000..216067c9e --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/StringToAggregateReferenceConverterUnitTests.java @@ -0,0 +1,141 @@ +/* + * Copyright 2023 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.config; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.net.URI; +import java.util.UUID; + +import org.jmolecules.ddd.types.Association; +import org.jmolecules.ddd.types.Identifier; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.ResolvableType; +import org.springframework.core.convert.ConversionFailedException; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.rest.core.AggregateReference; +import org.springframework.data.rest.core.AssociationAggregateReference; + +/** + * Unit tests for {@link StringToAggregateReferenceConverter}. + * + * @author Oliver Drotbohm + */ +@ExtendWith(MockitoExtension.class) +class StringToAggregateReferenceConverterUnitTests { + + @Mock ConversionService conversionService; + + StringToAggregateReferenceConverter converter = new StringToAggregateReferenceConverter(() -> conversionService); + + @Test // GH-2239 + void convertsUriIntoAggregateReference() { + + var aggregate = new Object(); + + when(conversionService.convert(any(), any(), eq(toTypeDescriptor(Long.class)))).thenReturn(42L); + when(conversionService.convert(any(), any(), eq(toTypeDescriptor(Object.class)))).thenReturn(aggregate); + + var source = "/foo/42"; + + var result = converter.convert(source, TypeDescriptor.valueOf(String.class), + toTypeDescriptor(AggregateReference.class, Object.class, Long.class)); + + assertThat(result.getUri()).isEqualTo(URI.create(source)); + assertThat(result.resolveId()).isEqualTo(42L); + assertThat(result.resolveAggregate()).isEqualTo(aggregate); + } + + @Test // GH-2239 + void convertsUriIntoAggregateReferenceUsingCustomExtractor() { + + var aggregate = new Object(); + + when(conversionService.convert(any(), any(), eq(toTypeDescriptor(Long.class)))).thenReturn(42L); + when(conversionService.convert(any(), any(), eq(toTypeDescriptor(Object.class)))).thenReturn(aggregate); + + var source = "/foo/42"; + + var result = converter.convert(source, TypeDescriptor.valueOf(String.class), + toTypeDescriptor(AggregateReference.class, Object.class, Long.class)); + + result = result.withIdSource(it -> it.getPathSegments().get(1)); + + assertThat(result.getUri()).isEqualTo(URI.create(source)); + assertThat(result.resolveId()).isEqualTo(42L); + assertThat(result.resolveAggregate()).isEqualTo(aggregate); + } + + @Test // GH-2239 + void createsAssociationAggregateReference() { + + var identifier = new CustomIdentifier(); + + when(conversionService.convert(any(), any(), eq(toTypeDescriptor(CustomIdentifier.class)))).thenReturn(identifier); + + var source = "/foo/42"; + + var result = converter.convert(source, TypeDescriptor.valueOf(String.class), + toTypeDescriptor(AssociationAggregateReference.class, Object.class, CustomIdentifier.class)); + + assertThat(result).isInstanceOfSatisfying(AssociationAggregateReference.class, it -> { + assertThat(it.resolveAssociation()).isNotNull() + .extracting(Association::getId).isEqualTo(identifier); + }); + } + + @Test // GH-2239 + void rejectsNullSource() { + + assertThatExceptionOfType(ConversionFailedException.class) + .isThrownBy(() -> converter.convert(null, TypeDescriptor.valueOf(String.class), + toTypeDescriptor(AggregateReference.class, Object.class, UUID.class))); + } + + @Test // GH-2239 + void rejectsInvalidURI() { + + assertThatExceptionOfType(ConversionFailedException.class) + .isThrownBy(() -> converter.convert("@\\", TypeDescriptor.valueOf(String.class), + toTypeDescriptor(AggregateReference.class, Object.class, UUID.class))); + } + + @Test // GH-2239 + void registersConversions() { + + var service = new DefaultConversionService(); + service.addConverter(converter); + + assertThat(service.canConvert(String.class, AggregateReference.class)).isTrue(); + assertThat(service.canConvert(String.class, AssociationAggregateReference.class)).isTrue(); + } + + private static TypeDescriptor toTypeDescriptor(Class type, Class... generics) { + + var resolvableType = ResolvableType.forClassWithGenerics(type, generics); + + return new TypeDescriptor(resolvableType, null, null); + } + + private static class CustomIdentifier implements Identifier {} +} diff --git a/src/main/asciidoc/overriding-sdr-response-handlers.adoc b/src/main/asciidoc/overriding-sdr-response-handlers.adoc index fc3f95418..9e479aee2 100644 --- a/src/main/asciidoc/overriding-sdr-response-handlers.adoc +++ b/src/main/asciidoc/overriding-sdr-response-handlers.adoc @@ -1,43 +1,44 @@ [[customizing-sdr.overriding-sdr-response-handlers]] = Overriding Spring Data REST Response Handlers -Sometimes, you may want to write a custom handler for a specific resource. To take advantage of Spring Data REST's settings, message converters, exception handling, and more, use the `@RepositoryRestController` annotation instead of a standard Spring MVC `@Controller` or `@RestController`. Controllers annotated with `@RepositoryRestController` are served from the API base path defined in `RepositoryRestConfiguration.setBasePath`, which is used by all other RESTful endpoints (for example, `/api`). The following example shows how to use the `@RepositoryRestController` annotation: +Sometimes, you may want to write a custom handler for a specific resource. +To take advantage of Spring Data REST's settings, message converters, exception handling, and more, use the `@RepositoryRestController` annotation instead of a standard Spring MVC `@Controller` or `@RestController`. +Controllers annotated with `@RepositoryRestController` are served from the API base path defined in `RepositoryRestConfiguration.setBasePath`, which is used by all other RESTful endpoints (for example, `/api`). +The following example shows how to use the `@RepositoryRestController` annotation: ==== [source,java] ---- -@RepositoryRestController -public class ScannerController { +@BasePathAwareController +class ScannerController { - private final ScannerRepository repository; + private final ScannerRepository repository; - @Autowired - public ScannerController(ScannerRepository repo) { // <1> - repository = repo; - } + ScannerController(ScannerRepository repository) { // <1> + repository = repository; + } - @RequestMapping(method = GET, value = "/scanners/search/listProducers") // <2> - public @ResponseBody ResponseEntity getProducers() { - List producers = repository.listProducers(); // <3> + @GetMapping(path = "/scanners/search/producers") // <2> + ResponseEntity getProducers() { - // - // do some intermediate processing, logging, etc. with the producers - // + List producers = repository.listProducers(); // <3> - CollectionModel resources = CollectionModel.of(producers); // <4> + // + // do some intermediate processing, logging, etc. with the producers + // - resources.add(linkTo(methodOn(ScannerController.class).getProducers()).withSelfRel()); // <5> + CollectionModel resources = CollectionModel.of(producers); // <4> - // add other links as needed + resources.add(linkTo(methodOn(ScannerController.class).getProducers()).withSelfRel()); // <5> - return ResponseEntity.ok(resources); // <6> - } + // add other links as needed + return ResponseEntity.ok(resources); // <6> + } } ---- - <1> This example uses constructor injection. -<2> This handler plugs in a custom handler for a Spring Data finder method. +<2> This handler plugs in a custom handler method as query method resource <3> This handler uses the underlying repository to fetch data, but then does some form of post processing before returning the final data set to the client. <4> The results of type T need to be wrapped up in a Spring HATEOAS `CollectionModel` object to return a collection. `EntityModel` or `RepresentationModel` are suitable wrappers for a single item, respectively. <5> Add a link back to this exact method as a `self` link. @@ -48,8 +49,51 @@ public class ScannerController { IMPORTANT: In this example, the combined path is `RepositoryRestConfiguration.getBasePath()` + `/scanners/search/listProducers`. +[[customizing-sdr.aggregate-references] +== Obtaining Aggregate References + +For custom controllers receiving `PUT` and `POST` requests, the request body usually contains a JSON document that will use URIs to express references to other resources. +For `GET` requests, those references are handed in via a request parameter. + +As of Spring Data REST 4.1, we provide `AggregateReference` to be used as handler method parameter type to capture such references and resolve them into either the referenced aggregate's identifier, the aggregate itself or a jMolecules `Association`. +All you need to do is declare an `@RequestParam` of that type and then consume either the identifier or the fully resolved aggregate. + +[source,java] +---- +@BasePathAwareController +class ScannerController { + + private final ScannerRepository repository; + + ScannerController(ScannerRepository repository) { + repository = repository; + } + + @GetMapping(path = "/scanners") + ResponseEntity getProducers( + @RequestParam AggregateReference producer) { + + var identifier = producer.resolveRequiredId(); + // Alternatively + var aggregate = producer.resolveRequiredAggregate(); + } + + // Alternatively + + @GetMapping(path = "/scanners") + ResponseEntity getProducers( + @RequestParam AssociationAggregateReference producer) { + + var association = producer.resolveRequiredAssociation(); + } +} +---- + +In case you are using jMolecules, `AssociationAggregateReference` also allows you to obtain an `Association`. +While both of the abstraction assume the value for the parameter to be a URI matching the scheme that Spring Data REST uses to expose item resources, that source value resolution can be customized by calling `….withIdSource(…)` on the reference instance to provide a function to extract the identifier value to be used for aggregate resolution eventually from the `UriComponents` obtained from the URI received. + [[customizing-sdr.overriding-sdr-response-handlers.annotations]] -== @RepositoryRestResource VS. @BasePathAwareController +== `@RepositoryRestResource` VS. `@BasePathAwareController` If you are not interested in entity-specific operations but still want to build custom operations underneath `basePath`, such as Spring MVC views, resources, and others, use `@BasePathAwareController`. If you're using `@RepositoryRestController` on your custom controller, it will only handle the request if your request mappings blend into the URI space used by the repository.