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 b8eb0b3f5..72ab5a0dd 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
@@ -43,6 +43,7 @@ import org.springframework.data.rest.webmvc.EmbeddedResourcesAssembler;
import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module;
import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module.LookupObjectSerializer;
import org.springframework.data.rest.webmvc.mapping.Associations;
+import org.springframework.data.rest.webmvc.mapping.DefaultLinkCollector;
import org.springframework.data.rest.webmvc.mapping.LinkCollector;
import org.springframework.data.rest.webmvc.spi.BackendIdConverter.DefaultIdConverter;
import org.springframework.data.rest.webmvc.support.ExcerptProjector;
@@ -120,7 +121,7 @@ public class RepositoryTestsConfig {
repositories());
Associations associations = new Associations(mappings, config());
- LinkCollector collector = new LinkCollector(persistentEntities(), selfLinkProvider, associations);
+ LinkCollector collector = new DefaultLinkCollector(persistentEntities(), selfLinkProvider, associations);
return new PersistentEntityJackson2Module(associations, persistentEntities(), uriToEntityConverter, collector,
invokerFactory, mock(LookupObjectSerializer.class),
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 06f71630f..a339e8f46 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
@@ -43,6 +43,7 @@ import org.springframework.data.rest.webmvc.jpa.Person;
import org.springframework.data.rest.webmvc.jpa.PersonRepository;
import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module.LookupObjectSerializer;
import org.springframework.data.rest.webmvc.mapping.Associations;
+import org.springframework.data.rest.webmvc.mapping.DefaultLinkCollector;
import org.springframework.data.rest.webmvc.mapping.LinkCollector;
import org.springframework.data.rest.webmvc.spi.BackendIdConverter.DefaultIdConverter;
import org.springframework.data.rest.webmvc.support.ExcerptProjector;
@@ -127,7 +128,7 @@ public class RepositoryTestsConfig {
repositories());
Associations associations = new Associations(mappings, config());
- LinkCollector collector = new LinkCollector(persistentEntities(), selfLinkProvider, associations);
+ LinkCollector collector = new DefaultLinkCollector(persistentEntities(), selfLinkProvider, associations);
return new PersistentEntityJackson2Module(associations, persistentEntities(), uriToEntityConverter, collector,
invokerFactory, mock(LookupObjectSerializer.class),
diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestConfigurer.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestConfigurer.java
index ad1811f78..5a2ad33c8 100644
--- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestConfigurer.java
+++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestConfigurer.java
@@ -23,6 +23,7 @@ import org.springframework.core.convert.support.ConfigurableConversionService;
import org.springframework.data.auditing.AuditableBeanWrapperFactory;
import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
import org.springframework.data.rest.core.event.ValidatingRepositoryEventListener;
+import org.springframework.data.rest.webmvc.mapping.LinkCollector;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.util.Assert;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
@@ -144,4 +145,15 @@ public interface RepositoryRestConfigurer {
default AuditableBeanWrapperFactory customizeAuditableBeanWrapperFactory(AuditableBeanWrapperFactory factory) {
return factory;
}
+
+ /**
+ * Customize the {@link LinkCollector} to be used.
+ *
+ * @param collector will never be {@literal null}.
+ * @return must not be {@literal null}.
+ * @since 3.5
+ */
+ default LinkCollector customizeLinkCollector(LinkCollector collector) {
+ return collector;
+ }
}
diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestConfigurerDelegate.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestConfigurerDelegate.java
index 1be406fb0..99a3c7c81 100644
--- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestConfigurerDelegate.java
+++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestConfigurerDelegate.java
@@ -21,6 +21,7 @@ import org.springframework.core.convert.support.ConfigurableConversionService;
import org.springframework.data.auditing.AuditableBeanWrapperFactory;
import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
import org.springframework.data.rest.core.event.ValidatingRepositoryEventListener;
+import org.springframework.data.rest.webmvc.mapping.LinkCollector;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.util.Assert;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
@@ -136,4 +137,18 @@ class RepositoryRestConfigurerDelegate implements RepositoryRestConfigurer {
return factory;
}
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer#customizeLinkCollector(org.springframework.data.rest.webmvc.mapping.LinkCollector)
+ */
+ @Override
+ public LinkCollector customizeLinkCollector(LinkCollector collector) {
+
+ for (RepositoryRestConfigurer configurer : delegates) {
+ collector = configurer.customizeLinkCollector(collector);
+ }
+
+ return collector;
+ }
}
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 9caf6fc3a..97d3de9f9 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
@@ -74,6 +74,7 @@ import org.springframework.data.rest.webmvc.json.*;
import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module.LookupObjectSerializer;
import org.springframework.data.rest.webmvc.json.PersistentEntityToJsonSchemaConverter.ValueTypeSchemaPropertyCustomizerFactory;
import org.springframework.data.rest.webmvc.mapping.Associations;
+import org.springframework.data.rest.webmvc.mapping.DefaultLinkCollector;
import org.springframework.data.rest.webmvc.mapping.LinkCollector;
import org.springframework.data.rest.webmvc.spi.BackendIdConverter;
import org.springframework.data.rest.webmvc.spi.BackendIdConverter.DefaultIdConverter;
@@ -727,7 +728,9 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon
@Bean
protected LinkCollector linkCollector(PersistentEntities persistentEntities, SelfLinkProvider selfLinkProvider,
Associations associationLinks) {
- return new LinkCollector(persistentEntities, selfLinkProvider, associationLinks);
+
+ return configurerDelegate.get()
+ .customizeLinkCollector(new DefaultLinkCollector(persistentEntities, selfLinkProvider, associationLinks));
}
@Bean
diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/mapping/DefaultLinkCollector.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/mapping/DefaultLinkCollector.java
new file mode 100644
index 000000000..0f9f7b495
--- /dev/null
+++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/mapping/DefaultLinkCollector.java
@@ -0,0 +1,254 @@
+/*
+ * Copyright 2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.rest.webmvc.mapping;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.springframework.data.mapping.Association;
+import org.springframework.data.mapping.MappingException;
+import org.springframework.data.mapping.PersistentEntity;
+import org.springframework.data.mapping.PersistentProperty;
+import org.springframework.data.mapping.PersistentPropertyAccessor;
+import org.springframework.data.mapping.SimpleAssociationHandler;
+import org.springframework.data.mapping.context.PersistentEntities;
+import org.springframework.data.rest.core.Path;
+import org.springframework.data.rest.core.mapping.ResourceMapping;
+import org.springframework.data.rest.core.mapping.ResourceMetadata;
+import org.springframework.data.rest.core.support.SelfLinkProvider;
+import org.springframework.hateoas.IanaLinkRelations;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.Links;
+import org.springframework.util.Assert;
+
+/**
+ * A service to collect all standard links that need to be added to a certain object.
+ *
+ * @author Oliver Drotbohm
+ * @since 3.6
+ */
+public class DefaultLinkCollector implements LinkCollector {
+
+ private final PersistentEntities entities;
+ private final Associations associationLinks;
+ private final SelfLinkProvider links;
+
+ /**
+ * Creates a new {@link DefaultLinkCollector} for the given {@link PersistentEntities}, {@link SelfLinkProvider} and
+ * {@link Associations}.
+ *
+ * @param entities must not be {@literal null}.
+ * @param linkProvider must not be {@literal null}.
+ * @param associationLinks must not be {@literal null}.
+ */
+ public DefaultLinkCollector(PersistentEntities entities, SelfLinkProvider linkProvider,
+ Associations associationLinks) {
+
+ Assert.notNull(entities, "PersistentEntities must not be null!");
+ Assert.notNull(linkProvider, "SelfLinkProvider must not be null!");
+ Assert.notNull(associationLinks, "AssociationLinks must not be null!");
+
+ this.links = linkProvider;
+ this.entities = entities;
+ this.associationLinks = associationLinks;
+ }
+
+ /**
+ * Returns all {@link Links} for the given object.
+ *
+ * @param object must not be {@literal null}.
+ * @return
+ */
+ @Override
+ public Links getLinksFor(Object object) {
+ return getLinksFor(object, Links.NONE);
+ }
+
+ /**
+ * Returns all {@link Links} for the given object and already existing {@link Link}.
+ *
+ * @param object must not be {@literal null}.
+ * @param existingLinks must not be {@literal null}.
+ * @return
+ */
+ @Override
+ public Links getLinksFor(Object object, Links existingLinks) {
+
+ Assert.notNull(object, "Object must not be null!");
+ Assert.notNull(existingLinks, "Existing links must not be null!");
+
+ Link selfLink = createSelfLink(object, existingLinks);
+
+ if (selfLink == null) {
+ return existingLinks;
+ }
+
+ Path path = new Path(selfLink.expand().getHref());
+
+ LinkCollectingAssociationHandler handler = new LinkCollectingAssociationHandler(path, associationLinks);
+ entities.getRequiredPersistentEntity(object.getClass()).doWithAssociations(handler);
+
+ return addSelfLinkIfNecessary(object, existingLinks.and(handler.getLinks()));
+ }
+
+ @Override
+ public Links getLinksForNested(Object object, Links existing) {
+
+ PersistentEntity, ?> entity = entities.getRequiredPersistentEntity(object.getClass());
+
+ NestedLinkCollectingAssociationHandler handler = new NestedLinkCollectingAssociationHandler(links,
+ entity.getPropertyAccessor(object), associationLinks);
+ entity.doWithAssociations(handler);
+
+ return existing.and(handler.getLinks());
+ }
+
+ private Links addSelfLinkIfNecessary(Object object, Links existing) {
+ return existing.andIf(!existing.hasLink(IanaLinkRelations.SELF),
+ () -> links.createSelfLinkFor(object).withSelfRel());
+ }
+
+ private Link createSelfLink(Object object, Links existing) {
+
+ return existing.getLink(IanaLinkRelations.SELF) //
+ .orElseGet(() -> links.createSelfLinkFor(object).withSelfRel());
+ }
+
+ /**
+ * {@link SimpleAssociationHandler} that will collect {@link Link}s for all linkable associations.
+ *
+ * @author Oliver Gierke
+ * @since 2.1
+ */
+ private static class LinkCollectingAssociationHandler implements SimpleAssociationHandler {
+
+ private static final String AMBIGUOUS_ASSOCIATIONS = "Detected multiple association links with same relation type! Disambiguate association %s using @RestResource!";
+
+ private final Path basePath;
+ private final Associations associationLinks;
+ private final List links = new ArrayList();
+
+ public LinkCollectingAssociationHandler(Path basePath, Associations associationLinks) {
+
+ Assert.notNull(basePath, "Base Path must not be null!");
+ Assert.notNull(associationLinks, "Associations must not be null!");
+
+ this.basePath = basePath;
+ this.associationLinks = associationLinks;
+ }
+
+ /**
+ * Returns the links collected after the {@link Association} has been traversed.
+ *
+ * @return the links
+ */
+ public Links getLinks() {
+ return Links.of(links);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.mapping.SimpleAssociationHandler#doWithAssociation(org.springframework.data.mapping.Association)
+ */
+ @Override
+ public void doWithAssociation(final Association extends PersistentProperty>> association) {
+
+ if (associationLinks.isLinkableAssociation(association)) {
+
+ PersistentProperty> property = association.getInverse();
+ Links existingLinks = Links.of(links);
+
+ for (Link link : associationLinks.getLinksFor(association, basePath)) {
+ if (existingLinks.hasLink(link.getRel())) {
+ throw new MappingException(String.format(AMBIGUOUS_ASSOCIATIONS, property.toString()));
+ } else {
+ links.add(link);
+ }
+ }
+ }
+ }
+ }
+
+ private static class NestedLinkCollectingAssociationHandler implements SimpleAssociationHandler {
+
+ private final SelfLinkProvider selfLinks;
+ private final PersistentPropertyAccessor> accessor;
+ private final Associations associations;
+ private Links links = Links.NONE;
+
+ public NestedLinkCollectingAssociationHandler(SelfLinkProvider selfLinks,
+ PersistentPropertyAccessor> accessor, Associations associations) {
+
+ Assert.notNull(selfLinks, "SelfLinkProvider must not be null!");
+ Assert.notNull(accessor, "PersistentPropertyAccessor must not be null!");
+ Assert.notNull(associations, "Associations must not be null!");
+
+ this.selfLinks = selfLinks;
+ this.accessor = accessor;
+ this.associations = associations;
+ }
+
+ public List getLinks() {
+ return this.links.toList();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.mapping.SimpleAssociationHandler#doWithAssociation(org.springframework.data.mapping.Association)
+ */
+ @Override
+ public void doWithAssociation(Association extends PersistentProperty>> association) {
+
+ if (!associations.isLinkableAssociation(association)) {
+ return;
+ }
+
+ PersistentProperty> property = association.getInverse();
+ Object value = accessor.getProperty(property);
+
+ if (value == null) {
+ return;
+ }
+
+ ResourceMetadata metadata = associations.getMappings().getMetadataFor(property.getOwner().getType());
+ ResourceMapping propertyMapping = metadata.getMappingFor(property);
+
+ for (Object element : asCollection(value)) {
+
+ links = links.andIf(element != null,
+ () -> selfLinks.createSelfLinkFor(property.getAssociationTargetType(), element)
+ .withRel(propertyMapping.getRel()));
+ }
+ }
+
+ /**
+ * Returns the given object as {@link Collection}, i.e. the object as is if it's a collection already or wrapped
+ * into a single-element collection otherwise.
+ *
+ * @param object can be {@literal null}.
+ * @return
+ */
+ @SuppressWarnings("unchecked")
+ private static Collection