From 0f925cc5ff085e8dbf9aa584f909bea92bb36990 Mon Sep 17 00:00:00 2001 From: Jon Brisbin Date: Thu, 30 Aug 2012 09:38:28 -0500 Subject: [PATCH] Re-organized how entities are serialized. Broke out the Jackson Module into its own class and incorporated the code from EntityResource.wrap into the serializer so that entities are properly serialized wherever they are encountered. Also implemented a Converter and use the ConversionService to turn an Entity into a Resource. Right now that's done in two separate places since the propertyOfEntity method needs to turn entities into resources as well as the Jackson serializer. Also threw in a fix for DATAREST-43. --- .../webmvc/EntityToResourceConverter.java | 63 +++ ...itoryAwareMappingHttpMessageConverter.java | 325 +--------------- .../rest/webmvc/RepositoryRestController.java | 109 ++++-- .../RepositoryRestMvcConfiguration.java | 13 +- .../rest/webmvc/{ => json}/JacksonUtil.java | 3 +- .../json/RepositoryAwareJacksonModule.java | 364 ++++++++++++++++++ .../rest/test/webmvc/ApplicationConfig.java | 13 + 7 files changed, 525 insertions(+), 365 deletions(-) create mode 100644 spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/EntityToResourceConverter.java rename spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/{ => json}/JacksonUtil.java (95%) create mode 100644 spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/RepositoryAwareJacksonModule.java diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/EntityToResourceConverter.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/EntityToResourceConverter.java new file mode 100644 index 000000000..1dc55f8a2 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/EntityToResourceConverter.java @@ -0,0 +1,63 @@ +package org.springframework.data.rest.webmvc; + +import static org.springframework.data.rest.core.util.UriUtils.*; + +import java.net.URI; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.rest.repository.AttributeMetadata; +import org.springframework.data.rest.repository.EntityMetadata; +import org.springframework.data.rest.repository.RepositoryMetadata; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.Resource; + +/** + * A {@link Converter} to turn domain entities into {@link Resource}s by segregating embedded entities (those entities + * not managed by a {@link org.springframework.data.repository.Repository}) from linked or related entities (which + * don't get inlined into an entity's representation but are replaced by links instead. + * + * @author Jon Brisbin + */ +public class EntityToResourceConverter implements Converter { + + private final RepositoryMetadata repositoryMetadata; + private final EntityMetadata entityMetadata; + + public EntityToResourceConverter(RepositoryMetadata repositoryMetadata) { + this.repositoryMetadata = repositoryMetadata; + this.entityMetadata = repositoryMetadata.entityMetadata(); + } + + @SuppressWarnings({"unchecked"}) + @Override public Resource convert(Object source) { + if(null == repositoryMetadata || null == source) { + return new Resource(source); + } + + URI baseUri = RepositoryRestController.BASE_URI.get(); + Set links = new HashSet(); + for(Object attrName : entityMetadata.linkedAttributes().keySet()) { + URI uri = buildUri(baseUri, attrName.toString()); + String rel = repositoryMetadata.rel() + "." + source.getClass().getSimpleName() + "." + attrName; + links.add(new Link(uri.toString(), rel)); + } + links.add(new Link(baseUri.toString(), "self")); + + Map entityDto = new HashMap(); + for(Map.Entry attrMeta : ((Map)entityMetadata.embeddedAttributes()) + .entrySet()) { + String name = attrMeta.getKey(); + Object val; + if(null != (val = attrMeta.getValue().get(source))) { + entityDto.put(name, val); + } + } + + return new EntityResource(entityDto, links); + } + +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryAwareMappingHttpMessageConverter.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryAwareMappingHttpMessageConverter.java index aa4edc134..ba13bb99e 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryAwareMappingHttpMessageConverter.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryAwareMappingHttpMessageConverter.java @@ -1,54 +1,31 @@ package org.springframework.data.rest.webmvc; import java.io.IOException; -import java.io.Serializable; -import java.net.URI; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Map; -import java.util.Set; import org.codehaus.jackson.JsonEncoding; -import org.codehaus.jackson.JsonGenerationException; import org.codehaus.jackson.JsonGenerator; -import org.codehaus.jackson.JsonParser; -import org.codehaus.jackson.JsonProcessingException; -import org.codehaus.jackson.JsonToken; -import org.codehaus.jackson.Version; -import org.codehaus.jackson.map.DeserializationContext; -import org.codehaus.jackson.map.KeyDeserializer; import org.codehaus.jackson.map.Module; import org.codehaus.jackson.map.ObjectMapper; -import org.codehaus.jackson.map.SerializerProvider; -import org.codehaus.jackson.map.deser.std.StdDeserializer; -import org.codehaus.jackson.map.module.SimpleDeserializers; -import org.codehaus.jackson.map.module.SimpleKeyDeserializers; -import org.codehaus.jackson.map.module.SimpleModule; -import org.codehaus.jackson.map.module.SimpleSerializers; -import org.codehaus.jackson.map.ser.std.SerializerBase; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.core.convert.ConversionService; -import org.springframework.data.rest.repository.AttributeMetadata; import org.springframework.data.rest.repository.RepositoryExporter; import org.springframework.data.rest.repository.RepositoryMetadata; import org.springframework.data.rest.repository.UriToDomainObjectUriResolver; +import org.springframework.data.rest.webmvc.json.RepositoryAwareJacksonModule; import org.springframework.format.support.DefaultFormattingConversionService; -import org.springframework.hateoas.Link; import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpOutputMessage; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.http.converter.json.MappingJacksonHttpMessageConverter; -import org.springframework.web.util.UriComponentsBuilder; /** * @author Jon Brisbin @@ -67,6 +44,8 @@ public class RepositoryAwareMappingHttpMessageConverter protected List modules = Collections.emptyList(); @Autowired protected UriToDomainObjectUriResolver domainObjectResolver = null; + @Autowired + protected RepositoryAwareJacksonModule jacksonModule = null; protected ApplicationEventPublisher eventPublisher = null; public RepositoryAwareMappingHttpMessageConverter() { @@ -83,7 +62,7 @@ public class RepositoryAwareMappingHttpMessageConverter } @Override public void afterPropertiesSet() throws Exception { - mapper.registerModule(new RepositoryAwareModule()); + mapper.registerModule(jacksonModule); for(Module m : modules) { mapper.registerModule(m); } @@ -102,11 +81,9 @@ public class RepositoryAwareMappingHttpMessageConverter return repositoryExporters; } - @Autowired(required = false) @SuppressWarnings({"unchecked"}) public RepositoryAwareMappingHttpMessageConverter setRepositoryExporters(List repositoryExporters) { this.repositoryExporters = repositoryExporters; - this.mapper.registerModule(new RepositoryAwareModule()); return this; } @@ -182,298 +159,4 @@ public class RepositoryAwareMappingHttpMessageConverter } } - @SuppressWarnings({"unchecked"}) - private RepositoryMetadata repositoryMetadataFor(Class domainType) { - for(RepositoryExporter repoExp : repositoryExporters) { - if(repoExp.hasRepositoryFor(domainType)) { - return repoExp.repositoryMetadataFor(domainType); - } - } - return null; - } - - private URI buildUri(URI baseUri, String... pathSegments) { - return UriComponentsBuilder.fromUri(baseUri).pathSegment(pathSegments).build().toUri(); - } - - private class RepositoryAwareModule extends SimpleModule { - - SimpleSerializers sers = new SimpleSerializers(); - SimpleDeserializers dsers = new SimpleDeserializers(); - SimpleSerializers keySers = new SimpleSerializers(); - SimpleKeyDeserializers keyDsers = new SimpleKeyDeserializers(); - - private RepositoryAwareModule() { - super("RepositoryAwareModule", Version.unknownVersion()); - } - - @SuppressWarnings({"unchecked"}) - @Override public void setupModule(SetupContext context) { - for(RepositoryExporter repoExp : repositoryExporters) { - for(String repoName : new ArrayList(repoExp.repositoryNames())) { - RepositoryMetadata repoMeta = repoExp.repositoryMetadataFor(repoName); - Class domainType = repoMeta.entityMetadata().type(); - - sers.addSerializer(domainType, new DomainObjectToLinkSerializer(domainType, repoMeta)); - keySers.addSerializer(domainType, new DomainObjectToStringKeySerializer(domainType, repoMeta)); - - dsers.addDeserializer(domainType, new LinkToDomainObjectDeserializer(domainType, repoMeta)); - keyDsers.addDeserializer(domainType, new KeyToDomainObjectDeserializer()); - } - } - - context.addSerializers(sers); - context.addKeySerializers(keySers); - context.addDeserializers(dsers); - context.addKeyDeserializers(keyDsers); - } - } - - private class DomainObjectToLinkSerializer extends SerializerBase { - - protected final RepositoryMetadata repoMeta; - protected final AttributeMetadata idAttr; - - private DomainObjectToLinkSerializer(Class t, RepositoryMetadata repoMeta) { - super(t); - this.repoMeta = repoMeta; - if(null != repoMeta) { - idAttr = repoMeta.entityMetadata().idAttribute(); - } else { - idAttr = null; - } - } - - @Override public void serialize(Object value, - JsonGenerator jgen, - SerializerProvider provider) throws IOException, - JsonGenerationException { - if(null == value) { - provider.defaultSerializeNull(jgen); - return; - } - if(null == repoMeta) { - provider.defaultSerializeValue(value, jgen); - return; - } - - Serializable serId = (Serializable)idAttr.get(value); - String sId = null; - for(ConversionService cs : conversionServices) { - if(cs.canConvert(idAttr.type(), String.class)) { - sId = cs.convert(serId, String.class); - break; - } - } - if(null == sId) { - sId = serId.toString(); - } - - String rel = repoMeta.rel() + "." + repoMeta.domainType().getSimpleName(); - URI selfUri = buildUri(RepositoryRestController.BASE_URI.get(), repoMeta.name(), sId); - - jgen.writeObject(new Link(selfUri.toString(), rel)); - } - - } - - private class DomainObjectToStringKeySerializer extends DomainObjectToLinkSerializer { - - private DomainObjectToStringKeySerializer(Class t, RepositoryMetadata repoMeta) { - super(t, repoMeta); - } - - @Override public void serialize(Object value, - JsonGenerator jgen, - SerializerProvider provider) throws IOException, - JsonGenerationException { - if(null == value) { - provider.defaultSerializeNull(jgen); - return; - } - if(null == repoMeta) { - provider.defaultSerializeValue(value, jgen); - return; - } - - Serializable serId = (Serializable)idAttr.get(value); - String sId = null; - for(ConversionService cs : conversionServices) { - if(cs.canConvert(idAttr.type(), String.class)) { - sId = cs.convert(serId, String.class); - break; - } - } - if(null == sId) { - sId = serId.toString(); - } - - URI href = buildUri(RepositoryRestController.BASE_URI.get(), repoMeta.name(), sId); - - jgen.writeString("@" + href.toString()); - } - - } - - private class LinkToDomainObjectDeserializer extends StdDeserializer { - - protected final RepositoryMetadata repoMeta; - - private LinkToDomainObjectDeserializer(Class vc, RepositoryMetadata repoMeta) { - super(vc); - this.repoMeta = repoMeta; - } - - @SuppressWarnings({"unchecked"}) - @Override public Object deserialize(JsonParser jp, - DeserializationContext ctxt) throws IOException, - JsonProcessingException { - Object entity; - try { - entity = getValueClass().newInstance(); - } catch(InstantiationException e) { - throw ctxt.instantiationException(getValueClass(), e); - } catch(IllegalAccessException e) { - throw ctxt.instantiationException(getValueClass(), e); - } - - for(JsonToken tok = jp.nextToken(); tok != JsonToken.END_OBJECT; tok = jp.nextToken()) { - String name = jp.getCurrentName(); - switch(tok) { - case FIELD_NAME: { - // Read the attribute metadata - AttributeMetadata attrMeta = repoMeta.entityMetadata().attribute(name); - Object val = null; - - if(name.startsWith("@http")) { - entity = domainObjectResolver.resolve( - RepositoryRestController.BASE_URI.get(), - URI.create(name.substring(1)) - ); - continue; - } - - if("href".equals(name)) { - entity = domainObjectResolver.resolve( - RepositoryRestController.BASE_URI.get(), - URI.create(jp.nextTextValue()) - ); - continue; - } - - if("rel".equals(name)) { - // rel is currently ignored - continue; - } - - if(null == attrMeta) { - // do nothing - continue; - } - - // Try and read the value of this attribute. - // The method of doing that varies based on the type of the property. - if(attrMeta.isCollectionLike()) { - Collection c = attrMeta.asCollection(entity); - if(null == c || c == Collections.emptyList()) { - c = new ArrayList(); - } - - if((tok = jp.nextToken()) == JsonToken.START_ARRAY) { - do { - Object cval = jp.readValueAs(attrMeta.elementType()); - c.add(cval); - } while((tok = jp.nextToken()) != JsonToken.END_ARRAY); - - val = c; - - } else if(tok == JsonToken.VALUE_NULL) { - val = null; - } else { - throw new HttpMessageNotReadableException("Cannot read a JSON " + tok + " as a Collection."); - } - } else if(attrMeta.isSetLike()) { - Set s = attrMeta.asSet(entity); - if(null == s || s == Collections.emptySet()) { - s = new HashSet(); - } - - if((tok = jp.nextToken()) == JsonToken.START_ARRAY) { - do { - Object sval = jp.readValueAs(attrMeta.elementType()); - s.add(sval); - } while((tok = jp.nextToken()) != JsonToken.END_ARRAY); - - val = s; - - } else if(tok == JsonToken.VALUE_NULL) { - val = null; - } else { - throw new HttpMessageNotReadableException("Cannot read a JSON " + tok + " as a Set."); - } - } else if(attrMeta.isMapLike()) { - Map m = attrMeta.asMap(entity); - if(null == m || m == Collections.emptyMap()) { - m = new HashMap(); - } - - if((tok = jp.nextToken()) == JsonToken.START_OBJECT) { - do { - name = jp.getCurrentName(); - Object mkey = ( - name.startsWith("@http") - ? domainObjectResolver.resolve( - RepositoryRestController.BASE_URI.get(), - URI.create(name.substring(1)) - ) - : name - ); - tok = jp.nextToken(); - Object mval = jp.readValueAs(attrMeta.elementType()); - - m.put(mkey, mval); - } while((tok = jp.nextToken()) != JsonToken.END_OBJECT); - - val = m; - - } else if(tok == JsonToken.VALUE_NULL) { - val = null; - } else { - throw new HttpMessageNotReadableException("Cannot read a JSON " + tok + " as a Map."); - } - } else { - if((tok = jp.nextToken()) != JsonToken.VALUE_NULL) { - val = jp.readValueAs(attrMeta.type()); - } - } - - if(null != val) { - attrMeta.set(val, entity); - } - - break; - } - } - } - - return entity; - } - - } - - private class KeyToDomainObjectDeserializer extends KeyDeserializer { - @Override public Object deserializeKey(String key, - DeserializationContext ctxt) throws IOException, - JsonProcessingException { - if(key.startsWith("@http")) { - return domainObjectResolver.resolve( - RepositoryRestController.BASE_URI.get(), - URI.create(key.substring(1)) - ); - } else { - return key; - } - } - } - } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryRestController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryRestController.java index 63b4dd48f..60d06af14 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryRestController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryRestController.java @@ -38,6 +38,7 @@ import org.springframework.core.MethodParameter; import org.springframework.core.convert.ConversionFailedException; import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.support.GenericConversionService; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Page; @@ -130,12 +131,13 @@ public class RepositoryRestController implements ApplicationContextAware, InitializingBean { - public static final String LOCATION = "Location"; - public static final String SELF = "self"; - final static ThreadLocal BASE_URI = new ThreadLocal(); - private static final Logger LOG = LoggerFactory.getLogger( + public static final String LOCATION = "Location"; + public static final String SELF = "self"; + public static final ThreadLocal BASE_URI = new ThreadLocal(); + + private static final Logger LOG = LoggerFactory.getLogger( RepositoryRestController.class); - private static final TypeDescriptor STRING_ARRAY_TYPE = TypeDescriptor.valueOf(String[].class); + private static final TypeDescriptor STRING_ARRAY_TYPE = TypeDescriptor.valueOf(String[].class); /** * We manage a list of possible {@link ConversionService}s to handle converting objects in the controller. This list @@ -326,6 +328,16 @@ public class RepositoryRestController .values()) { conversionService.addConversionServices(cs); } + + GenericConversionService entityConverters = new GenericConversionService(); + for(RepositoryExporter exp : repositoryExporters()) { + for(String repoName : (Set)exp.repositoryNames()) { + RepositoryMetadata repoMeta = exp.repositoryMetadataFor(repoName); + Class domainType = repoMeta.domainType(); + entityConverters.addConverter(domainType, Resource.class, new EntityToResourceConverter(repoMeta)); + } + } + conversionService.addConversionService(0, entityConverters); } /** @@ -397,7 +409,7 @@ public class RepositoryRestController return negotiateResponse(request, HttpStatus.METHOD_NOT_ALLOWED, new HttpHeaders(), null); } - Set links = new HashSet(); + List links = new ArrayList(); PagedResources.PageMetadata pageMeta = null; Iterator allEntities = Collections.emptyList().iterator(); @@ -452,16 +464,15 @@ public class RepositoryRestController } } - List>> allResources = new ArrayList>>(); + List allResources = new ArrayList(); while(allEntities.hasNext()) { Object o = allEntities.next(); - Serializable id = (Serializable)repoMeta.entityMetadata().idAttribute().get(o); if(shouldReturnLinks(request.getServletRequest().getHeader("Accept"))) { + Serializable id = (Serializable)repoMeta.entityMetadata().idAttribute().get(o); links.add(new Link(buildUri(baseUri, repository, id.toString()).toString(), repoMeta.rel() + "." + o.getClass().getSimpleName())); } else { - URI selfUri = buildUri(baseUri, repository, id.toString()); - allResources.add(EntityResource.wrap(o, repoMeta, selfUri)); + allResources.add(o); } } @@ -687,23 +698,19 @@ public class RepositoryRestController List results = new ArrayList(); while(entities.hasNext()) { Object obj = entities.next(); - - if(!hasRepositoryMetadataFor(obj.getClass())) { - results.add(obj); - continue; - } - - // This object is managed by a repository - RepositoryMetadata elemRepoMeta = repositoryMetadataFor(obj.getClass()); - - String id = elemRepoMeta.entityMetadata().idAttribute().get(obj).toString(); if(shouldReturnLinks(request.getServletRequest().getHeader("Accept"))) { + if(!hasRepositoryMetadataFor(obj.getClass())) { + results.add(obj); + continue; + } + // This object is managed by a repository + RepositoryMetadata elemRepoMeta = repositoryMetadataFor(obj.getClass()); + String id = elemRepoMeta.entityMetadata().idAttribute().get(obj).toString(); String rel = elemRepoMeta.rel() + "." + elemRepoMeta.entityMetadata().type().getSimpleName(); URI path = buildUri(baseUri, repository, id); links.add(new org.springframework.hateoas.Link(path.toString(), rel)); } else { - URI selfUri = buildUri(baseUri, repository, id); - results.add(EntityResource.wrap(obj, repoMeta, selfUri)); + results.add(obj); } } @@ -767,7 +774,7 @@ public class RepositoryRestController Resource body = null; if(returnBody(request)) { - body = EntityResource.wrap(savedEntity, repoMeta, selfUri); + body = new Resource(savedEntity); } return negotiateResponse(request, HttpStatus.CREATED, headers, body); @@ -826,11 +833,10 @@ public class RepositoryRestController } } - URI selfUri = buildUri(baseUri, repository, id); return negotiateResponse(request, HttpStatus.OK, headers, - EntityResource.wrap(entity, repoMeta, selfUri)); + entity); } /** @@ -1049,10 +1055,14 @@ public class RepositoryRestController if(shouldReturnLinks(accept)) { links.add(new Link(path.toString(), propertyRel)); } else { - URI selfUri = buildUri(baseUri, propRepoMeta.name(), propValId); - EntityResource er = EntityResource.wrap(o, propRepoMeta, selfUri); - er.add(new Link(path.toString(), propertyRel)); - outgoing.add(er); + Resource r; + if(conversionService.canConvert(o.getClass(), Resource.class)) { + r = conversionService.convert(o, Resource.class); + } else { + r = new Resource(o); + } + r.add(new Link(path.toString(), propertyRel)); + outgoing.add(r); } } @@ -1074,9 +1084,14 @@ public class RepositoryRestController if(shouldReturnLinks(accept)) { resource.put(sKey, new Link(path.toString(), propertyRel)); } else { - URI selfUri = buildUri(baseUri, propRepoMeta.name(), propValId); - EntityResource er = EntityResource.wrap(entry.getValue(), propRepoMeta, selfUri); - resource.put(sKey, er); + Resource r; + if(conversionService.canConvert(entry.getValue().getClass(), Resource.class)) { + r = conversionService.convert(entry.getValue(), Resource.class); + } else { + r = new Resource(entry.getValue()); + } + r.add(new Link(path.toString(), propertyRel)); + resource.put(sKey, r); } } @@ -1086,22 +1101,32 @@ public class RepositoryRestController new Resource(resource, links)); } else { - String propValId = idAttr.get(propVal).toString(); URI path = buildUri(baseUri, repository, id, property); - URI selfUri = buildUri(baseUri, propRepoMeta.name(), propValId); - List> outgoing = new ArrayList>(); if(shouldReturnLinks(accept)) { links.add(new Link(path.toString(), propertyRel)); + + return negotiateResponse(request, + HttpStatus.OK, + new HttpHeaders(), + new Resources(Collections.emptyList(), links)); + } else { - EntityResource er = EntityResource.wrap(propVal, propRepoMeta, selfUri); - outgoing.add(er); + Resource r; + if(conversionService.canConvert(propVal.getClass(), Resource.class)) { + r = conversionService.convert(propVal, Resource.class); + } else { + r = new Resource(propVal); + } + r.add(new Link(path.toString(), propertyRel)); + + return negotiateResponse(request, + HttpStatus.OK, + new HttpHeaders(), + r); + } - return negotiateResponse(request, - HttpStatus.OK, - new HttpHeaders(), - new Resources(outgoing, links)); } @@ -1622,7 +1647,7 @@ public class RepositoryRestController boolean addIf, int nextPage, String rel, - Set links) { + Collection links) { if(null != page && addIf) { UriComponentsBuilder urib = UriComponentsBuilder.fromUri(resourceUri); urib.queryParam(config.getPageParamName(), nextPage); // PageRequest is 0-based, so it's already (page - 1) diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryRestMvcConfiguration.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryRestMvcConfiguration.java index e55c13117..e3cf3f229 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryRestMvcConfiguration.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryRestMvcConfiguration.java @@ -9,6 +9,7 @@ import org.springframework.context.annotation.ImportResource; import org.springframework.data.rest.repository.UriToDomainObjectUriResolver; import org.springframework.data.rest.repository.context.ValidatingRepositoryEventListener; import org.springframework.data.rest.repository.jpa.JpaRepositoryExporter; +import org.springframework.data.rest.webmvc.json.RepositoryAwareJacksonModule; import org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver; @@ -77,6 +78,15 @@ public class RepositoryRestMvcConfiguration { : validatingRepositoryEventListener); } + /** + * A special Jackson {@link org.codehaus.jackson.map.Module} implementation that configures converters for entities. + * + * @return + */ + @Bean public RepositoryAwareJacksonModule jacksonModule() { + return new RepositoryAwareJacksonModule(); + } + /** * Special Repository-aware {@link org.springframework.http.converter.HttpMessageConverter} that can deal with * entities and links. @@ -88,7 +98,8 @@ public class RepositoryRestMvcConfiguration { } /** - * A {@link org.springframework.data.rest.core.UriResolver} implementation that takes a {@link java.net.URI} and turns + * A {@link org.springframework.data.rest.core.UriResolver} implementation that takes a {@link java.net.URI} and + * turns * it * into a top-level domain object. * diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/JacksonUtil.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonUtil.java similarity index 95% rename from spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/JacksonUtil.java rename to spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonUtil.java index 7336aae49..bf17ea5af 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/JacksonUtil.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/JacksonUtil.java @@ -1,4 +1,4 @@ -package org.springframework.data.rest.webmvc; +package org.springframework.data.rest.webmvc.json; import java.io.IOException; import java.util.Arrays; @@ -6,6 +6,7 @@ import java.util.Arrays; import org.codehaus.jackson.JsonEncoding; import org.codehaus.jackson.JsonGenerator; import org.codehaus.jackson.map.ObjectMapper; +import org.springframework.data.rest.webmvc.MediaTypes; import org.springframework.http.HttpOutputMessage; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageNotWritableException; diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/RepositoryAwareJacksonModule.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/RepositoryAwareJacksonModule.java new file mode 100644 index 000000000..9cd3c7948 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/RepositoryAwareJacksonModule.java @@ -0,0 +1,364 @@ +package org.springframework.data.rest.webmvc.json; + +import static org.springframework.data.rest.core.util.UriUtils.*; +import static org.springframework.data.util.ClassTypeInformation.*; + +import java.io.IOException; +import java.io.Serializable; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import org.codehaus.jackson.JsonGenerationException; +import org.codehaus.jackson.JsonGenerator; +import org.codehaus.jackson.JsonParser; +import org.codehaus.jackson.JsonProcessingException; +import org.codehaus.jackson.JsonToken; +import org.codehaus.jackson.Version; +import org.codehaus.jackson.map.DeserializationContext; +import org.codehaus.jackson.map.KeyDeserializer; +import org.codehaus.jackson.map.SerializerProvider; +import org.codehaus.jackson.map.deser.std.StdDeserializer; +import org.codehaus.jackson.map.module.SimpleDeserializers; +import org.codehaus.jackson.map.module.SimpleKeyDeserializers; +import org.codehaus.jackson.map.module.SimpleModule; +import org.codehaus.jackson.map.module.SimpleSerializers; +import org.codehaus.jackson.map.ser.std.SerializerBase; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.data.rest.repository.AttributeMetadata; +import org.springframework.data.rest.repository.RepositoryExporter; +import org.springframework.data.rest.repository.RepositoryMetadata; +import org.springframework.data.rest.repository.UriToDomainObjectUriResolver; +import org.springframework.data.rest.webmvc.EntityToResourceConverter; +import org.springframework.data.rest.webmvc.RepositoryRestController; +import org.springframework.data.util.TypeInformation; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceProcessor; +import org.springframework.http.converter.HttpMessageNotReadableException; + +/** + * Special implementation of a Jackson {@link org.codehaus.jackson.map.Module} to handle properly serializing and + * deserializing entities with links. + * + * @author Jon Brisbin + */ +public class RepositoryAwareJacksonModule extends SimpleModule implements InitializingBean { + + @Autowired(required = false) + protected List repositoryExporters = Collections.emptyList(); + @Autowired(required = false) + private List conversionServices = Collections.emptyList(); + @Autowired(required = false) + private List>> resourceProcessors = Collections.emptyList(); + private Multimap, ResourceProcessor>> resourceProcessorMap = ArrayListMultimap.create(); + @Autowired + private UriToDomainObjectUriResolver domainObjectResolver; + private final GenericConversionService conversionService = new GenericConversionService(); + + private final SimpleSerializers sers = new SimpleSerializers(); + private final SimpleDeserializers dsers = new SimpleDeserializers(); + private final SimpleSerializers keySers = new SimpleSerializers(); + private final SimpleKeyDeserializers keyDsers = new SimpleKeyDeserializers(); + + public RepositoryAwareJacksonModule() { + super("RepositoryAwareJacksonModule", Version.unknownVersion()); + } + + @SuppressWarnings({"unchecked"}) + @Override public void afterPropertiesSet() throws Exception { + for(RepositoryExporter repoExp : repositoryExporters) { + for(String repoName : new ArrayList(repoExp.repositoryNames())) { + RepositoryMetadata repoMeta = repoExp.repositoryMetadataFor(repoName); + Class domainType = repoMeta.entityMetadata().type(); + TypeInformation domainTypeInfo = from(domainType); + + for(ResourceProcessor> rp : resourceProcessors) { + TypeInformation resourceType = from(rp.getClass()) + .getSuperTypeInformation(ResourceProcessor.class) + .getComponentType(); + Class processorType = resourceType.getType(); + TypeInformation componentType = resourceType.getComponentType(); + + if(Resource.class.isAssignableFrom(processorType) && componentType.isAssignableFrom(domainTypeInfo)) { + resourceProcessorMap.put(domainType, rp); + } + } + + if(!conversionService.canConvert(domainType, Resource.class)) { + // Assign only if no custom converter already assigned + conversionService.addConverter(domainType, Resource.class, new EntityToResourceConverter(repoMeta)); + } + + sers.addSerializer(domainType, new DomainObjectToResourceSerializer(domainType)); + keySers.addSerializer(domainType, new DomainObjectToStringKeySerializer(domainType, repoMeta)); + + dsers.addDeserializer(domainType, new LinkToDomainObjectDeserializer(domainType, repoMeta)); + keyDsers.addDeserializer(domainType, new KeyToDomainObjectDeserializer()); + } + } + } + + @Override public void setupModule(SetupContext context) { + context.addSerializers(sers); + context.addKeySerializers(keySers); + context.addDeserializers(dsers); + context.addKeyDeserializers(keyDsers); + } + + private class DomainObjectToResourceSerializer extends SerializerBase { + private DomainObjectToResourceSerializer(Class t) { + super(t); + } + + @SuppressWarnings({"unchecked"}) + @Override public void serialize(Object value, + JsonGenerator jgen, + SerializerProvider provider) throws IOException, + JsonGenerationException { + if(null == value) { + provider.defaultSerializeNull(jgen); + return; + } + + if(!conversionService.canConvert(value.getClass(), Resource.class)) { + provider.defaultSerializeValue(value, jgen); + return; + } + + // Process the resource first to catch user stuff + Resource resource = new Resource(value); + for(ResourceProcessor> rp : resourceProcessorMap.get(value.getClass())) { + resource = rp.process(resource); + } + // Maybe convert the resource so we can extract linked properties + if(null == resource.getContent()) { + provider.defaultSerializeNull(jgen); + return; + } + + if(conversionService.canConvert(resource.getContent().getClass(), Resource.class)) { + Set links = resource.getLinks(); + resource = conversionService.convert(value, Resource.class); + resource.add(links); + } + + jgen.writeObject(resource); + + } + + } + + private class DomainObjectToStringKeySerializer extends SerializerBase { + + private final RepositoryMetadata repoMeta; + private final AttributeMetadata idAttr; + + private DomainObjectToStringKeySerializer(Class t, RepositoryMetadata repoMeta) { + super(t); + this.repoMeta = repoMeta; + if(null != repoMeta) { + idAttr = repoMeta.entityMetadata().idAttribute(); + } else { + idAttr = null; + } + } + + @Override public void serialize(Object value, + JsonGenerator jgen, + SerializerProvider provider) throws IOException, + JsonGenerationException { + if(null == value) { + provider.defaultSerializeNull(jgen); + return; + } + if(null == repoMeta) { + provider.defaultSerializeValue(value, jgen); + return; + } + + Serializable serId = (Serializable)idAttr.get(value); + String sId = null; + for(ConversionService cs : conversionServices) { + if(cs.canConvert(idAttr.type(), String.class)) { + sId = cs.convert(serId, String.class); + break; + } + } + if(null == sId) { + sId = serId.toString(); + } + + URI href = buildUri(RepositoryRestController.BASE_URI.get(), repoMeta.name(), sId); + + jgen.writeString("@" + href.toString()); + } + + } + + private class LinkToDomainObjectDeserializer extends StdDeserializer { + + protected final RepositoryMetadata repoMeta; + + private LinkToDomainObjectDeserializer(Class vc, RepositoryMetadata repoMeta) { + super(vc); + this.repoMeta = repoMeta; + } + + @SuppressWarnings({"unchecked"}) + @Override public Object deserialize(JsonParser jp, + DeserializationContext ctxt) throws IOException, + JsonProcessingException { + Object entity = BeanUtils.instantiate(getValueClass()); + for(JsonToken tok = jp.nextToken(); tok != JsonToken.END_OBJECT; tok = jp.nextToken()) { + String name = jp.getCurrentName(); + switch(tok) { + case FIELD_NAME: { + // Read the attribute metadata + AttributeMetadata attrMeta = repoMeta.entityMetadata().attribute(name); + Object val = null; + + if(name.startsWith("@http")) { + entity = domainObjectResolver.resolve( + RepositoryRestController.BASE_URI.get(), + URI.create(name.substring(1)) + ); + continue; + } + + if("href".equals(name)) { + entity = domainObjectResolver.resolve( + RepositoryRestController.BASE_URI.get(), + URI.create(jp.nextTextValue()) + ); + continue; + } + + if("rel".equals(name)) { + // rel is currently ignored + continue; + } + + if(null == attrMeta) { + // do nothing + continue; + } + + // Try and read the value of this attribute. + // The method of doing that varies based on the type of the property. + if(attrMeta.isCollectionLike()) { + Collection c = attrMeta.asCollection(entity); + if(null == c || c == Collections.emptyList()) { + c = new ArrayList(); + } + + if((tok = jp.nextToken()) == JsonToken.START_ARRAY) { + do { + Object cval = jp.readValueAs(attrMeta.elementType()); + c.add(cval); + } while((tok = jp.nextToken()) != JsonToken.END_ARRAY); + + val = c; + + } else if(tok == JsonToken.VALUE_NULL) { + val = null; + } else { + throw new HttpMessageNotReadableException("Cannot read a JSON " + tok + " as a Collection."); + } + } else if(attrMeta.isSetLike()) { + Set s = attrMeta.asSet(entity); + if(null == s || s == Collections.emptySet()) { + s = new HashSet(); + } + + if((tok = jp.nextToken()) == JsonToken.START_ARRAY) { + do { + Object sval = jp.readValueAs(attrMeta.elementType()); + s.add(sval); + } while((tok = jp.nextToken()) != JsonToken.END_ARRAY); + + val = s; + + } else if(tok == JsonToken.VALUE_NULL) { + val = null; + } else { + throw new HttpMessageNotReadableException("Cannot read a JSON " + tok + " as a Set."); + } + } else if(attrMeta.isMapLike()) { + Map m = attrMeta.asMap(entity); + if(null == m || m == Collections.emptyMap()) { + m = new HashMap(); + } + + if((tok = jp.nextToken()) == JsonToken.START_OBJECT) { + do { + name = jp.getCurrentName(); + Object mkey = ( + name.startsWith("@http") + ? domainObjectResolver.resolve( + RepositoryRestController.BASE_URI.get(), + URI.create(name.substring(1)) + ) + : name + ); + tok = jp.nextToken(); + Object mval = jp.readValueAs(attrMeta.elementType()); + + m.put(mkey, mval); + } while((tok = jp.nextToken()) != JsonToken.END_OBJECT); + + val = m; + + } else if(tok == JsonToken.VALUE_NULL) { + val = null; + } else { + throw new HttpMessageNotReadableException("Cannot read a JSON " + tok + " as a Map."); + } + } else { + if((tok = jp.nextToken()) != JsonToken.VALUE_NULL) { + val = jp.readValueAs(attrMeta.type()); + } + } + + if(null != val) { + attrMeta.set(val, entity); + } + + break; + } + } + } + + return entity; + } + + } + + private class KeyToDomainObjectDeserializer extends KeyDeserializer { + @Override public Object deserializeKey(String key, + DeserializationContext ctxt) throws IOException, + JsonProcessingException { + if(key.startsWith("@http")) { + return domainObjectResolver.resolve( + RepositoryRestController.BASE_URI.get(), + URI.create(key.substring(1)) + ); + } else { + return key; + } + } + } + +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/ApplicationConfig.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/ApplicationConfig.java index 6aaa98282..9db5b4bd8 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/ApplicationConfig.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/ApplicationConfig.java @@ -14,6 +14,9 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.rest.webmvc.RepositoryRestMvcConfiguration; import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.Resource; +import org.springframework.hateoas.ResourceProcessor; import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; import org.springframework.orm.jpa.JpaDialect; @@ -83,4 +86,14 @@ public class ApplicationConfig { return cs; } + @Bean public ResourceProcessor> personProcessor() { + return new ResourceProcessor>() { + @Override public Resource process(Resource resource) { + System.out.println("\t***** ResourceProcessor for Person: " + resource); + resource.add(new Link("http://localhost:8080/people", "added-link")); + return resource; + } + }; + } + }