From d65179ccc83de04e4e4e09d4b42dd392d4cd311d Mon Sep 17 00:00:00 2001 From: Greg Turnquist Date: Tue, 14 Jan 2014 13:23:47 -0600 Subject: [PATCH] DATAREST-95 - Add support for PATCH to partially update entities. Added general support for HTTP PATCH to partially update entities and amend property resources. --- .gitignore | 3 +- .../rest/core/support/DomainObjectMerger.java | 17 +++++++- .../core/support/DomainObjectMergerTests.java | 4 +- .../webmvc/RepositoryEntityController.java | 41 ++++++++++++++++--- .../webmvc/AbstractWebIntegrationTests.java | 14 +++++++ .../data/rest/webmvc/jpa/JpaWebTests.java | 31 ++++++++++++++ 6 files changed, 100 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 4434ad519..ab206b9ea 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ target/ .project .settings *.log -vf.gf.* \ No newline at end of file +vf.gf.* +out/ diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/support/DomainObjectMerger.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/support/DomainObjectMerger.java index 2a75c2f7e..28f7631be 100644 --- a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/support/DomainObjectMerger.java +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/support/DomainObjectMerger.java @@ -61,7 +61,7 @@ public class DomainObjectMerger { * @param from can be {@literal null}. * @param target can be {@literal null}. */ - public void merge(Object from, Object target) { + public void merge(Object from, Object target, final MergeNullPolicy nullPolicy) { if (null == from || null == target) { return; @@ -88,7 +88,9 @@ public class DomainObjectMerger { } if (!ObjectUtils.nullSafeEquals(sourceValue, targetValue)) { - targetWrapper.setProperty(persistentProperty, sourceValue); + if (nullPolicy == MergeNullPolicy.APPLY_NULLS || sourceValue != null) { + targetWrapper.setProperty(persistentProperty, sourceValue); + } } } }); @@ -110,4 +112,15 @@ public class DomainObjectMerger { } }); } + + /** + * A switch on whether or not to ignore nulls. + * NOTE: This could have been a simple boolean flag but the enumerated value clearly + * denotes which version is being used. + */ + public static enum MergeNullPolicy { + APPLY_NULLS, IGNORE_NULLS; + } + + } diff --git a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/support/DomainObjectMergerTests.java b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/support/DomainObjectMergerTests.java index 730b16556..2bdfddd49 100644 --- a/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/support/DomainObjectMergerTests.java +++ b/spring-data-rest-core/src/test/java/org/springframework/data/rest/core/support/DomainObjectMergerTests.java @@ -57,7 +57,7 @@ public class DomainObjectMergerTests { Person existingDomainObject = new Person("Frodo", "Baggins"); DomainObjectMerger merger = new DomainObjectMerger(repositories, conversionService); - merger.merge(incoming, existingDomainObject); + merger.merge(incoming, existingDomainObject, DomainObjectMerger.MergeNullPolicy.APPLY_NULLS); assertThat(existingDomainObject.getFirstName(), equalTo(incoming.getFirstName())); assertThat(existingDomainObject.getLastName(), equalTo(incoming.getLastName())); @@ -76,7 +76,7 @@ public class DomainObjectMergerTests { Person existingDomainObject = new Person("Frodo", "Baggins"); DomainObjectMerger merger = new DomainObjectMerger(repositories, conversionService); - merger.merge(incoming, existingDomainObject); + merger.merge(incoming, existingDomainObject, DomainObjectMerger.MergeNullPolicy.APPLY_NULLS); assertThat(existingDomainObject.getFirstName(), equalTo(incoming.getFirstName())); assertThat(existingDomainObject.getLastName(), equalTo(incoming.getLastName())); diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java index b85622383..45e1e300a 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java @@ -17,11 +17,15 @@ package org.springframework.data.rest.webmvc; import static org.springframework.data.rest.webmvc.ControllerUtils.*; +import java.io.IOException; import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.ApplicationEventPublisher; @@ -57,10 +61,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.HttpRequestMethodNotSupportedException; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.*; /** * @author Jon Brisbin @@ -238,7 +239,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem return createNewEntity(resourceInformation, incoming); } - domainObjectMerger.merge(incoming.getContent(), domainObj); + domainObjectMerger.merge(incoming.getContent(), domainObj, DomainObjectMerger.MergeNullPolicy.APPLY_NULLS); publisher.publishEvent(new BeforeSaveEvent(incoming.getContent())); Object obj = invoker.invokeSave(domainObj); @@ -255,6 +256,36 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem } } + @RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.PATCH, consumes = { "application/json" }) + public ResponseEntity patchEntity( + RepositoryRestRequest request, PersistentEntityResource incoming, + @PathVariable String id) { + + RepositoryInvoker invoker = request.getRepositoryInvoker(); + if (null == invoker || !invoker.exposesSave() || !invoker.exposesFindOne()) { + return new ResponseEntity>(HttpStatus.METHOD_NOT_ALLOWED); + } + + Object domainObj = converter.convert(id, STRING_TYPE, + TypeDescriptor.valueOf(request.getPersistentEntity().getType())); + if (null == domainObj) { + return new ResponseEntity>(HttpStatus.NOT_FOUND); + } + + domainObjectMerger.merge(incoming.getContent(), domainObj, DomainObjectMerger.MergeNullPolicy.IGNORE_NULLS); + + publisher.publishEvent(new BeforeSaveEvent(domainObj)); + Object obj = invoker.invokeSave(domainObj); + publisher.publishEvent(new AfterSaveEvent(domainObj)); + + if (config.isReturnBodyOnUpdate()) { + return ControllerUtils.toResponseEntity(HttpStatus.OK, null, perAssembler.toResource(obj)); + } else { + return ControllerUtils.toEmptyResponse(HttpStatus.NO_CONTENT); + } + + } + @RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.DELETE) public ResponseEntity deleteEntity(final RootResourceInformation resourceInformation, @PathVariable final String id) throws ResourceNotFoundException, HttpRequestMethodNotSupportedException { diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractWebIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractWebIntegrationTests.java index 141116e73..6217a9d2b 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractWebIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/AbstractWebIntegrationTests.java @@ -37,6 +37,7 @@ import org.springframework.hateoas.Link; import org.springframework.hateoas.LinkDiscoverer; import org.springframework.hateoas.LinkDiscoverers; import org.springframework.hateoas.MediaTypes; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.context.ContextConfiguration; @@ -47,6 +48,7 @@ import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -172,6 +174,18 @@ public abstract class AbstractWebIntegrationTests { return request(response.getHeader("Location")); } + protected MockHttpServletResponse patchAndGet(Link link, Object payload, MediaType mediaType) throws Exception { + + String href = link.isTemplated() ? link.expand().getHref() : link.getHref(); + + MockHttpServletResponse response = mvc.perform(MockMvcRequestBuilders.request(HttpMethod.PATCH, href).// + content(payload.toString()).contentType(mediaType)).// + andExpect(status().isNoContent()).// + andReturn().getResponse(); + + return request(href); + } + protected MockHttpServletResponse deleteAndGet(Link link, MediaType mediaType) throws Exception { String href = link.isTemplated() ? link.expand().getHref() : link.getHref(); diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java index f02278e97..9c8725e69 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.Scanner; +import com.jayway.jsonpath.JsonPath; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -34,9 +35,11 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.webmvc.AbstractWebIntegrationTests; import org.springframework.hateoas.Link; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -176,6 +179,34 @@ public class JpaWebTests extends AbstractWebIntegrationTests { ).andExpect(status().isCreated()); } + /** + * @see DATAREST-95 + */ + @Test + public void createThenPatch() throws Exception { + + MockHttpServletResponse bilbo = postAndGet(new Link("/people/"),// + "{ \"firstName\" : \"Bilbo\", \"lastName\" : \"Baggins\" }",// + MediaType.APPLICATION_JSON); + + Link bilboLink = assertHasLinkWithRel("self", bilbo); + + assertThat((String) JsonPath.read(bilbo.getContentAsString(), "$.firstName"), + equalTo("Bilbo")); + assertThat((String) JsonPath.read(bilbo.getContentAsString(), "$.lastName"), + equalTo("Baggins")); + + MockHttpServletResponse frodo = patchAndGet(bilboLink,// + "{ \"firstName\" : \"Frodo\" }",// + MediaType.APPLICATION_JSON); + + assertThat((String) JsonPath.read(frodo.getContentAsString(), "$.firstName"), + equalTo("Frodo")); + assertThat((String) JsonPath.read(frodo.getContentAsString(), "$.lastName"), + equalTo("Baggins")); + } + + @Test public void listsSiblingsWithContentCorrectly() throws Exception {