From d7e2e53ce22c95206d1c24baf2cb366a22b3e19f Mon Sep 17 00:00:00 2001 From: Jon Brisbin Date: Wed, 15 Aug 2012 14:31:57 -0500 Subject: [PATCH] Implemented a new style of JSON serialization and deserialization by registering Repository-aware components with Jackson's underlying ObjectMapper. This has the benefit of allowing us to transliterate domain objects into link representations and back again, no matter where those entities appear in complex nested object graphs. --- .../springframework/data/rest/core/Link.java | 1 - .../data/rest/core/ResourceLink.java | 21 +- .../core/util/FluentBeanDeserializer.java | 89 ---- .../rest/core/util/FluentBeanSerializer.java | 78 ---- .../rest/repository/AttributeMetadata.java | 7 + .../rest/repository/PageableResourceSet.java | 12 +- .../data/rest/repository/PagingMetadata.java | 51 ++- .../repository/RepositoryExporterSupport.java | 2 +- .../RepositoryMetadataResolver.java | 59 --- .../repository/UriToDomainObjectResolver.java | 69 +++ .../repository/jpa/JpaAttributeMetadata.java | 7 + .../data/rest/webmvc/JacksonUtil.java | 31 +- .../data/rest/webmvc/PagingAndSorting.java | 3 +- ...itoryAwareMappingHttpMessageConverter.java | 431 ++++++++++++++++++ .../rest/webmvc/RepositoryRestController.java | 184 ++++---- .../RepositoryRestMvcConfiguration.java | 24 +- .../rest/webmvc/spec/DiscoverySpec.groovy | 4 +- .../spec/RepositoryRestControllerSpec.groovy | 8 - .../data/rest/test/webmvc/WebCustomer.java | 14 +- 19 files changed, 723 insertions(+), 372 deletions(-) delete mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/util/FluentBeanDeserializer.java delete mode 100644 spring-data-rest-core/src/main/java/org/springframework/data/rest/core/util/FluentBeanSerializer.java delete mode 100644 spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryMetadataResolver.java create mode 100644 spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/UriToDomainObjectResolver.java create mode 100644 spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryAwareMappingHttpMessageConverter.java diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/Link.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/Link.java index ba828fc33..8d9127dcd 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/Link.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/Link.java @@ -9,7 +9,6 @@ import org.codehaus.jackson.annotate.JsonTypeInfo; * * @author Jon Brisbin */ -@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, defaultImpl = ResourceLink.class) public interface Link { /** diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/ResourceLink.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/ResourceLink.java index 804676705..e1549a945 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/ResourceLink.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/ResourceLink.java @@ -2,8 +2,8 @@ package org.springframework.data.rest.core; import java.net.URI; -import org.codehaus.jackson.annotate.JsonIgnore; import org.codehaus.jackson.annotate.JsonProperty; +import org.springframework.util.Assert; /** * Implementation of {@link Link}. @@ -33,6 +33,25 @@ public class ResourceLink implements Link, Comparable { return href; } + public String getRel() { + return rel(); + } + + public ResourceLink setRel(String rel) { + this.rel = rel; + return this; + } + + public URI getHref() { + return href(); + } + + public ResourceLink setHref(URI href) { + Assert.notNull(href, "href URI cannot be null."); + this.href = href; + return this; + } + @Override public int compareTo(Link link) { if(null == rel || null == link.rel()) { return -1; diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/util/FluentBeanDeserializer.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/util/FluentBeanDeserializer.java deleted file mode 100644 index 03a00822d..000000000 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/util/FluentBeanDeserializer.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.springframework.data.rest.core.util; - -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; - -import org.codehaus.jackson.JsonParser; -import org.codehaus.jackson.JsonToken; -import org.codehaus.jackson.map.DeserializationContext; -import org.codehaus.jackson.map.deser.std.StdDeserializer; -import org.springframework.core.convert.ConversionService; -import org.springframework.util.ClassUtils; - -/** - * A "fluent" bean is one that does not use the JavaBean conventions of "setProperty" and "getProperty" but instead - * uses just "property" with 0 or 1 arguments to distinguish between getter (0 arg) and setter (1 arg). - * - * @author Jon Brisbin - */ -public class FluentBeanDeserializer extends StdDeserializer { - - private ConversionService conversionService; - private FluentBeanUtils.Metadata beanMeta; - - @SuppressWarnings({"unchecked"}) - public FluentBeanDeserializer(final Class valueClass, ConversionService conversionService) { - super(valueClass); - this.conversionService = conversionService; - this.beanMeta = FluentBeanUtils.metadata(valueClass); - - if(!FluentBeanUtils.isFluentBean(valueClass)) { - throw new IllegalArgumentException("Class of type " + valueClass + " is not a FluentBean"); - } - } - - @Override - public Object deserialize(JsonParser jp, - DeserializationContext ctxt) throws IOException { - if(jp.getCurrentToken() != JsonToken.START_OBJECT) { - throw ctxt.mappingException(_valueClass); - } - - Object bean; - try { - bean = _valueClass.newInstance(); - } catch(InstantiationException e) { - throw new IllegalStateException(e); - } catch(IllegalAccessException e) { - throw new IllegalStateException(e); - } - - while(jp.nextToken() != JsonToken.END_OBJECT) { - String name = jp.getCurrentName(); - Method setter = beanMeta.setters().get(name); - - Object obj; - if(null != setter) { - Class targetType = setter.getParameterTypes()[0]; - if(ClassUtils.isAssignable(targetType, Long.class)) { - obj = jp.nextLongValue(-1); - } else if(ClassUtils.isAssignable(targetType, Integer.class)) { - obj = jp.nextIntValue(-1); - } else if(ClassUtils.isAssignable(targetType, Boolean.class)) { - obj = jp.nextBooleanValue(); - } else { - obj = jp.nextTextValue(); - } - - if(null != obj) { - if(!ClassUtils.isAssignable(obj.getClass(), targetType)) { - obj = conversionService.convert(obj, targetType); - } - - try { - setter.invoke(bean, obj); - } catch(IllegalAccessException e) { - throw new IllegalStateException(e); - } catch(InvocationTargetException e) { - throw new IllegalStateException(e); - } - } - } - - } - - return bean; - } - -} diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/util/FluentBeanSerializer.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/util/FluentBeanSerializer.java deleted file mode 100644 index 463aba1dd..000000000 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/util/FluentBeanSerializer.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.springframework.data.rest.core.util; - -import java.io.IOException; -import java.util.Collection; -import java.util.Map; - -import org.codehaus.jackson.JsonGenerationException; -import org.codehaus.jackson.JsonGenerator; -import org.codehaus.jackson.map.SerializerProvider; -import org.codehaus.jackson.map.ser.std.SerializerBase; -import org.springframework.util.ClassUtils; - -/** - * A "fluent" bean is one that does not use the JavaBean conventions of "setProperty" and "getProperty" but instead - * uses just "property" with 0 or 1 arguments to distinguish between getter (0 arg) and setter (1 arg). - * - * @author Jon Brisbin - */ -public class FluentBeanSerializer - extends SerializerBase { - - @SuppressWarnings({"unchecked", "rawtypes"}) - public FluentBeanSerializer(final Class t) { - super(t); - - if(!FluentBeanUtils.isFluentBean(t)) { - throw new IllegalArgumentException("Class of type " + t + " is not a FluentBean"); - } - } - - @SuppressWarnings({"unchecked"}) - @Override - public void serialize(final Object value, - final JsonGenerator jgen, - final SerializerProvider provider) - throws IOException, - JsonGenerationException { - if(null == value) { - provider.defaultSerializeNull(jgen); - } else { - Class type = value.getClass(); - if(ClassUtils.isAssignable(type, Collection.class)) { - jgen.writeStartArray(); - for(Object o : (Collection)value) { - write(o, jgen, provider); - } - jgen.writeEndArray(); - } else if(ClassUtils.isAssignable(type, Map.class)) { - jgen.writeStartObject(); - for(Map.Entry entry : ((Map)value).entrySet()) { - jgen.writeFieldName(entry.getKey()); - write(entry.getValue(), jgen, provider); - } - jgen.writeEndObject(); - } else { - write(value, jgen, provider); - } - } - } - - private void write(final Object value, - final JsonGenerator jgen, - final SerializerProvider provider) - throws IOException { - Class type = value.getClass(); - if(ClassUtils.isAssignable(type, _handledType)) { - jgen.writeStartObject(); - for(String fname : FluentBeanUtils.metadata(type).fieldNames()) { - jgen.writeFieldName(fname); - write(FluentBeanUtils.get(fname, value), jgen, provider); - } - jgen.writeEndObject(); - } else { - jgen.writeObject(value); - } - } - -} diff --git a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/AttributeMetadata.java b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/AttributeMetadata.java index 584fdb03f..086ae9dff 100644 --- a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/AttributeMetadata.java +++ b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/AttributeMetadata.java @@ -25,6 +25,13 @@ public interface AttributeMetadata { */ Class type(); + /** + * The type of this map's key, if it's map-like. + * + * @return + */ + Class keyType(); + /** * The element type of this attribute, if this attribute is a "plural"-like attribute (a Collection, Map, etc...). * diff --git a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/PageableResourceSet.java b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/PageableResourceSet.java index 4f0b7ccfd..d392cef29 100644 --- a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/PageableResourceSet.java +++ b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/PageableResourceSet.java @@ -8,18 +8,8 @@ import org.springframework.data.rest.core.ResourceSet; */ public class PageableResourceSet extends ResourceSet { - protected long resourceCount = 0; @JsonProperty("page") - protected PagingMetadata paging = new PagingMetadata(-1, 0); - - public long getResourceCount() { - return resourceCount; - } - - public PageableResourceSet setResourceCount(long resourceCount) { - this.resourceCount = resourceCount; - return this; - } + protected PagingMetadata paging = new PagingMetadata(-1, 20, 0, 0); public PagingMetadata getPaging() { return paging; diff --git a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/PagingMetadata.java b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/PagingMetadata.java index dee278ea7..8678cb3c1 100644 --- a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/PagingMetadata.java +++ b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/PagingMetadata.java @@ -5,29 +5,54 @@ package org.springframework.data.rest.repository; */ public class PagingMetadata { - private int current = 0; - private int total = 0; + private int number = 0; + private int size = 0; + private int totalPages = 0; + private long totalElements = 0; - public PagingMetadata(int current, int total) { - this.current = current; - this.total = total; + public PagingMetadata(int number, + int size, + int totalPages, + long totalElements) { + this.number = number; + this.size = size; + this.totalPages = totalPages; + this.totalElements = totalElements; } - public int getCurrent() { - return current; + public int getNumber() { + return number; } - public PagingMetadata setCurrent(int current) { - this.current = current; + public PagingMetadata setNumber(int number) { + this.number = number; return this; } - public int getTotal() { - return total; + public int getSize() { + return size; } - public PagingMetadata setTotal(int total) { - this.total = total; + public PagingMetadata setSize(int size) { + this.size = size; + return this; + } + + public int getTotalPages() { + return totalPages; + } + + public PagingMetadata setTotalPages(int totalPages) { + this.totalPages = totalPages; + return this; + } + + public long getTotalElements() { + return totalElements; + } + + public PagingMetadata setTotalElements(long totalElements) { + this.totalElements = totalElements; return this; } diff --git a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryExporterSupport.java b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryExporterSupport.java index fefb149e1..1af03a408 100644 --- a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryExporterSupport.java +++ b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryExporterSupport.java @@ -13,7 +13,6 @@ import org.springframework.beans.factory.annotation.Autowired; */ public abstract class RepositoryExporterSupport> { - @Autowired(required = false) protected List repositoryExporters = Collections.emptyList(); /** @@ -31,6 +30,7 @@ public abstract class RepositoryExporterSupport repositoryExporters) { this.repositoryExporters = repositoryExporters; } diff --git a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryMetadataResolver.java b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryMetadataResolver.java deleted file mode 100644 index 92e21b719..000000000 --- a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryMetadataResolver.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.springframework.data.rest.repository; - -import static org.springframework.data.rest.core.util.UriUtils.*; - -import java.net.URI; -import java.util.Collections; -import java.util.List; - -import org.springframework.beans.BeansException; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationContext; -import org.springframework.context.ApplicationContextAware; -import org.springframework.data.rest.core.Resolver; -import org.springframework.util.Assert; - -/** - * @author Jon Brisbin - */ -public class RepositoryMetadataResolver, E extends EntityMetadata> - implements Resolver, - ApplicationContextAware { - - private ApplicationContext applicationContext; - @Autowired(required = false) - private List repositoryExporters = Collections.emptyList(); - - @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { - this.applicationContext = applicationContext; - } - - public List getRepositoryExporters() { - return repositoryExporters; - } - - public RepositoryMetadataResolver setRepositoryExporters(List repositoryExporters) { - Assert.notNull(repositoryExporters, "List of RepositoryExporters cannot be null!"); - this.repositoryExporters = repositoryExporters; - return this; - } - - @SuppressWarnings({"unchecked"}) - @Override public M resolve(URI baseUri, URI uri) { - URI tail; - if(null == (tail = tail(baseUri, uri))) { - return null; - } - - String path = tail.getPath(); - for(RepositoryExporter exporter : repositoryExporters) { - RepositoryMetadata repoMeta; - if(null != (repoMeta = exporter.repositoryMetadataFor(path))) { - return (M)repoMeta; - } - } - - return null; - } - -} diff --git a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/UriToDomainObjectResolver.java b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/UriToDomainObjectResolver.java new file mode 100644 index 000000000..750cf43f5 --- /dev/null +++ b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/UriToDomainObjectResolver.java @@ -0,0 +1,69 @@ +package org.springframework.data.rest.repository; + +import java.io.Serializable; +import java.net.URI; +import java.util.Stack; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.convert.ConversionService; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.rest.core.Resolver; +import org.springframework.data.rest.core.util.UriUtils; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.util.ClassUtils; + +/** + * @author Jon Brisbin + */ +public class UriToDomainObjectResolver + extends RepositoryExporterSupport + implements Resolver { + + @Autowired(required = false) + private ConversionService conversionService = new DefaultFormattingConversionService(); + + public ConversionService getConversionService() { + return conversionService; + } + + public UriToDomainObjectResolver setConversionService(ConversionService conversionService) { + this.conversionService = conversionService; + return this; + } + + @SuppressWarnings({"unchecked"}) + @Override public Object resolve(URI baseUri, URI uri) { + URI relativeUri = baseUri.relativize(uri); + Stack uris = UriUtils.explode(baseUri, relativeUri); + + if(uris.size() < 1) { + return null; + } + + String repoName = UriUtils.path(uris.get(0)); + String sId = UriUtils.path(uris.get(1)); + + RepositoryMetadata repoMeta = repositoryMetadataFor(repoName); + + CrudRepository repo; + if(null == (repo = repoMeta.repository())) { + return null; + } + + EntityMetadata entityMeta; + if(null == (entityMeta = repoMeta.entityMetadata())) { + return null; + } + + Class idType = (Class)entityMeta.idAttribute().type(); + Serializable serId; + if(ClassUtils.isAssignable(idType, String.class)) { + serId = sId; + } else { + serId = conversionService.convert(sId, idType); + } + + return repo.findOne(serId); + } + +} diff --git a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/jpa/JpaAttributeMetadata.java b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/jpa/JpaAttributeMetadata.java index 25ac5a0e8..109bf3861 100644 --- a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/jpa/JpaAttributeMetadata.java +++ b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/jpa/JpaAttributeMetadata.java @@ -8,6 +8,7 @@ import java.util.Map; import java.util.Set; import javax.persistence.metamodel.Attribute; import javax.persistence.metamodel.EntityType; +import javax.persistence.metamodel.MapAttribute; import javax.persistence.metamodel.PluralAttribute; import org.springframework.beans.BeanUtils; @@ -58,6 +59,12 @@ public class JpaAttributeMetadata implements AttributeMetadata { return type; } + @Override public Class keyType() { + return (attribute instanceof MapAttribute + ? ((MapAttribute)attribute).getKeyJavaType() + : null); + } + @Override public Class elementType() { return (attribute instanceof PluralAttribute ? ((PluralAttribute)attribute).getElementType().getJavaType() 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/JacksonUtil.java index 49bb589e2..7336aae49 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/JacksonUtil.java @@ -6,10 +6,6 @@ import java.util.Arrays; import org.codehaus.jackson.JsonEncoding; import org.codehaus.jackson.JsonGenerator; import org.codehaus.jackson.map.ObjectMapper; -import org.codehaus.jackson.map.ser.CustomSerializerFactory; -import org.springframework.data.rest.core.Link; -import org.springframework.data.rest.core.ResourceLink; -import org.springframework.data.rest.core.util.FluentBeanSerializer; import org.springframework.http.HttpOutputMessage; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageNotWritableException; @@ -27,25 +23,34 @@ public abstract class JacksonUtil { } public static MappingJacksonHttpMessageConverter createJacksonHttpMessageConverter(final ObjectMapper objectMapper) { - // We need a custom serializer for handling beans that don't conform to the JavaBeans standard 'get' and 'set' - CustomSerializerFactory customSerializerFactory = new CustomSerializerFactory(); - customSerializerFactory.addSpecificMapping(ResourceLink.class, new FluentBeanSerializer(ResourceLink.class)); - objectMapper.setSerializerFactory(customSerializerFactory); // We want to support all our custom types of JSON and also the catch-all MappingJacksonHttpMessageConverter jsonConverter = new MappingJacksonHttpMessageConverter() { { setSupportedMediaTypes(Arrays.asList( MediaType.APPLICATION_JSON, MediaTypes.COMPACT_JSON, - MediaTypes.VERBOSE_JSON, - MediaType.ALL + MediaTypes.VERBOSE_JSON )); } + @Override public boolean canRead(Class clazz, MediaType mediaType) { + if(!canRead(mediaType)) { + return false; + } + return true; + } + + @Override public boolean canWrite(Class clazz, MediaType mediaType) { + if(!canWrite(mediaType)) { + return false; + } + return true; + } + @Override - protected void writeInternal(Object object, HttpOutputMessage outputMessage) - throws IOException, - HttpMessageNotWritableException { + protected void writeInternal(Object object, + HttpOutputMessage outputMessage) throws IOException, + HttpMessageNotWritableException { JsonEncoding encoding = getJsonEncoding(outputMessage.getHeaders().getContentType()); // Believe it or not, this is the only way to get pretty-printing from Jackson in this configuration JsonGenerator jsonGenerator = objectMapper diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PagingAndSorting.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PagingAndSorting.java index 224d66653..af541a63c 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PagingAndSorting.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/PagingAndSorting.java @@ -14,8 +14,7 @@ import org.springframework.web.util.UriComponentsBuilder; * * @author Jon Brisbin */ -public class PagingAndSorting - implements Pageable { +public class PagingAndSorting implements Pageable { private final RepositoryRestConfiguration config; private final PageRequest pageRequest; 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 new file mode 100644 index 000000000..6c3c2e23d --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryAwareMappingHttpMessageConverter.java @@ -0,0 +1,431 @@ +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.SimpleAbstractTypeResolver; +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.core.convert.ConversionService; +import org.springframework.data.rest.core.Link; +import org.springframework.data.rest.core.ResourceLink; +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.UriToDomainObjectResolver; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.http.converter.json.MappingJacksonHttpMessageConverter; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * @author Jon Brisbin + */ +public class RepositoryAwareMappingHttpMessageConverter + extends MappingJacksonHttpMessageConverter + implements InitializingBean { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Autowired(required = false) + protected ConversionService conversionService = new DefaultFormattingConversionService(); + @Autowired(required = false) + protected List repositoryExporters = Collections.emptyList(); + @Autowired(required = false) + protected List modules = Collections.emptyList(); + @Autowired + protected UriToDomainObjectResolver domainObjectResolver; + + public RepositoryAwareMappingHttpMessageConverter() { + setSupportedMediaTypes(Arrays.asList( + MediaType.APPLICATION_JSON, + MediaTypes.COMPACT_JSON, + MediaTypes.VERBOSE_JSON + )); + + mapper.registerModule(new RepositoryAwareModule()); + + setObjectMapper(mapper); + } + + @Override public void afterPropertiesSet() throws Exception { + for(Module m : modules) { + mapper.registerModule(m); + } + } + + public ConversionService getConversionService() { + return conversionService; + } + + public RepositoryAwareMappingHttpMessageConverter setConversionService(ConversionService conversionService) { + this.conversionService = conversionService; + return this; + } + + public List getRepositoryExporters() { + return repositoryExporters; + } + + @Autowired(required = false) + @SuppressWarnings({"unchecked"}) + public RepositoryAwareMappingHttpMessageConverter setRepositoryExporters(List repositoryExporters) { + this.repositoryExporters = repositoryExporters; + + for(RepositoryExporter repoExp : repositoryExporters) { + for(String repoName : new ArrayList(repoExp.repositoryNames())) { + RepositoryMetadata repoMeta = repoExp.repositoryMetadataFor(repoName); + Class domainType = repoMeta.entityMetadata().type(); + + } + } + return this; + } + + public List getModules() { + return modules; + } + + public RepositoryAwareMappingHttpMessageConverter setModules(List modules) { + this.modules = modules; + return this; + } + + public UriToDomainObjectResolver getDomainObjectResolver() { + return domainObjectResolver; + } + + public RepositoryAwareMappingHttpMessageConverter setDomainObjectResolver(UriToDomainObjectResolver domainObjectResolver) { + this.domainObjectResolver = domainObjectResolver; + return this; + } + + @Override public boolean canWrite(Class clazz, MediaType mediaType) { + if(!canWrite(mediaType)) { + return false; + } + return supports(clazz); + } + + @Override public boolean canRead(Class clazz, MediaType mediaType) { + if(!canRead(mediaType)) { + return false; + } + return supports(clazz); + } + + @SuppressWarnings({"unchecked"}) + @Override protected boolean supports(Class clazz) { + for(RepositoryExporter repoExp : repositoryExporters) { + for(String repoName : new ArrayList(repoExp.repositoryNames())) { + RepositoryMetadata repoMeta = repoExp.repositoryMetadataFor(repoName); + Class domainType = repoMeta.entityMetadata().type(); + if(domainType.isAssignableFrom(clazz)) { + return true; + } + } + } + return false; + } + + @Override protected void writeInternal(Object object, + HttpOutputMessage outputMessage) throws IOException, + HttpMessageNotWritableException { + JsonEncoding encoding = getJsonEncoding(outputMessage.getHeaders().getContentType()); + // Believe it or not, this is the only way to get pretty-printing from Jackson in this configuration + JsonGenerator jsonGenerator = mapper + .getJsonFactory() + .createJsonGenerator(outputMessage.getBody(), encoding) + .useDefaultPrettyPrinter(); + try { + mapper.writeValue(jsonGenerator, object); + } catch(IOException ex) { + throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getMessage(), ex); + } + } + + @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 { + private RepositoryAwareModule() { + super("RepositoryAwareModule", new Version(1, 0, 0, "SNAPSHOT")); + } + + @SuppressWarnings({"unchecked"}) + @Override public void setupModule(SetupContext context) { + context.addAbstractTypeResolver( + new SimpleAbstractTypeResolver() + .addMapping(Link.class, ResourceLink.class) + ); + SimpleSerializers sers = new SimpleSerializers(); + SimpleDeserializers dsers = new SimpleDeserializers(); + SimpleSerializers keySers = new SimpleSerializers(); + SimpleKeyDeserializers keyDsers = new SimpleKeyDeserializers(); + + 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 = conversionService.convert(serId, String.class); + if(null == sId) { + sId = serId.toString(); + } + + String rel = repoMeta.rel() + "." + repoMeta.domainType().getSimpleName(); + URI href = buildUri(RepositoryRestController.BASE_URI.get(), repoMeta.name(), sId); + + jgen.writeObject(new ResourceLink(rel, href)); + } + + } + + 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 = conversionService.convert(serId, String.class); + 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.getCurrentToken(); tok != JsonToken.END_OBJECT; tok = jp.nextToken()) { + String name = jp.getCurrentName(); + switch(tok) { + case FIELD_NAME: { + if(name.startsWith("@http")) { + entity = domainObjectResolver.resolve( + RepositoryRestController.BASE_URI.get(), + URI.create(name.substring(1)) + ); + } else if("href".equals(name)) { + entity = domainObjectResolver.resolve( + RepositoryRestController.BASE_URI.get(), + URI.create(jp.nextTextValue()) + ); + } else if("rel".equals(name)) { + // rel is currently ignored + } else { + // Read the attribute metadata + AttributeMetadata attrMeta = repoMeta.entityMetadata().attribute(name); + if(null == attrMeta) { + continue; + } + // Try and read the value of this attribute. + // The method of doing that varies based on the type of the property. + Object val; + if(attrMeta.isCollectionLike()) { + Collection c = attrMeta.asCollection(entity); + if(null == c) { + c = new ArrayList(); + } + + if(jp.getCurrentToken() == JsonToken.START_ARRAY) { + while((tok = jp.nextToken()) != JsonToken.END_ARRAY) { + Object cval = jp.readValueAs(attrMeta.elementType()); + c.add(cval); + } + } + + val = c; + } else if(attrMeta.isSetLike()) { + Set s = attrMeta.asSet(entity); + if(null == s) { + s = new HashSet(); + } + + if(jp.getCurrentToken() == JsonToken.START_ARRAY) { + while((tok = jp.nextToken()) != JsonToken.END_ARRAY) { + Object sval = jp.readValueAs(attrMeta.elementType()); + s.add(sval); + } + } + + val = s; + } else if(attrMeta.isMapLike()) { + Map m = attrMeta.asMap(entity); + if(null == m) { + m = new HashMap(); + } + + if(jp.getCurrentToken() == JsonToken.START_OBJECT) { + while((tok = jp.nextToken()) != JsonToken.END_OBJECT) { + 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); + } + } + + val = m; + } else { + val = jp.readValueAs(attrMeta.type()); + } + 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 c74d63608..9c713f900 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 @@ -18,9 +18,9 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.SortedSet; -import java.util.Stack; import java.util.TreeSet; import java.util.concurrent.atomic.AtomicReference; +import javax.servlet.http.HttpServletRequest; import org.codehaus.jackson.map.ObjectMapper; import org.slf4j.Logger; @@ -34,6 +34,7 @@ import org.springframework.context.ApplicationContextAware; import org.springframework.core.convert.ConversionFailedException; import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.converter.Converter; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -49,7 +50,6 @@ import org.springframework.data.rest.core.Resource; import org.springframework.data.rest.core.ResourceLink; import org.springframework.data.rest.core.ResourceSet; import org.springframework.data.rest.core.convert.DelegatingConversionService; -import org.springframework.data.rest.core.util.UriUtils; import org.springframework.data.rest.repository.AttributeMetadata; import org.springframework.data.rest.repository.EntityMetadata; import org.springframework.data.rest.repository.PageableResourceSet; @@ -59,6 +59,7 @@ import org.springframework.data.rest.repository.RepositoryExporter; import org.springframework.data.rest.repository.RepositoryExporterSupport; import org.springframework.data.rest.repository.RepositoryMetadata; import org.springframework.data.rest.repository.RepositoryNotFoundException; +import org.springframework.data.rest.repository.UriToDomainObjectResolver; import org.springframework.data.rest.repository.annotation.ConvertWith; import org.springframework.data.rest.repository.annotation.RestResource; import org.springframework.data.rest.repository.context.AfterDeleteEvent; @@ -82,12 +83,9 @@ import org.springframework.http.HttpOutputMessage; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.ByteArrayHttpMessageConverter; -import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; -import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -135,43 +133,36 @@ public class RepositoryRestController implements ApplicationContextAware, InitializingBean { - public static final String LOCATION = "Location"; - public static final String SELF = "self"; - public static final String LINKS = "_links"; - - private static final Logger LOG = LoggerFactory.getLogger(RepositoryRestController.class); - - private ApplicationContext applicationContext; + 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(RepositoryRestController.class); /** * We manage a list of possible {@link ConversionService}s to handle converting objects in the controller. This list * is prioritized as well, so one can add a ConversionService at index 0 to make sure that ConversionService takes * priority whenever an object of the type it can convert is needing conversion. */ - @Autowired(required = false) private DelegatingConversionService conversionService = new DelegatingConversionService( new DefaultFormattingConversionService() ); /** * Converters for reading and writing representations of objects. */ - @Autowired(required = false) private List httpMessageConverters = new ArrayList(); /** * List of {@link MediaType}s we can support, given the list of {@link HttpMessageConverter}s currently configured. */ private SortedSet availableMediaTypes = new TreeSet(); - @Autowired(required = false) private RepositoryRestConfiguration config = RepositoryRestConfiguration.DEFAULT; private ObjectMapper objectMapper = new ObjectMapper(); + private RepositoryAwareMappingHttpMessageConverter mappingHttpMessageConverter; + private UriToDomainObjectResolver domainObjectResolver; + private ApplicationContext applicationContext; { List httpMessageConverters = new ArrayList(); - httpMessageConverters.add(0, new StringHttpMessageConverter()); - httpMessageConverters.add(0, new ByteArrayHttpMessageConverter()); - httpMessageConverters.add(0, new FormHttpMessageConverter()); - httpMessageConverters.add(0, JacksonUtil.createJacksonHttpMessageConverter(objectMapper)); - httpMessageConverters.add(0, new UriListHttpMessageConverter()); + httpMessageConverters.add(new UriListHttpMessageConverter()); setHttpMessageConverters(httpMessageConverters); } @@ -195,6 +186,7 @@ public class RepositoryRestController * * @param conversionService */ + @Autowired(required = false) public void setConversionService(ConversionService conversionService) { if(null != conversionService) { this.conversionService.addConversionServices(conversionService); @@ -243,10 +235,14 @@ public class RepositoryRestController this.httpMessageConverters = httpMessageConverters; this.availableMediaTypes.clear(); for(HttpMessageConverter conv : httpMessageConverters) { - availableMediaTypes.addAll(conv.getSupportedMediaTypes()); + for(MediaType mt : (List)conv.getSupportedMediaTypes()) { + availableMediaTypes.add(mt.toString()); + } } for(HttpMessageConverter conv : config.getCustomConverters()) { - availableMediaTypes.addAll(conv.getSupportedMediaTypes()); + for(MediaType mt : (List)conv.getSupportedMediaTypes()) { + availableMediaTypes.add(mt.toString()); + } } } @@ -287,17 +283,40 @@ public class RepositoryRestController * * @return @this */ + @Autowired(required = false) public RepositoryRestController setRepositoryRestConfig(RepositoryRestConfiguration config) { this.config = config; return this; } + public RepositoryAwareMappingHttpMessageConverter getMappingHttpMessageConverter() { + return mappingHttpMessageConverter; + } + + @Autowired + public RepositoryRestController setMappingHttpMessageConverter(RepositoryAwareMappingHttpMessageConverter mappingHttpMessageConverter) { + this.mappingHttpMessageConverter = mappingHttpMessageConverter; + httpMessageConverters.add(mappingHttpMessageConverter); + this.objectMapper = mappingHttpMessageConverter.getObjectMapper(); + return this; + } + + public UriToDomainObjectResolver getDomainObjectResolver() { + return domainObjectResolver; + } + + @Autowired + public RepositoryRestController setDomainObjectResolver(UriToDomainObjectResolver domainObjectResolver) { + this.domainObjectResolver = domainObjectResolver; + return this; + } + @SuppressWarnings({"unchecked"}) @Override public void afterPropertiesSet() throws Exception { - for(ConversionService convsvc : BeanFactoryUtils.beansOfTypeIncludingAncestors(applicationContext, - ConversionService.class) - .values()) { - conversionService.addConversionServices(convsvc); + for(ConversionService cs : BeanFactoryUtils.beansOfTypeIncludingAncestors(applicationContext, + ConversionService.class) + .values()) { + conversionService.addConversionServices(cs); } } @@ -320,6 +339,7 @@ public class RepositoryRestController public ResponseEntity listRepositories(ServletServerHttpRequest request, UriComponentsBuilder uriBuilder) throws IOException { URI baseUri = uriBuilder.build().toUri(); + BASE_URI.set(baseUri); ResourceSet resources = new ResourceSet(); for(RepositoryExporter repoExporter : repositoryExporters) { @@ -361,6 +381,7 @@ public class RepositoryRestController UriComponentsBuilder uriBuilder, @PathVariable String repository) throws IOException { URI baseUri = uriBuilder.build().toUri(); + BASE_URI.set(baseUri); RepositoryMetadata repoMeta = repositoryMetadataFor(repository); if(!repoMeta.exportsMethod(CrudMethod.FIND_ALL)) { @@ -378,8 +399,10 @@ public class RepositoryRestController } // Set page counts in the response - pr.setPaging(new PagingMetadata(page.getNumber() + 1, page.getTotalPages())); - pr.setResourceCount(page.getTotalElements()); + pr.setPaging(new PagingMetadata(page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.getTotalElements())); // Copy over parameters UriComponentsBuilder selfUri = UriComponentsBuilder.fromUri(baseUri).pathSegment(repository); @@ -470,6 +493,7 @@ public class RepositoryRestController UriComponentsBuilder uriBuilder, @PathVariable String repository) throws IOException { URI baseUri = uriBuilder.build().toUri(); + BASE_URI.set(baseUri); RepositoryMetadata repoMeta = repositoryMetadataFor(repository); ResourceSet resources = new ResourceSet(); @@ -533,6 +557,7 @@ public class RepositoryRestController IllegalAccessException, IOException { URI baseUri = uriBuilder.build().toUri(); + BASE_URI.set(baseUri); RepositoryMetadata repoMeta = repositoryMetadataFor(repository); Repository repo = repoMeta.repository(); @@ -624,8 +649,10 @@ public class RepositoryRestController // Set page counts in the response PageableResourceSet pr = new PageableResourceSet(); - pr.setResourceCount(page.getTotalElements()); - pr.setPaging(new PagingMetadata(page.getNumber() + 1, page.getTotalPages())); + pr.setPaging(new PagingMetadata(page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.getTotalElements())); // Copy over parameters UriComponentsBuilder selfUri = UriComponentsBuilder.fromUri(baseUri).pathSegment(repository, "search", query); @@ -719,6 +746,7 @@ public class RepositoryRestController UriComponentsBuilder uriBuilder, @PathVariable String repository) throws IOException { URI baseUri = uriBuilder.build().toUri(); + BASE_URI.set(baseUri); RepositoryMetadata repoMeta = repositoryMetadataFor(repository); if(!repoMeta.exportsMethod(CrudMethod.SAVE_ONE)) { @@ -783,6 +811,7 @@ public class RepositoryRestController @PathVariable String repository, @PathVariable String id) throws IOException { URI baseUri = uriBuilder.build().toUri(); + BASE_URI.set(baseUri); RepositoryMetadata repoMeta = repositoryMetadataFor(repository); if(!repoMeta.exportsMethod(CrudMethod.FIND_ONE)) { @@ -853,6 +882,7 @@ public class RepositoryRestController IllegalAccessException, InstantiationException { URI baseUri = uriBuilder.build().toUri(); + BASE_URI.set(baseUri); RepositoryMetadata repoMeta = repositoryMetadataFor(repository); if(!repoMeta.exportsMethod(CrudMethod.SAVE_ONE) || !repoMeta.exportsMethod(CrudMethod.FIND_ONE)) { @@ -993,6 +1023,7 @@ public class RepositoryRestController @PathVariable String id, @PathVariable String property) throws IOException { URI baseUri = uriBuilder.build().toUri(); + BASE_URI.set(baseUri); String accept = request.getServletRequest().getHeader("Accept"); RepositoryMetadata repoMeta = repositoryMetadataFor(repository); @@ -1025,7 +1056,6 @@ public class RepositoryRestController return negotiateResponse(request, HttpStatus.METHOD_NOT_ALLOWED, new HttpHeaders(), null); } - Object propVal; if(null == (propVal = attrMeta.get(entity))) { return notFoundResponse(request); @@ -1058,17 +1088,13 @@ public class RepositoryRestController } body = resources; } else if(propVal instanceof Map) { - Map resource = new HashMap(); + Map resource = new HashMap(); for(Map.Entry entry : ((Map)propVal).entrySet()) { String propValId = idAttr.get(entry.getValue()).toString(); URI path = buildUri(baseUri, repository, id, property, propValId); + Object oKey = entry.getKey(); - String sKey; - if(ClassUtils.isAssignable(oKey.getClass(), String.class)) { - sKey = (String)oKey; - } else { - sKey = conversionService.convert(oKey, String.class); - } + String sKey = objectToMapKey(oKey); if(shouldReturnLinks(accept)) { resource.put(sKey, new ResourceLink(propertyRel, path)); @@ -1136,6 +1162,7 @@ public class RepositoryRestController @PathVariable String id, final @PathVariable String property) throws IOException { URI baseUri = uriBuilder.build().toUri(); + BASE_URI.set(baseUri); final RepositoryMetadata repoMeta = repositoryMetadataFor(repository); if(!repoMeta.exportsMethod(CrudMethod.SAVE_ONE)) { @@ -1200,7 +1227,7 @@ public class RepositoryRestController LinkList.class); for(Link l : incomingLinks.getLinks()) { Object o; - if(null != (o = resolveTopLevelResource(baseUri, l.href().toString()))) { + if(null != (o = domainObjectResolver.resolve(baseUri, URI.create(l.href().toString())))) { rel.set(l.rel()); ResponseEntity possibleResponse = entityHandler.handle(o); if(null != possibleResponse) { @@ -1302,6 +1329,7 @@ public class RepositoryRestController @PathVariable String property, @PathVariable String linkedId) throws IOException { URI baseUri = uriBuilder.build().toUri(); + BASE_URI.set(baseUri); RepositoryMetadata repoMeta = repositoryMetadataFor(repository); if(!repoMeta.exportsMethod(CrudMethod.FIND_ONE)) { @@ -1531,10 +1559,10 @@ public class RepositoryRestController * @throws IOException */ @SuppressWarnings({"unchecked"}) - @ExceptionHandler(OptimisticLockingFailureException.class) + @ExceptionHandler({OptimisticLockingFailureException.class, DataIntegrityViolationException.class}) @ResponseBody - public ResponseEntity handleLockingFailure(OptimisticLockingFailureException ex, - ServletServerHttpRequest request) throws IOException { + public ResponseEntity handleConflict(Exception ex, + ServletServerHttpRequest request) throws IOException { LOG.error(ex.getMessage(), ex); return errorResponse(request, HttpStatus.CONFLICT, ex); } @@ -1579,15 +1607,15 @@ public class RepositoryRestController @SuppressWarnings({"unchecked"}) @ExceptionHandler({HttpMessageNotReadableException.class, HttpMessageNotWritableException.class}) @ResponseBody - public ResponseEntity handleMessageConversionFailure(HttpMessageNotReadableException ex, - ServletServerHttpRequest request) throws IOException { + public ResponseEntity handleMessageConversionFailure(Exception ex, + HttpServletRequest request) throws IOException { LOG.error(ex.getMessage(), ex); Map m = new HashMap(); m.put("message", ex.getMessage()); m.put("acceptableTypes", availableMediaTypes); - return negotiateResponse(request, HttpStatus.BAD_REQUEST, new HttpHeaders(), m); + return negotiateResponse(new ServletServerHttpRequest(request), HttpStatus.BAD_REQUEST, new HttpHeaders(), m); } /* @@ -1599,18 +1627,6 @@ public class RepositoryRestController return UriComponentsBuilder.fromUri(baseUri).pathSegment(pathSegments).build().toUri(); } - @SuppressWarnings({"unchecked"}) - private URI addSelfLink(URI baseUri, Map model, String... pathComponents) { - List links = (List)model.get(LINKS); - if(null == links) { - links = new ArrayList(); - model.put(LINKS, links); - } - URI selfUri = buildUri(baseUri, pathComponents); - links.add(new ResourceLink(SELF, selfUri)); - return selfUri; - } - @SuppressWarnings({"unchecked"}) private void maybeAddPrevNextLink(URI resourceUri, RepositoryMetadata repoMeta, @@ -1638,38 +1654,6 @@ public class RepositoryRestController } } - @SuppressWarnings({"unchecked"}) - private Object resolveTopLevelResource(URI baseUri, String uri) { - URI href = URI.create(uri); - - URI relativeUri = baseUri.relativize(href); - Stack uris = UriUtils.explode(baseUri, relativeUri); - - if(uris.size() > 1) { - String repoName = UriUtils.path(uris.get(0)); - String sId = UriUtils.path(uris.get(1)); - - RepositoryMetadata repoMeta = repositoryMetadataFor(repoName); - - CrudRepository repo; - if(null == (repo = repoMeta.repository())) { - return null; - } - - EntityMetadata entityMeta; - if(null == (entityMeta = repoMeta.entityMetadata())) { - return null; - } - - Class idType = (Class)entityMeta.idAttribute().type(); - Serializable serId = stringToSerializable(sId, idType); - - return repo.findOne(serId); - } - - return null; - } - @SuppressWarnings({"unchecked"}) private V readIncoming(HttpInputMessage request, MediaType incomingMediaType, Class targetType) throws IOException { @@ -1685,7 +1669,7 @@ public class RepositoryRestController return (V)conv.read(targetType, request); } } - return null; + return (V)mappingHttpMessageConverter.read(targetType, request); } private MapResource createResource(String repoRel, @@ -1758,6 +1742,24 @@ public class RepositoryRestController return m; } + private String objectToMapKey(Object obj) { + Assert.notNull(obj, "Map key cannot be null!"); + + RepositoryMetadata repoMeta; + String key; + if(ClassUtils.isAssignable(obj.getClass(), String.class)) { + key = (String)obj; + } else if(null != (repoMeta = repositoryMetadataFor(obj.getClass()))) { + AttributeMetadata attrMeta = repoMeta.entityMetadata().idAttribute(); + String id = attrMeta.get(obj).toString(); + key = "@" + buildUri(BASE_URI.get(), repoMeta.name(), id); + } else { + key = conversionService.convert(obj, String.class); + } + + return key; + } + private ResponseEntity notFoundResponse(ServletServerHttpRequest request) throws IOException { return negotiateResponse(request, HttpStatus.NOT_FOUND, new HttpHeaders(), null); } @@ -1804,7 +1806,7 @@ public class RepositoryRestController headers.setContentType(acceptType); if(null == converter) { - throw new HttpMessageNotWritableException("No HttpMessageConverter found to handle " + resource.getClass()); + converter = mappingHttpMessageConverter; } final ByteArrayOutputStream bout = new ByteArrayOutputStream(); 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 64c5bd863..0acaf2cbe 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 @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportResource; +import org.springframework.data.rest.repository.UriToDomainObjectResolver; import org.springframework.data.rest.repository.context.ValidatingRepositoryEventListener; import org.springframework.data.rest.repository.jpa.JpaRepositoryExporter; import org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor; @@ -80,6 +81,26 @@ public class RepositoryRestMvcConfiguration { } } + /** + * Special Repository-aware {@link org.springframework.http.converter.HttpMessageConverter} that can deal with + * entities and links. + * + * @return + */ + @Bean public RepositoryAwareMappingHttpMessageConverter mappingHttpMessageConverter() { + return new RepositoryAwareMappingHttpMessageConverter(); + } + + /** + * A {@link org.springframework.data.rest.core.Resolver} implementation that takes a {@link java.net.URI} and turns it + * into a top-level domain object. + * + * @return + */ + @Bean public UriToDomainObjectResolver domainObjectResolver() { + return new UriToDomainObjectResolver(); + } + /** * The main REST exporter Spring MVC controller. * @@ -87,8 +108,7 @@ public class RepositoryRestMvcConfiguration { * * @throws Exception */ - @Bean public RepositoryRestController repositoryRestController() - throws Exception { + @Bean public RepositoryRestController repositoryRestController() throws Exception { return new RepositoryRestController(); } diff --git a/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/DiscoverySpec.groovy b/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/DiscoverySpec.groovy index 1574f0578..1fb66b669 100644 --- a/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/DiscoverySpec.groovy +++ b/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/DiscoverySpec.groovy @@ -43,8 +43,8 @@ class DiscoverySpec extends BaseSpec { then: response.statusCode == HttpStatus.OK body.content.size() == 10 - body.page.total == 2 - body.page.current == 1 + body.page.totalPages == 2 + body.page.number == 1 } diff --git a/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/RepositoryRestControllerSpec.groovy b/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/RepositoryRestControllerSpec.groovy index 7ce8d1d18..e800db32b 100644 --- a/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/RepositoryRestControllerSpec.groovy +++ b/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/RepositoryRestControllerSpec.groovy @@ -1,11 +1,8 @@ package org.springframework.data.rest.webmvc.spec import org.codehaus.jackson.map.ObjectMapper -import org.codehaus.jackson.map.ser.CustomSerializerFactory import org.springframework.context.support.ClassPathXmlApplicationContext import org.springframework.data.domain.PageRequest - -import org.springframework.data.rest.core.util.FluentBeanSerializer import org.springframework.data.rest.test.webmvc.Address import org.springframework.data.rest.webmvc.PagingAndSorting import org.springframework.data.rest.webmvc.RepositoryRestConfiguration @@ -26,7 +23,6 @@ import spock.lang.Shared import spock.lang.Specification import javax.persistence.EntityManagerFactory -import org.springframework.data.rest.core.ResourceLink /** * @author Jon Brisbin @@ -73,10 +69,6 @@ class RepositoryRestControllerSpec extends Specification { controller = webAppCtx.getBean(RepositoryRestController) pageSort = new PagingAndSorting(RepositoryRestConfiguration.DEFAULT, new PageRequest(0, 1000)) uriBuilder = UriComponentsBuilder.fromUriString("http://localhost:8080/data") - - def customSerializerFactory = new CustomSerializerFactory() - customSerializerFactory.addSpecificMapping(ResourceLink, new FluentBeanSerializer(ResourceLink)) - mapper.setSerializerFactory(customSerializerFactory) } def setup() { diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/WebCustomer.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/WebCustomer.java index 0aaf2b731..8375c6a86 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/WebCustomer.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/WebCustomer.java @@ -1,6 +1,8 @@ package org.springframework.data.rest.test.webmvc; +import java.util.Map; import javax.persistence.Entity; +import javax.persistence.OneToMany; /** * @author Jon Brisbin @@ -8,7 +10,9 @@ import javax.persistence.Entity; @Entity public class WebCustomer extends Customer { - private String username; + private String username; + @OneToMany + private Map people; public String getUsername() { return username; @@ -18,4 +22,12 @@ public class WebCustomer extends Customer { this.username = username; } + public Map getPeople() { + return people; + } + + public void setPeople(Map people) { + this.people = people; + } + }