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.