Allow customization of LinkCollector.

LinkCollector is now an interface. The actual implementation has been moved to DefaultLinkCollector. RepositoryRestConfigurer now has a customizeLinkCollector(…) callback method to tweak or even completely replace the LinkCollector instance.

Fixes #2042.
This commit is contained in:
Oliver Drotbohm
2021-07-15 08:15:20 +02:00
parent 0c24113484
commit 161a3a9499
8 changed files with 311 additions and 223 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Link> links = new ArrayList<Link>();
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<Link> 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<Object> asCollection(Object object) {
return object instanceof Collection //
? (Collection<Object>) object //
: Collections.singleton(object);
}
}
}

View File

@@ -15,55 +15,15 @@
*/
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 Gierke
*/
public class LinkCollector {
private final PersistentEntities entities;
private final Associations associationLinks;
private final SelfLinkProvider links;
/**
* Creates a new {@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 LinkCollector(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;
}
public interface LinkCollector {
/**
* Returns all {@link Links} for the given object.
@@ -71,189 +31,16 @@ public class LinkCollector {
* @param object must not be {@literal null}.
* @return
*/
public Links getLinksFor(Object object) {
return getLinksFor(object, Links.NONE);
}
Links getLinksFor(Object object);
/**
* 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}.
* @param existing must not be {@literal null}.
* @return
*/
public Links getLinksFor(Object object, Links existingLinks) {
Links getLinksFor(Object object, Links existing);
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()));
}
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<Link> links = new ArrayList<Link>();
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<Link> 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 a link pointing to the given entity using the given {@link ResourceMapping} to detect the link relation.
*
* @param entity must not be {@literal null}.
* @param mapping must not be {@literal null}.
* @return
*/
private Link getLinkFor(Object entity, ResourceMapping mapping) {
return selfLinks.createSelfLinkFor(entity).withRel(mapping.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<Object> asCollection(Object object) {
return object instanceof Collection //
? (Collection<Object>) object //
: Collections.singleton(object);
}
}
Links getLinksForNested(Object object, Links existing);
}

View File

@@ -47,6 +47,7 @@ import org.springframework.data.rest.webmvc.RepositoryLinksResource;
import org.springframework.data.rest.webmvc.RestMediaTypes;
import org.springframework.data.rest.webmvc.alps.AlpsJsonHttpMessageConverter;
import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module;
import org.springframework.data.rest.webmvc.mapping.LinkCollector;
import org.springframework.data.util.Lazy;
import org.springframework.data.util.Streamable;
import org.springframework.data.web.HateoasPageableHandlerMethodArgumentResolver;
@@ -221,6 +222,14 @@ public class RepositoryRestMvConfigurationIntegrationTests {
assertThat(factory).isEqualTo(ExtendingConfiguration.auditableBeanWrapperFactory);
}
@Test // #2042
public void appliesLinkCollectorCustomizer() {
LinkCollector factory = context.getBean(LinkCollector.class);
assertThat(factory).isEqualTo(ExtendingConfiguration.collector);
}
private static ObjectMapper getObjectMapper() {
AbstractJackson2HttpMessageConverter converter = context.getBean("halJacksonHttpMessageConverter",
@@ -233,6 +242,7 @@ public class RepositoryRestMvConfigurationIntegrationTests {
static class ExtendingConfiguration {
static AuditableBeanWrapperFactory auditableBeanWrapperFactory = mock(AuditableBeanWrapperFactory.class);
static LinkCollector collector = mock(LinkCollector.class);
@Bean
DefaultLinkRelationProvider relProvider() {
@@ -265,8 +275,8 @@ public class RepositoryRestMvConfigurationIntegrationTests {
}
@Bean
@Order(300) // #2040
RepositoryRestConfigurer auditingBeanWrapperCustomizer() {
@Order(300) // #2040, #2042
RepositoryRestConfigurer customizer() {
return new RepositoryRestConfigurer() {
@@ -274,6 +284,11 @@ public class RepositoryRestMvConfigurationIntegrationTests {
public AuditableBeanWrapperFactory customizeAuditableBeanWrapperFactory(AuditableBeanWrapperFactory factory) {
return auditableBeanWrapperFactory;
}
@Override
public LinkCollector customizeLinkCollector(LinkCollector collector) {
return ExtendingConfiguration.collector;
}
};
}
}