Support to receive aggregate references as request parameters.

We now support using AggregateReference as type to bind request parameters taking URIs pointing to related aggregates. The default resolution will try to resolve the entire URI via UriToEntityConverter but one can also provide a function that can extract any part of the URI to be then resolved into either an identifier, aggregate instance or jMolecules Association against the ConversionService.

Fixes #2239.
This commit is contained in:
Oliver Drotbohm
2023-03-11 23:50:05 +01:00
parent 6d0034f15f
commit e4bca534bf
21 changed files with 1025 additions and 129 deletions

View File

@@ -62,6 +62,13 @@
<artifactId>jackson-datatype-jdk8</artifactId>
</dependency>
<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-ddd</artifactId>
<version>${jmolecules}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
@@ -76,6 +83,13 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jmolecules.integrations</groupId>
<artifactId>jmolecules-spring</artifactId>
<version>${jmolecules-integration}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -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<T, ID> {
/**
* 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<T, ID> withIdSource(Function<UriComponents, Object> 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;
}
}

View File

@@ -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<T extends AggregateRoot<T, ID>, ID extends Identifier>
extends AggregateReference<T, ID> {
/**
* 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<T, ID> 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<T, ID> resolveRequiredAssociation() {
return Association.forId(resolveRequiredId());
}
@Override
AssociationAggregateReference<T, ID> withIdSource(Function<UriComponents, Object> extractor);
}

View File

@@ -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<T, ID> implements AggregateReference<T, ID> {
private static final Function<URI, UriComponents> STARTER = it -> UriComponentsBuilder.fromUri(it).build();
private final URI source;
private final Function<URI, ? extends Object> extractor;
private final Function<Object, ? extends T> aggregateResolver;
private final Function<Object, ? extends ID> 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<Object, ? extends T> aggregateResolver,
Function<Object, ? extends ID> identifierResolver) {
this(source, aggregateResolver, identifierResolver, it -> it);
}
protected ResolvingAggregateReference(URI source, Function<Object, ? extends T> aggregateResolver,
Function<Object, ? extends ID> identifierResolver, Function<URI, ? extends Object> 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<T, ID> withIdSource(Function<UriComponents, Object> extractor) {
return new ResolvingAggregateReference<>(source, aggregateResolver, identifierResolver, STARTER.andThen(extractor));
}
}

View File

@@ -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> conversionService;
private final Set<ConvertiblePair> convertiblePairs;
private final Set<Class<?>> 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> 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<ConvertiblePair> convertiblePairs = new HashSet<ConvertiblePair>();
this.convertiblePairs = new HashSet<ConvertiblePair>();
this.identifierTypes = new HashSet<>();
for (TypeInformation<?> domainType : entities.getManagedTypes()) {
Class<?> rawType = domainType.getType();
Optional<PersistentEntity<?, ? extends PersistentProperty<?>>> 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<ConvertiblePair> 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<PersistentEntity<?, ? extends PersistentProperty<?>>> 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];
}
}

View File

@@ -0,0 +1,2 @@
@org.springframework.lang.NonNullApi
package org.springframework.data.rest.core;

View File

@@ -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);
}
}

View File

@@ -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<Class<?>>(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<JMoleculesAggregateRoot, JMoleculesIdentifier> {
@Override
public JMoleculesIdentifier getId() {
return JMoleculesIdentifier.of(UUID.randomUUID());
}
@Value(staticConstructor = "of")
static class JMoleculesIdentifier implements Identifier {
UUID id;
}
}
private static void someMethod(Association<JMoleculesAggregateRoot, JMoleculesIdentifier> association) {}
}

View File

@@ -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.<EntityLookup<?>> emptyList(), new DefaultConversionService());
Collections.<EntityLookup<?>> 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);

View File

@@ -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);

View File

@@ -1,44 +1,51 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-tests</artifactId>
<version>4.1.0-SNAPSHOT</version>
</parent>
<name>Spring Data REST Tests - Shop</name>
<artifactId>spring-data-rest-tests-shop</artifactId>
<properties>
<java-module-name>spring.data.rest.tests.shop</java-module-name>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>spring-data-rest-tests-core</artifactId>
<version>${project.version}</version>
<type>test-jar</type>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-keyvalue</artifactId>
<version>${springdata.keyvalue}</version>
</dependency>
<!-- Explicit declaration required on Java 11 and above-->
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
<version>2.0.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-ddd</artifactId>
<version>${jmolecules}</version>
</dependency>
<dependency>
<groupId>org.jmolecules.integrations</groupId>
<artifactId>jmolecules-spring</artifactId>
<version>${jmolecules-integration}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jmolecules.integrations</groupId>
<artifactId>jmolecules-jackson</artifactId>
<version>${jmolecules-integration}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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<Order, OrderIdentifier> reference) {
return reference //
.withIdSource(it -> it.getPathSegments().get(3)) //
.resolveId();
}
@GetMapping("/order-custom-association")
OrderIdentifier customOrderAssociation(
@RequestParam("order") AssociationAggregateReference<Order, OrderIdentifier> reference) {
return reference //
.withIdSource(it -> it.getPathSegments().get(3)) //
.resolveAssociation() //
.getId();
}
@GetMapping("/order-custom")
OrderIdentifier customOrder(@RequestParam("order") AggregateReference<Order, OrderIdentifier> reference) {
return reference //
.withIdSource(it -> it.getPathSegments().get(3)) //
.resolveAggregate() //
.getId();
}
}

View File

@@ -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<Order, OrderIdentifier> {
@Projection(name = "itemsOnly", types = Order.class)
public interface OrderItemsOnlyProjection {
List<LineItemProductsOnlyProjection> getItems();
}
private final @Id UUID id = UUID.randomUUID();
private final @Id OrderIdentifier id = new OrderIdentifier(UUID.randomUUID());
private final List<LineItem> 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;
}
}

View File

@@ -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<Order, UUID> {
public interface OrderRepository extends CrudRepository<Order, OrderIdentifier> {
}

View File

@@ -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);
});
}
}
}

View File

@@ -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('.');

View File

@@ -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<ConversionService> 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

View File

@@ -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<ConversionService> 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()) {

View File

@@ -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> conversionService;
/**
* Creates a new {@link StringToAggregateReferenceConverter} for the given {@link ConversionService}.
*
* @param conversionService must not be {@literal null}.
*/
StringToAggregateReferenceConverter(Supplier<ConversionService> 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<ConvertiblePair> 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<Object, Object> aggregateResolver = it -> conversionService.get().convert(it, sourceType,
aggregateDescriptor);
Function<Object, Object> 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<T extends AggregateRoot<T, ID>, ID extends Identifier>
implements AssociationAggregateReference<T, ID> {
private AggregateReference<T, ID> delegate;
ResolvingAssociationAggregateReference(AggregateReference<T, ID> 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<T, ID> withIdSource(Function<UriComponents, Object> extractor) {
return new ResolvingAssociationAggregateReference<T, ID>(delegate.withIdSource(extractor));
}
}
}

View File

@@ -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 {}
}

View File

@@ -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<String> producers = repository.listProducers(); // <3>
@GetMapping(path = "/scanners/search/producers") // <2>
ResponseEntity<?> getProducers() {
//
// do some intermediate processing, logging, etc. with the producers
//
List<String> producers = repository.listProducers(); // <3>
CollectionModel<String> resources = CollectionModel.of(producers); // <4>
//
// do some intermediate processing, logging, etc. with the producers
//
resources.add(linkTo(methodOn(ScannerController.class).getProducers()).withSelfRel()); // <5>
CollectionModel<String> 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<T>` object to return a collection. `EntityModel<T>` or `RepresentationModel<T>` 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<T, ID>` 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, ProducerIdentifier> producer) {
var identifier = producer.resolveRequiredId();
// Alternatively
var aggregate = producer.resolveRequiredAggregate();
}
// Alternatively
@GetMapping(path = "/scanners")
ResponseEntity<?> getProducers(
@RequestParam AssociationAggregateReference<Producer, ProducerIdentifier> 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.