DATAREST-521 - Excerpts now contain all links of the projection target.

Extended the serialization capabilities to explicitly treat projection proxies by wrapping them into a ProjectionResource and collecting links for the target backing the projection if the target is an exported resource.
This commit is contained in:
Oliver Gierke
2015-04-16 09:15:48 +02:00
parent dd398c8011
commit bba665cef4
7 changed files with 326 additions and 64 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2014 the original author or authors.
* Copyright 2012-2015 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.
@@ -43,7 +43,6 @@ public class PersistentEntityResource extends Resource<Object> {
private final PersistentEntity<?, ?> entity;
private final Resources<EmbeddedWrapper> embeddeds;
private final boolean enforceAssociationLinks;
/**
* Creates a new {@link PersistentEntityResource} for the given {@link PersistentEntity}, content, embedded
@@ -52,11 +51,10 @@ public class PersistentEntityResource extends Resource<Object> {
* @param entity must not be {@literal null}.
* @param content must not be {@literal null}.
* @param links must not be {@literal null}.
* @param renderAllAssociations
* @param embeddeds can be {@literal null}.
*/
private PersistentEntityResource(PersistentEntity<?, ?> entity, Object content, Iterable<Link> links,
boolean renderAllAssociations, Resources<EmbeddedWrapper> embeddeds) {
Resources<EmbeddedWrapper> embeddeds) {
super(content, links);
@@ -64,7 +62,6 @@ public class PersistentEntityResource extends Resource<Object> {
this.entity = entity;
this.embeddeds = embeddeds == null ? NO_EMBEDDEDS : embeddeds;
this.enforceAssociationLinks = renderAllAssociations;
}
/**
@@ -118,7 +115,6 @@ public class PersistentEntityResource extends Resource<Object> {
private final List<Link> links = new ArrayList<Link>();
private Resources<EmbeddedWrapper> embeddeds;
private boolean renderAllAssociationLinks = false;
/**
* Creates a new {@link Builder} instance for the given content and {@link PersistentEntity}.
@@ -147,17 +143,6 @@ public class PersistentEntityResource extends Resource<Object> {
return this;
}
/**
* Configures the builder to render all association links independently of the embedded resources added.
*
* @return the builder
*/
public Builder renderAllAssociationLinks() {
this.renderAllAssociationLinks = true;
return this;
}
/**
* Adds the given {@link Link} to the {@link PersistentEntityResource}.
*
@@ -178,7 +163,7 @@ public class PersistentEntityResource extends Resource<Object> {
* @return
*/
public PersistentEntityResource build() {
return new PersistentEntityResource(entity, content, links, renderAllAssociationLinks, embeddeds);
return new PersistentEntityResource(entity, content, links, embeddeds);
}
}
}

View File

@@ -32,7 +32,6 @@ import org.springframework.data.rest.webmvc.mapping.AssociationLinks;
import org.springframework.data.rest.webmvc.support.Projector;
import org.springframework.hateoas.EntityLinks;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceAssembler;
import org.springframework.hateoas.core.EmbeddedWrapper;
import org.springframework.hateoas.core.EmbeddedWrappers;
@@ -81,9 +80,7 @@ public class PersistentEntityResourceAssembler implements ResourceAssembler<Obje
public PersistentEntityResource toResource(Object instance) {
Assert.notNull(instance, "Entity instance must not be null!");
return wrap(projector.projectExcerpt(instance), instance).build();
}
/**
@@ -95,8 +92,7 @@ public class PersistentEntityResourceAssembler implements ResourceAssembler<Obje
public PersistentEntityResource toFullResource(Object instance) {
Assert.notNull(instance, "Entity instance must not be null!");
return wrap(projector.project(instance), instance).//
renderAllAssociationLinks().build();
return wrap(projector.project(instance), instance).build();
}
private Builder wrap(Object instance, Object source) {
@@ -165,14 +161,14 @@ public class PersistentEntityResourceAssembler implements ResourceAssembler<Obje
for (Object element : collection) {
if (element != null) {
nestedCollection.add(getExcerptResource(element));
nestedCollection.add(projector.projectExcerpt(element));
}
}
associationProjections.add(wrappers.wrap(nestedCollection, rel));
} else {
associationProjections.add(wrappers.wrap(getExcerptResource(value), rel));
associationProjections.add(wrappers.wrap(projector.projectExcerpt(value), rel));
}
}
});
@@ -203,14 +199,4 @@ public class PersistentEntityResourceAssembler implements ResourceAssembler<Obje
Link resourceLink = entityLinks.linkToSingleResource(entity.getType(), id);
return new Link(resourceLink.getHref(), Link.REL_SELF);
}
/**
* Returns a {@link Resource} instance for the excerpt of the given source entity.
*
* @param entity must not be {@literal null}.
* @return
*/
private Resource<Object> getExcerptResource(Object entity) {
return new Resource<Object>(projector.projectExcerpt(entity), getSelfLinkFor(entity));
}
}

View File

@@ -539,8 +539,8 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon
PersistentEntities entities = persistentEntities();
return new PersistentEntityJackson2Module(resourceMappings(), entities, config(),
uriToEntityConverter(defaultConversionService()));
return new PersistentEntityJackson2Module(
resourceMappings(), entities, config(), uriToEntityConverter(defaultConversionService()), entityLinks());
}
/**

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2014 the original author or authors.
* Copyright 2012-2015 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.
@@ -18,6 +18,7 @@ package org.springframework.data.rest.webmvc.json;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
@@ -25,9 +26,11 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.CollectionFactory;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.data.mapping.IdentifierAccessor;
import org.springframework.data.mapping.PersistentEntity;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.data.mapping.context.PersistentEntities;
import org.springframework.data.projection.TargetAware;
import org.springframework.data.repository.support.Repositories;
import org.springframework.data.rest.core.Path;
import org.springframework.data.rest.core.UriToEntityConverter;
@@ -36,7 +39,9 @@ import org.springframework.data.rest.core.mapping.ResourceMappings;
import org.springframework.data.rest.webmvc.PersistentEntityResource;
import org.springframework.data.rest.webmvc.mapping.AssociationLinks;
import org.springframework.data.rest.webmvc.mapping.LinkCollectingAssociationHandler;
import org.springframework.hateoas.EntityLinks;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Links;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.UriTemplate;
@@ -68,6 +73,7 @@ import com.fasterxml.jackson.databind.ser.BeanSerializerBuilder;
import com.fasterxml.jackson.databind.ser.BeanSerializerModifier;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import com.fasterxml.jackson.databind.type.CollectionLikeType;
import com.fasterxml.jackson.databind.util.NameTransformer;
/**
* Jackson 2 module to serialize and deserialize {@link PersistentEntityResource}s.
@@ -92,7 +98,7 @@ public class PersistentEntityJackson2Module extends SimpleModule {
* @param converter must not be {@literal null}.
*/
public PersistentEntityJackson2Module(ResourceMappings mappings, PersistentEntities entities,
RepositoryRestConfiguration config, UriToEntityConverter converter) {
RepositoryRestConfiguration config, UriToEntityConverter converter, EntityLinks entityLinks) {
super(new Version(2, 0, 0, null, "org.springframework.data.rest", "jackson-module"));
@@ -102,8 +108,12 @@ public class PersistentEntityJackson2Module extends SimpleModule {
Assert.notNull(converter, "UriToEntityConverter must not be null!");
AssociationLinks associationLinks = new AssociationLinks(mappings);
LinkCollector collector = new LinkCollector(entities, entityLinks, associationLinks);
addSerializer(new PersistentEntityResourceSerializer(collector));
addSerializer(new ProjectionSerializer(collector, mappings));
addSerializer(new ProjectionResourceContentSerializer());
addSerializer(new PersistentEntityResourceSerializer(entities, associationLinks));
setSerializerModifier(new AssociationOmittingSerializerModifier(entities, associationLinks, config));
setDeserializerModifier(new AssociationUriResolvingDeserializerModifier(entities, converter, associationLinks));
}
@@ -114,10 +124,10 @@ public class PersistentEntityJackson2Module extends SimpleModule {
*
* @author Oliver Gierke
*/
@SuppressWarnings("serial")
private static class PersistentEntityResourceSerializer extends StdSerializer<PersistentEntityResource> {
private final PersistentEntities entities;
private final AssociationLinks associationLinks;
private final LinkCollector collector;
/**
* Creates a new {@link PersistentEntityResourceSerializer} using the given {@link PersistentEntities} and
@@ -127,15 +137,11 @@ public class PersistentEntityJackson2Module extends SimpleModule {
* @param links must not be {@literal null}.
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
private PersistentEntityResourceSerializer(PersistentEntities entities, AssociationLinks links) {
private PersistentEntityResourceSerializer(LinkCollector collector) {
super((Class) PersistentEntityResource.class);
Assert.notNull(entities, "PersistentEntities must not be null!");
Assert.notNull(links, "AssociationLinks must not be null!");
this.associationLinks = links;
this.entities = entities;
this.collector = collector;
}
/*
@@ -150,23 +156,17 @@ public class PersistentEntityJackson2Module extends SimpleModule {
LOG.debug("Serializing PersistentEntity " + resource.getPersistentEntity());
}
final Link id = resource.getId();
Object content = resource.getContent();
if (id == null) {
throw new JsonGenerationException(String.format("No self link found resource %s!", resource));
if (TargetAware.class.isInstance(content)) {
TargetAware targetAware = (TargetAware) content;
Links links = collector.getLinksFor(targetAware.getTarget(), resource.getLinks());
provider.defaultSerializeValue(new ProjectionResource(targetAware, links), jgen);
return;
}
List<Link> links = new ArrayList<Link>();
links.addAll(resource.getLinks());
Path basePath = new Path(id.expand().getHref());
LinkCollectingAssociationHandler associationHandler = new LinkCollectingAssociationHandler(entities, basePath,
associationLinks);
resource.getPersistentEntity().doWithAssociations(associationHandler);
for (Link link : associationHandler.getLinks()) {
links.add(link);
}
Links links = collector.getLinksFor(resource.getContent(), resource.getLinks());
Resource<Object> resourceToRender = new Resource<Object>(resource.getContent(), links) {
@@ -237,6 +237,8 @@ public class PersistentEntityJackson2Module extends SimpleModule {
continue;
}
// Is there a default projection?
if (associationLinks.isLinkableAssociation(persistentProperty)) {
continue;
}
@@ -400,6 +402,222 @@ public class PersistentEntityJackson2Module extends SimpleModule {
}
}
@SuppressWarnings("serial")
static class ProjectionSerializer extends StdSerializer<TargetAware> {
private final LinkCollector collector;
private final ResourceMappings mappings;
private boolean unwrapping;
/**
* Creates a new {@link ProjectionSerializer} for the given {@link LinkCollector}.
*
* @param collector
*/
public ProjectionSerializer(LinkCollector collector, ResourceMappings mappings) {
super(TargetAware.class);
this.collector = collector;
this.mappings = mappings;
}
/*
* (non-Javadoc)
* @see com.fasterxml.jackson.databind.ser.std.StdSerializer#serialize(java.lang.Object, com.fasterxml.jackson.core.JsonGenerator, com.fasterxml.jackson.databind.SerializerProvider)
*/
@Override
public void serialize(TargetAware value, JsonGenerator jgen, SerializerProvider provider) throws IOException,
JsonGenerationException {
Object target = value.getTarget();
Links links = mappings.getMetadataFor(value.getTargetClass()).isExported() ? collector.getLinksFor(target)
: new Links();
jgen.writeStartObject();
provider.//
findValueSerializer(ProjectionResource.class, null).//
unwrappingSerializer(null).//
serialize(new ProjectionResource(value, links), jgen, provider);
jgen.writeEndObject();
}
/*
* (non-Javadoc)
* @see com.fasterxml.jackson.databind.JsonSerializer#isUnwrappingSerializer()
*/
@Override
public boolean isUnwrappingSerializer() {
return unwrapping;
}
/*
* (non-Javadoc)
* @see com.fasterxml.jackson.databind.JsonSerializer#unwrappingSerializer(com.fasterxml.jackson.databind.util.NameTransformer)
*/
@Override
public JsonSerializer<TargetAware> unwrappingSerializer(NameTransformer unwrapper) {
this.unwrapping = true;
return this;
}
}
static class ProjectionResource extends Resource<ProjectionResourceContent> {
ProjectionResource(TargetAware projection, Iterable<Link> links) {
super(new ProjectionResourceContent(projection, projection.getClass().getInterfaces()[0]), links);
}
}
static class ProjectionResourceContent {
private final Object projection;
private final Class<?> projectionInterface;
/**
* @param projection
* @param projectionInterface
*/
public ProjectionResourceContent(Object projection, Class<?> projectionInterface) {
this.projection = projection;
this.projectionInterface = projectionInterface;
}
public Object getProjection() {
return projection;
}
public Class<?> getProjectionInterface() {
return projectionInterface;
}
}
@SuppressWarnings("serial")
private static class ProjectionResourceContentSerializer extends StdSerializer<ProjectionResourceContent> {
private boolean unwrapping;
/**
* Creates a new {@link ProjectionResourceContentSerializer}.
*/
public ProjectionResourceContentSerializer() {
super(ProjectionResourceContent.class);
}
/*
* (non-Javadoc)
* @see com.fasterxml.jackson.databind.ser.std.StdSerializer#serialize(java.lang.Object, com.fasterxml.jackson.core.JsonGenerator, com.fasterxml.jackson.databind.SerializerProvider)
*/
@Override
public void serialize(ProjectionResourceContent value, JsonGenerator jgen, SerializerProvider provider)
throws IOException, JsonGenerationException {
provider.//
findValueSerializer(value.getProjectionInterface(), null).//
unwrappingSerializer(null).//
serialize(value.getProjection(), jgen, provider);
}
/*
* (non-Javadoc)
* @see com.fasterxml.jackson.databind.JsonSerializer#isUnwrappingSerializer()
*/
@Override
public boolean isUnwrappingSerializer() {
return unwrapping;
}
/*
* (non-Javadoc)
* @see com.fasterxml.jackson.databind.JsonSerializer#unwrappingSerializer(com.fasterxml.jackson.databind.util.NameTransformer)
*/
@Override
public JsonSerializer<ProjectionResourceContent> unwrappingSerializer(NameTransformer unwrapper) {
this.unwrapping = true;
return this;
}
}
/**
* A service to collect all standard links that need to be added to a certain object.
*
* @author Oliver Gierke
*/
private static class LinkCollector {
private final PersistentEntities entities;
private final AssociationLinks associationLinks;
private final EntityLinks links;
/**
* Creates a new {@link PersistentEntities}, {@link EntityLinks} and {@link AssociationLinks}.
*
* @param entities must not be {@literal null}.
* @param entityLinks must not be {@literal null}.
* @param associationLinks must not be {@literal null}.
*/
public LinkCollector(PersistentEntities entities, EntityLinks entityLinks, AssociationLinks associationLinks) {
Assert.notNull(entities, "PersistentEntities must not be null!");
Assert.notNull(entityLinks, "EntityLinks must not be null!");
Assert.notNull(associationLinks, "AssociationLinks must not be null!");
this.links = entityLinks;
this.entities = entities;
this.associationLinks = associationLinks;
}
/**
* Returns all {@link Links} for the given object.
*
* @param object must not be {@literal null}.
* @return
*/
public Links getLinksFor(Object object) {
return getLinksFor(object, Collections.<Link> emptyList());
}
/**
* 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
*/
public Links getLinksFor(Object object, List<Link> existingLinks) {
Assert.notNull(object, "Object must not be null!");
Assert.notNull(existingLinks, "Existing links must not be null!");
PersistentEntity<?, ?> entity = entities.getPersistentEntity(object.getClass());
Link selfLink = getSelfLink(object, entity, new Links(existingLinks));
Path path = new Path(selfLink.expand().getHref());
LinkCollectingAssociationHandler handler = new LinkCollectingAssociationHandler(entities, path, associationLinks);
entity.doWithAssociations(handler);
List<Link> result = new ArrayList<Link>();
result.add(getSelfLink(object, entity, new Links(existingLinks)));
result.addAll(handler.getLinks());
return new Links(result);
}
private Link getSelfLink(Object object, PersistentEntity<?, ?> entity, Links existing) {
if (existing.hasLink(Link.REL_SELF)) {
return existing.getLink(Link.REL_SELF);
}
IdentifierAccessor accessor = entity.getIdentifierAccessor(object);
return links.linkToSingleResource(entity.getType(), accessor.getIdentifier()).withSelfRel();
}
}
/**
* {@link ValueInstantiator} to create collection or map instances based on the type of the configured
* {@link PersistentProperty}.

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2015 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
*
* http://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.jpa;
/**
* @author Oliver Gierke
* @soundtrack Elen - Sink like a stone (Elen)
*/
public interface UserExcerpt {
UserExcerpt getFather();
}

View File

@@ -28,6 +28,8 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mapping.PersistentEntity;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
import org.springframework.data.repository.support.Repositories;
import org.springframework.data.rest.webmvc.PersistentEntityResource;
import org.springframework.data.rest.webmvc.jpa.LineItem;
@@ -35,6 +37,7 @@ import org.springframework.data.rest.webmvc.jpa.Order;
import org.springframework.data.rest.webmvc.jpa.OrderRepository;
import org.springframework.data.rest.webmvc.jpa.Person;
import org.springframework.data.rest.webmvc.jpa.PersonRepository;
import org.springframework.data.rest.webmvc.jpa.UserExcerpt;
import org.springframework.data.rest.webmvc.mongodb.Address;
import org.springframework.data.rest.webmvc.mongodb.User;
import org.springframework.data.rest.webmvc.util.TestUtils;
@@ -42,6 +45,8 @@ import org.springframework.hateoas.Link;
import org.springframework.hateoas.LinkDiscoverer;
import org.springframework.hateoas.PagedResources;
import org.springframework.hateoas.PagedResources.PageMetadata;
import org.springframework.hateoas.core.EmbeddedWrapper;
import org.springframework.hateoas.core.EmbeddedWrappers;
import org.springframework.hateoas.hal.HalLinkDiscoverer;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
@@ -49,6 +54,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.util.UriTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.jayway.jsonpath.JsonPath;
/**
@@ -71,10 +77,13 @@ public class PersistentEntitySerializationTests {
@Autowired OrderRepository orders;
LinkDiscoverer linkDiscoverer;
ProjectionFactory projectionFactory;
@Before
public void setUp() {
linkDiscoverer = new HalLinkDiscoverer();
this.linkDiscoverer = new HalLinkDiscoverer();
this.projectionFactory = new SpelAwareProxyProjectionFactory();
}
@Test
@@ -191,6 +200,8 @@ public class PersistentEntitySerializationTests {
String result = mapper.writeValueAsString(persistentEntityResource);
System.out.println(result);
assertThat(JsonPath.read(result, "$_embedded.users[*].address"), is(notNullValue()));
}
@@ -218,4 +229,30 @@ public class PersistentEntitySerializationTests {
assertThat(JsonPath.read(result, "$_embedded.orders[*].lineItems"), is(notNullValue()));
}
/**
* @see DATAREST-521
*/
@Test
public void serializesLinksForExcerpts() throws Exception {
Person oliver = new Person("Oliver August", "Matthews");
Person dave = new Person("Dave", "Matthews");
oliver.setFather(dave);
UserExcerpt daveExcerpt = projectionFactory.createProjection(UserExcerpt.class, dave);
EmbeddedWrapper wrapper = new EmbeddedWrappers(false).wrap(daveExcerpt, "father");
PersistentEntityResource resource = PersistentEntityResource.//
build(oliver, repositories.getPersistentEntity(Person.class)).//
withLink(new Link("/people/1")).//
withEmbedded(Arrays.asList(wrapper)).//
build();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
String result = mapper.writeValueAsString(resource);
assertThat(JsonPath.read(result, "$_embedded.father[*]._links.self"), is(notNullValue()));
}
}

View File

@@ -15,9 +15,13 @@
*/
package org.springframework.data.rest.webmvc.json;
import static org.mockito.Matchers.*;
import static org.mockito.Mockito.*;
import java.util.Collections;
import java.util.List;
import org.mockito.Matchers;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
@@ -36,6 +40,8 @@ import org.springframework.data.rest.webmvc.jpa.PersonRepository;
import org.springframework.data.rest.webmvc.mongodb.MongoDbRepositoryConfig;
import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.format.support.FormattingConversionService;
import org.springframework.hateoas.EntityLinks;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.RelProvider;
import org.springframework.hateoas.core.EvoInflectorRelProvider;
import org.springframework.hateoas.hal.Jackson2HalModule;
@@ -94,8 +100,13 @@ public class RepositoryTestsConfig {
@Bean
public Module persistentEntityModule() {
EntityLinks entityLinks = mock(EntityLinks.class);
when(entityLinks.linkToSingleResource(Matchers.<Class<?>> any(), anyObject())).thenReturn(new Link("/mock/1"));
return new PersistentEntityJackson2Module(new RepositoryResourceMappings(repositories(), persistentEntities()),
persistentEntities(), config(), new UriToEntityConverter(persistentEntities(), defaultConversionService()));
persistentEntities(), config(), new UriToEntityConverter(persistentEntities(), defaultConversionService()),
entityLinks);
}
@Bean