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.