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