diff --git a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryExporter.java b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryExporter.java index 92ae7d6c5..5feb1c981 100644 --- a/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryExporter.java +++ b/spring-data-rest-repository/src/main/java/org/springframework/data/rest/repository/RepositoryExporter.java @@ -26,8 +26,9 @@ public abstract class RepositoryExporter, E exte protected ApplicationContext applicationContext; protected Repositories repositories; - protected List exportOnlyTheseClasses = Collections.emptyList(); - protected Map repositoryMetadata; + protected Map repositoryMetadata; + protected List exportOnlyTheseClasses = Collections.emptyList(); + protected Map, Class> domainTypeMappings = new HashMap, Class>(); /** * Get the list of class names of Repositories to export. @@ -53,6 +54,16 @@ public abstract class RepositoryExporter, E exte return (M)this; } + public Map, Class> getDomainTypeMappings() { + return domainTypeMappings; + } + + @SuppressWarnings({"unchecked"}) + public M setDomainTypeMappings(Map, Class> domainTypeMappings) { + this.domainTypeMappings = domainTypeMappings; + return (M)this; + } + @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { this.applicationContext = applicationContext; @@ -100,6 +111,22 @@ public abstract class RepositoryExporter, E exte */ public M repositoryMetadataFor(Class domainType) { refresh(); + // Look for an exact match + for(M repoMeta : repositoryMetadata.values()) { + if(repoMeta.domainType() == domainType) { + return repoMeta; + } + } + // Didn't find an exact match, look for domain type mapping + Class repoClass = domainTypeMappings.get(domainType); + if(null != repoClass) { + for(M repoMeta : repositoryMetadata.values()) { + if(repoMeta.repositoryClass() == repoClass) { + return repoMeta; + } + } + } + // Didn't find a mapping, look for a superclass for(M repoMeta : repositoryMetadata.values()) { if(repoMeta.domainType().isAssignableFrom(domainType)) { return repoMeta; 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 6c3c2e23d..a66b7c1e2 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 @@ -42,8 +42,10 @@ 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.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; @@ -101,14 +103,7 @@ public class RepositoryAwareMappingHttpMessageConverter @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(); - - } - } + this.mapper.registerModule(new RepositoryAwareModule()); return this; } @@ -174,6 +169,16 @@ public class RepositoryAwareMappingHttpMessageConverter } } + @Override protected Object readInternal(Class clazz, + HttpInputMessage inputMessage) throws IOException, + HttpMessageNotReadableException { + try { + return mapper.readValue(inputMessage.getBody(), clazz); + } catch(IOException ex) { + throw new HttpMessageNotReadableException("Could not read JSON: " + ex.getMessage(), ex); + } + } + @SuppressWarnings({"unchecked"}) private RepositoryMetadata repositoryMetadataFor(Class domainType) { for(RepositoryExporter repoExp : repositoryExporters) { @@ -259,9 +264,9 @@ public class RepositoryAwareMappingHttpMessageConverter } String rel = repoMeta.rel() + "." + repoMeta.domainType().getSimpleName(); - URI href = buildUri(RepositoryRestController.BASE_URI.get(), repoMeta.name(), sId); + URI selfUri = buildUri(RepositoryRestController.BASE_URI.get(), repoMeta.name(), sId); - jgen.writeObject(new ResourceLink(rel, href)); + jgen.writeObject(new ResourceLink(rel, selfUri)); } } @@ -320,89 +325,120 @@ public class RepositoryAwareMappingHttpMessageConverter throw ctxt.instantiationException(getValueClass(), e); } - for(JsonToken tok = jp.getCurrentToken(); tok != JsonToken.END_OBJECT; tok = jp.nextToken()) { + 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)) ); - } else if("href".equals(name)) { + continue; + } + + 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(); - } + continue; + } - if(jp.getCurrentToken() == JsonToken.START_ARRAY) { - while((tok = jp.nextToken()) != JsonToken.END_ARRAY) { - Object cval = jp.readValueAs(attrMeta.elementType()); - c.add(cval); - } - } + 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(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); - } - } + } 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(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()); + } 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(); + } - m.put(mkey, mval); - } - } + 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; } } 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 8abdc8d49..ac47bcd49 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 @@ -1112,7 +1112,7 @@ public class RepositoryRestController body = new MapResource(resource); } else { String propValId = idAttr.get(propVal).toString(); - URI path = buildUri(baseUri, repository, id, property, propValId); + URI path = buildUri(baseUri, repository, id, property); URI selfUri = buildUri(baseUri, propRepoMeta.name(), propValId); if(shouldReturnLinks(accept)) { Resource resource = new Resource(); @@ -1122,7 +1122,7 @@ public class RepositoryRestController MapResource res = createResource(propRepoMeta.rel(), propVal, propRepoMeta.entityMetadata(), - buildUri(baseUri, propRepoMeta.name(), propValId)); + selfUri); res.addLink(new ResourceLink(propertyRel, path)); res.addLink(new ResourceLink(SELF, selfUri)); body = res; @@ -1214,6 +1214,14 @@ public class RepositoryRestController m.put(rel.get(), linkedEntity); attrMeta.set(m, entity); } else { + // Don't support POST when it's a single value + if(request.getMethod() == HttpMethod.POST) { + try { + return negotiateResponse(request, HttpStatus.METHOD_NOT_ALLOWED, new HttpHeaders(), null); + } catch(IOException e) { + throw new IllegalStateException(e.getMessage(), e); + } + } attrMeta.set(linkedEntity, entity); } diff --git a/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/BaseSpec.groovy b/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/BaseSpec.groovy index 2d9212618..b29abd224 100644 --- a/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/BaseSpec.groovy +++ b/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/BaseSpec.groovy @@ -6,6 +6,7 @@ import org.springframework.context.ApplicationContext import org.springframework.data.rest.test.webmvc.Address import org.springframework.data.rest.test.webmvc.AddressRepository import org.springframework.data.rest.test.webmvc.ApplicationConfig +import org.springframework.data.rest.test.webmvc.CustomerRepository import org.springframework.data.rest.test.webmvc.Person import org.springframework.data.rest.test.webmvc.PersonRepository import org.springframework.data.rest.test.webmvc.TestRepositoryEventListener @@ -36,6 +37,7 @@ abstract class BaseSpec extends Specification { @Autowired EntityManagerFactory emf @Autowired PersonRepository people @Autowired AddressRepository addresses + @Autowired CustomerRepository customers UriComponentsBuilder baseUri ObjectMapper mapper = new ObjectMapper() 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 1fb66b669..8db8dc7de 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 @@ -25,7 +25,7 @@ class DiscoverySpec extends BaseSpec { def links = readJson(response).links then: - links.size() == 5 + links.size() == 6 } diff --git a/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/NestedObjectSpec.groovy b/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/NestedObjectSpec.groovy new file mode 100644 index 000000000..1e2b3ff48 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/groovy/org/springframework/data/rest/webmvc/spec/NestedObjectSpec.groovy @@ -0,0 +1,39 @@ +package org.springframework.data.rest.webmvc.spec + +import org.springframework.data.rest.test.webmvc.Customer +import org.springframework.http.HttpStatus + +/** + * @author Jon Brisbin + */ +class NestedObjectSpec extends BaseSpec { + + def "saves nested object"() { + + given: + def customer = customers.save(new Customer(userid: "jdoe")) + def jsonObj = [ + "customers": [ + ["rel": "customer.Customer", "href": "http://localhost:8080/data/customer/" + customer.id] + ] + ] + def request = createJsonRequest("PUT", "customerTracker/1", null, jsonObj) + def getReq = createRequest("GET", "customerTracker/1/customers", null) + + when: + def response = controller.create(request, baseUri, "customerTracker") + + then: + response.statusCode == HttpStatus.CREATED + + when: + def getResp = controller.propertyOfEntity(getReq, baseUri, "customerTracker", "1", "customers") + def jsonResp = readJson(getResp) + + then: + getResp.statusCode == HttpStatus.OK + jsonResp.content[0].userid == "jdoe" + + } + +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/Customer.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/Customer.java index c8c07bb50..040c7bcbc 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/Customer.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/Customer.java @@ -1,29 +1,29 @@ package org.springframework.data.rest.test.webmvc; +import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; -import javax.persistence.MappedSuperclass; -import javax.persistence.OneToOne; /** * @author Jon Brisbin */ -@MappedSuperclass +@Entity public class Customer { @Id @GeneratedValue private Long id; - @OneToOne private Person person; + private String userid; public Long getId() { return id; } - public Person getPerson() { - return person; + public String getUserid() { + return userid; } - public void setPerson(Person person) { - this.person = person; + public Customer setUserid(String userid) { + this.userid = userid; + return this; } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/WebCustomerRepository.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/CustomerRepository.java similarity index 63% rename from spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/WebCustomerRepository.java rename to spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/CustomerRepository.java index a3aa49640..b93b46554 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/WebCustomerRepository.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/CustomerRepository.java @@ -5,5 +5,5 @@ import org.springframework.data.repository.CrudRepository; /** * @author Jon Brisbin */ -public interface WebCustomerRepository extends CrudRepository { +public interface CustomerRepository extends CrudRepository { } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/CustomerTracker.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/CustomerTracker.java new file mode 100644 index 000000000..cea10c78c --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/CustomerTracker.java @@ -0,0 +1,34 @@ +package org.springframework.data.rest.test.webmvc; + +import java.util.Collections; +import java.util.List; +import javax.persistence.CascadeType; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.OneToMany; + +/** + * @author Jon Brisbin + */ +@Entity +public class CustomerTracker { + + @Id @GeneratedValue private Long id; + @OneToMany(cascade = CascadeType.REMOVE, orphanRemoval = true) + private List customers = Collections.emptyList(); + + public Long getId() { + return id; + } + + public List getCustomers() { + return customers; + } + + public CustomerTracker setCustomers(List customers) { + this.customers = customers; + return this; + } + +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/CustomerTrackerRepository.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/CustomerTrackerRepository.java new file mode 100644 index 000000000..6ca8e6a0c --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/CustomerTrackerRepository.java @@ -0,0 +1,9 @@ +package org.springframework.data.rest.test.webmvc; + +import org.springframework.data.repository.CrudRepository; + +/** + * @author Jon Brisbin + */ +public interface CustomerTrackerRepository extends CrudRepository { +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/Person.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/Person.java index f072d3427..5c8f9427b 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/Person.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/Person.java @@ -6,6 +6,7 @@ import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.MapKey; +import javax.persistence.MappedSuperclass; import javax.persistence.OneToMany; import javax.persistence.Version; 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 deleted file mode 100644 index 8375c6a86..000000000 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/test/webmvc/WebCustomer.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.springframework.data.rest.test.webmvc; - -import java.util.Map; -import javax.persistence.Entity; -import javax.persistence.OneToMany; - -/** - * @author Jon Brisbin - */ -@Entity -public class WebCustomer extends Customer { - - private String username; - @OneToMany - private Map people; - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - - public Map getPeople() { - return people; - } - - public void setPeople(Map people) { - this.people = people; - } - -}