diff --git a/spring-data-rest-webmvc/pom.xml b/spring-data-rest-webmvc/pom.xml index 131f9b826..2d8008880 100644 --- a/spring-data-rest-webmvc/pom.xml +++ b/spring-data-rest-webmvc/pom.xml @@ -92,7 +92,15 @@ ${jackson} true - + + + + + com.github.fge + json-patch + 1.7 + + diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/IncomingRequest.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/IncomingRequest.java new file mode 100644 index 000000000..9aa801cae --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/IncomingRequest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.rest.webmvc; + +import java.io.IOException; +import java.io.InputStream; + +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.util.Assert; + +/** + * Value object to wrap a {@link ServerHttpRequest} to provide a slightly more abstract API to find out about the + * request method. + * + * @author Oliver Gierke + */ +public class IncomingRequest { + + private final ServerHttpRequest request; + private final MediaType contentType; + + /** + * Creates a new {@link IncomingRequest} from {@link ServerHttpRequest}. + * + * @param request must not be {@literal null}. + */ + public IncomingRequest(ServerHttpRequest request) { + + Assert.notNull(request, "ServerHttpRequest must not be null!"); + + this.request = request; + this.contentType = request.getHeaders().getContentType(); + } + + /** + * Returns whether the request is a PATCH request. + * + * @return + */ + public boolean isPatchRequest() { + return request.getMethod().equals(HttpMethod.PATCH); + } + + /** + * Returns whether the request is a PATCH request with a payload of type {@link RestMediaTypes#JSON_PATCH_JSON}. + * + * @return + */ + public boolean isJsonPatchRequest() { + return isPatchRequest() && RestMediaTypes.JSON_PATCH_JSON.equals(contentType); + } + + /** + * Returns whether the request is a PATCH request with a payload of type {@link RestMediaTypes#MERGE_PATCH_JSON}. + * + * @return + */ + public boolean isJsonMergePatchRequest() { + return isPatchRequest() && RestMediaTypes.MERGE_PATCH_JSON.equals(contentType); + } + + /** + * Returns the body of the request. + * + * @return will never be {@literal null}. + * @throws IOException + */ + public InputStream getBody() throws IOException { + return request.getBody(); + } + + /** + * Returns the underlying {@link ServerHttpRequest}. + * + * @return will never be {@literal null}. + */ + public ServerHttpRequest getServerHttpRequest() { + return request; + } +} 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 0c3c61310..bc26c573f 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 @@ -15,7 +15,6 @@ */ package org.springframework.data.rest.webmvc; -import static org.springframework.data.rest.core.support.DomainObjectMerger.NullHandlingPolicy.*; import static org.springframework.http.HttpMethod.*; import java.io.Serializable; @@ -41,8 +40,6 @@ import org.springframework.data.rest.core.event.BeforeSaveEvent; import org.springframework.data.rest.core.invoke.RepositoryInvoker; import org.springframework.data.rest.core.mapping.ResourceMetadata; import org.springframework.data.rest.core.mapping.SearchResourceMappings; -import org.springframework.data.rest.core.support.DomainObjectMerger; -import org.springframework.data.rest.core.support.DomainObjectMerger.NullHandlingPolicy; import org.springframework.data.rest.webmvc.support.BackendId; import org.springframework.data.rest.webmvc.support.DefaultedPageable; import org.springframework.data.rest.webmvc.support.RepositoryEntityLinks; @@ -75,21 +72,19 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem private final RepositoryEntityLinks entityLinks; private final RepositoryRestConfiguration config; private final ConversionService conversionService; - private final DomainObjectMerger domainObjectMerger; private ApplicationEventPublisher publisher; @Autowired public RepositoryEntityController(Repositories repositories, RepositoryRestConfiguration config, RepositoryEntityLinks entityLinks, PagedResourcesAssembler assembler, - @Qualifier("defaultConversionService") ConversionService conversionService, DomainObjectMerger domainObjectMerger) { + @Qualifier("defaultConversionService") ConversionService conversionService) { super(assembler); this.entityLinks = entityLinks; this.config = config; this.conversionService = conversionService; - this.domainObjectMerger = domainObjectMerger; } /* @@ -305,18 +300,15 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem resourceInformation.verifySupportedMethod(HttpMethod.PUT, ResourceType.ITEM); - Object domainObject = conversionService.convert(id, resourceInformation.getDomainType()); + // Force ID on unmarshalled object + BeanWrapper incomingWrapper = BeanWrapper.create(payload.getContent(), conversionService); + incomingWrapper.setProperty(payload.getPersistentEntity().getIdProperty(), id); + RepositoryInvoker invoker = resourceInformation.getInvoker(); + Object objectToSave = incomingWrapper.getBean(); - if (domainObject == null) { - - BeanWrapper incomingWrapper = BeanWrapper.create(payload.getContent(), conversionService); - incomingWrapper.setProperty(payload.getPersistentEntity().getIdProperty(), id); - - return createAndReturn(incomingWrapper.getBean(), invoker, assembler); - } - - return mergeAndReturn(payload.getContent(), domainObject, invoker, PUT, assembler); + return invoker.invokeFindOne(id) == null ? createAndReturn(objectToSave, invoker, assembler) : saveAndReturn( + objectToSave, invoker, PUT, assembler); } /** @@ -336,13 +328,11 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem resourceInformation.verifySupportedMethod(HttpMethod.PATCH, ResourceType.ITEM); - Object domainObject = conversionService.convert(id, resourceInformation.getDomainType()); - - if (domainObject == null) { + if (resourceInformation.getInvoker().invokeFindOne(id) == null) { throw new ResourceNotFoundException(); } - return mergeAndReturn(payload.getContent(), domainObject, resourceInformation.getInvoker(), PATCH, assembler); + return saveAndReturn(payload.getContent(), resourceInformation.getInvoker(), PATCH, assembler); } /** @@ -379,11 +369,8 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem * @param httpMethod * @return */ - private ResponseEntity mergeAndReturn(Object incoming, Object domainObject, - RepositoryInvoker invoker, HttpMethod httpMethod, PersistentEntityResourceAssembler assembler) { - - NullHandlingPolicy nullPolicy = httpMethod.equals(PATCH) ? IGNORE_NULLS : APPLY_NULLS; - domainObjectMerger.merge(incoming, domainObject, nullPolicy); + private ResponseEntity saveAndReturn(Object domainObject, RepositoryInvoker invoker, + HttpMethod httpMethod, PersistentEntityResourceAssembler assembler) { publisher.publishEvent(new BeforeSaveEvent(domainObject)); Object obj = invoker.invokeSave(domainObject); diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java index 369cf7c84..d0644ceed 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySchemaController.java @@ -60,7 +60,7 @@ class RepositorySchemaController { @RequestMapping(value = BASE_MAPPING + "/schema", method = GET, produces = { "application/schema+json" }) public HttpEntity schema(RootResourceInformation resourceInformation) { - JsonSchema schema = jsonSchemaConverter.convert(resourceInformation.getPersistentEntity().getType()); + JsonSchema schema = jsonSchemaConverter.convert(resourceInformation.getDomainType()); return new ResponseEntity(schema, HttpStatus.OK); } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RestMediaTypes.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RestMediaTypes.java new file mode 100644 index 000000000..7afe1fc16 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RestMediaTypes.java @@ -0,0 +1,37 @@ +/* + * Copyright 2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.rest.webmvc; + +import org.springframework.hateoas.MediaTypes; +import org.springframework.http.MediaType; + +/** + * Constants to refer to supported media types. + * + * @author Oliver Gierke + */ +public class RestMediaTypes { + + public static final MediaType HAL_JSON = MediaTypes.HAL_JSON; + + public static final MediaType JSON_PATCH_JSON = MediaType.valueOf("application/json-patch+json"); + public static final MediaType MERGE_PATCH_JSON = MediaType.valueOf("application/merge-patch+json"); + + public static final MediaType SCHEMA_JSON = MediaType.valueOf("application/schema+json"); + + public static final MediaType SPRING_DATA_VERBOSE_JSON = MediaType.valueOf("application/x-spring-data-verbose+json"); + public static final MediaType SPRING_DATA_COMPACT_JSON = MediaType.valueOf("application/x-spring-data-compact+json"); +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/JsonPatchHandler.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/JsonPatchHandler.java new file mode 100644 index 000000000..c79603fe0 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/JsonPatchHandler.java @@ -0,0 +1,131 @@ +/* + * Copyright 2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.rest.webmvc.config; + +import java.io.InputStream; +import java.lang.reflect.Field; +import java.util.List; + +import org.springframework.data.rest.webmvc.IncomingRequest; +import org.springframework.data.rest.webmvc.json.DomainObjectReader; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.type.CollectionType; +import com.github.fge.jackson.jsonpointer.JsonPointer; +import com.github.fge.jsonpatch.JsonPatchOperation; +import com.github.fge.jsonpatch.RemoveOperation; +import com.github.fge.jsonpatch.ReplaceOperation; + +/** + * Component to apply JSON Patch and JSON Merge Patch payloads to existing domain objects. The implementation uses the + * JSON Patch library by Francis Galiegue but applies a customization to remove operations. As the patched node set + * needs to be applied to the existing domain objects (see {@link DomainObjectReader} for that) we turn remove calls + * into + * + * @author Oliver Gierke + * @see http://tools.ietf.org/html/rfc6902 + * @see http://tools.ietf.org/html/draft-ietf-appsawg-json-merge-patch-02 + */ +class JsonPatchHandler { + + private static final Field PATH_FIELD; + + static { + + Field field = ReflectionUtils.findField(JsonPatchOperation.class, "path"); + ReflectionUtils.makeAccessible(field); + + PATH_FIELD = field; + } + + private final ObjectMapper mapper; + private final ObjectMapper sourceMapper; + private final DomainObjectReader reader; + + /** + * Creates a new {@link JsonPatchHandler} with the given {@link ObjectMapper} and {@link DomainObjectReader}. + * + * @param mapper must not be {@literal null}. + * @param reader must not be {@literal null}. + */ + public JsonPatchHandler(ObjectMapper mapper, DomainObjectReader reader) { + + Assert.notNull(mapper, "ObjectMapper must not be null!"); + Assert.notNull(reader, "DomainObjectReader must not be null!"); + + this.mapper = mapper; + this.reader = reader; + + this.sourceMapper = mapper.copy(); + this.sourceMapper.setSerializationInclusion(Include.NON_NULL); + } + + /** + * Applies the body of the given {@link IncomingRequest} as patch on the given target object. + * + * @param request must not be {@literal null}. + * @param target must not be {@literal null}. + * @return + * @throws Exception + */ + public T apply(IncomingRequest request, T target) throws Exception { + + Assert.notNull(request, "Request must not be null!"); + Assert.isTrue(request.isPatchRequest(), "Cannot handle non-PATCH request!"); + Assert.notNull(target, "Target must not be null!"); + + if (request.isJsonPatchRequest()) { + return applyPatch(request.getBody(), target); + } else { + return applyMergePatch(request.getBody(), target); + } + } + + T applyPatch(InputStream source, T target) throws Exception { + + CollectionType listOfOperationsType = mapper.getTypeFactory().constructCollectionType(List.class, + JsonPatchOperation.class); + List readValue = mapper.readValue(source, listOfOperationsType); + + JsonNode existingAsNode = mapper.readTree(sourceMapper.writeValueAsBytes(target)); + JsonNode patchedNode = existingAsNode; + + for (JsonPatchOperation operation : readValue) { + + if (operation instanceof RemoveOperation) { + + // Replace remove operation with replace operation and a value of null. + JsonPointer path = (JsonPointer) ReflectionUtils.getField(PATH_FIELD, operation); + patchedNode = new ReplaceOperation(path, NullNode.getInstance()).apply(patchedNode); + + } else { + patchedNode = operation.apply(patchedNode); + } + } + + return reader.merge((ObjectNode) patchedNode, target, mapper); + } + + T applyMergePatch(InputStream source, T existingObject) throws Exception { + return reader.read(source, existingObject, mapper); + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java index 8f0411812..395cc7afc 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/PersistentEntityResourceHandlerMethodArgumentResolver.java @@ -15,16 +15,25 @@ */ package org.springframework.data.rest.webmvc.config; +import java.io.IOException; +import java.io.Serializable; import java.util.List; import javax.servlet.http.HttpServletRequest; import org.springframework.core.MethodParameter; +import org.springframework.data.rest.core.invoke.RepositoryInvoker; +import org.springframework.data.rest.webmvc.IncomingRequest; import org.springframework.data.rest.webmvc.PersistentEntityResource; +import org.springframework.data.rest.webmvc.ResourceNotFoundException; import org.springframework.data.rest.webmvc.RootResourceInformation; +import org.springframework.data.rest.webmvc.json.DomainObjectReader; +import org.springframework.data.rest.webmvc.support.BackendIdHandlerMethodArgumentResolver; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.util.Assert; import org.springframework.web.bind.support.WebDataBinderFactory; @@ -32,6 +41,8 @@ import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; +import com.fasterxml.jackson.databind.ObjectMapper; + /** * Custom {@link HandlerMethodArgumentResolver} to create {@link PersistentEntityResource} instances. * @@ -40,10 +51,13 @@ import org.springframework.web.method.support.ModelAndViewContainer; */ public class PersistentEntityResourceHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver { - private static final String ERROR_MESSAGE = "Could not read an object of type %s from the request! Converter %s returned null!"; + private static final String ERROR_MESSAGE = "Could not read an object of type %s from the request!"; private static final String NO_CONVERTER_FOUND = "No suitable HttpMessageConverter found to read request body into object of type %s from request with content type of %s!"; - private final RootResourceInformationHandlerMethodArgumentResolver repoRequestResolver; + private final RootResourceInformationHandlerMethodArgumentResolver resourceInformationResolver; + + private final BackendIdHandlerMethodArgumentResolver idResolver; + private final DomainObjectReader reader; private final List> messageConverters; /** @@ -51,17 +65,24 @@ public class PersistentEntityResourceHandlerMethodArgumentResolver implements Ha * {@link HttpMessageConverter}s and {@link RootResourceInformationHandlerMethodArgumentResolver}.. * * @param messageConverters must not be {@literal null}. - * @param repositoryRequestResolver must not be {@literal null}. + * @param resourceInformationResolver must not be {@literal null}. + * @param idResolver must not be {@literal null}. + * @param reader must not be {@literal null}. */ public PersistentEntityResourceHandlerMethodArgumentResolver(List> messageConverters, - RootResourceInformationHandlerMethodArgumentResolver repositoryRequestResolver) { + RootResourceInformationHandlerMethodArgumentResolver resourceInformationResolver, + BackendIdHandlerMethodArgumentResolver idResolver, DomainObjectReader reader) { Assert.notEmpty(messageConverters, "MessageConverters must not be null or empty!"); - Assert - .notNull(repositoryRequestResolver, "RootResourceInformationHandlerMethodArgumentResolver must not be empty!"); + Assert.notNull(resourceInformationResolver, + "RootResourceInformationHandlerMethodArgumentResolver must not be empty!"); + Assert.notNull(idResolver, "BackendIdHandlerMethodArgumentResolver must not be null!"); + Assert.notNull(reader, "DomainObjectReader must not be null!"); this.messageConverters = messageConverters; - this.repoRequestResolver = repositoryRequestResolver; + this.resourceInformationResolver = resourceInformationResolver; + this.idResolver = idResolver; + this.reader = reader; } /* @@ -82,13 +103,14 @@ public class PersistentEntityResourceHandlerMethodArgumentResolver implements Ha public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - RootResourceInformation resourceInformation = repoRequestResolver.resolveArgument(parameter, mavContainer, + RootResourceInformation resourceInformation = resourceInformationResolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory); HttpServletRequest nativeRequest = webRequest.getNativeRequest(HttpServletRequest.class); ServletServerHttpRequest request = new ServletServerHttpRequest(nativeRequest); + IncomingRequest incoming = new IncomingRequest(request); - Class domainType = resourceInformation.getPersistentEntity().getType(); + Class domainType = resourceInformation.getDomainType(); MediaType contentType = request.getHeaders().getContentType(); for (HttpMessageConverter converter : messageConverters) { @@ -97,10 +119,11 @@ public class PersistentEntityResourceHandlerMethodArgumentResolver implements Ha continue; } - Object obj = converter.read(domainType, request); + Serializable id = idResolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory); + Object obj = read(resourceInformation, incoming, converter, id); if (obj == null) { - throw new HttpMessageNotReadableException(String.format(ERROR_MESSAGE, domainType, converter)); + throw new HttpMessageNotReadableException(String.format(ERROR_MESSAGE, domainType)); } return PersistentEntityResource.build(obj, resourceInformation.getPersistentEntity()).build(); @@ -108,4 +131,61 @@ public class PersistentEntityResourceHandlerMethodArgumentResolver implements Ha throw new HttpMessageNotReadableException(String.format(NO_CONVERTER_FOUND, domainType, contentType)); } + + /** + * Reads the given {@link ServerHttpRequest} into an object of the type of the given {@link RootResourceInformation}, + * potentially applying the content to an object of the given id. + * + * @param information + * @param request + * @param converter + * @param id + * @return + * @throws IOException + */ + private Object read(RootResourceInformation information, IncomingRequest request, + HttpMessageConverter converter, Serializable id) { + + if (request.isPatchRequest() && converter instanceof MappingJackson2HttpMessageConverter) { + + if (id == null) { + new ResourceNotFoundException(); + } + + RepositoryInvoker invoker = information.getInvoker(); + Object existingObject = invoker.invokeFindOne(id); + + if (existingObject == null) { + throw new ResourceNotFoundException(); + } + + ObjectMapper mapper = ((MappingJackson2HttpMessageConverter) converter).getObjectMapper(); + Object result = readPatch(request, mapper, existingObject); + + return result; + } + + return read(request, converter, information); + } + + private Object readPatch(IncomingRequest request, ObjectMapper mapper, Object existingObject) { + + try { + JsonPatchHandler handler = new JsonPatchHandler(mapper, reader); + return handler.apply(request, existingObject); + } catch (Exception e) { + throw new HttpMessageNotReadableException(String.format(ERROR_MESSAGE, existingObject.getClass())); + } + } + + private Object read(IncomingRequest request, HttpMessageConverter converter, + RootResourceInformation information) { + + try { + return converter.read(information.getDomainType(), request.getServerHttpRequest()); + } catch (IOException e) { + throw new HttpMessageNotReadableException(String.format(ERROR_MESSAGE, information.getDomainType())); + } + } + } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java index fb14f888f..ae0d39e33 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java @@ -66,10 +66,12 @@ import org.springframework.data.rest.webmvc.BaseUri; import org.springframework.data.rest.webmvc.RepositoryRestController; import org.springframework.data.rest.webmvc.RepositoryRestHandlerAdapter; import org.springframework.data.rest.webmvc.RepositoryRestHandlerMapping; +import org.springframework.data.rest.webmvc.RestMediaTypes; import org.springframework.data.rest.webmvc.ServerHttpRequestMethodArgumentResolver; import org.springframework.data.rest.webmvc.convert.StringToDistanceConverter; import org.springframework.data.rest.webmvc.convert.StringToPointConverter; import org.springframework.data.rest.webmvc.convert.UriListHttpMessageConverter; +import org.springframework.data.rest.webmvc.json.DomainObjectReader; import org.springframework.data.rest.webmvc.json.Jackson2DatatypeHelper; import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module; import org.springframework.data.rest.webmvc.json.PersistentEntityToJsonSchemaConverter; @@ -312,7 +314,8 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon List> messageConverters = defaultMessageConverters(); configureHttpMessageConverters(messageConverters); - return new PersistentEntityResourceHandlerMethodArgumentResolver(messageConverters, repoRequestArgumentResolver()); + return new PersistentEntityResourceHandlerMethodArgumentResolver(messageConverters, repoRequestArgumentResolver(), + backendIdHandlerMethodArgumentResolver(), new DomainObjectReader(persistentEntities(), resourceMappings())); } /** @@ -360,9 +363,9 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon public MappingJackson2HttpMessageConverter jacksonHttpMessageConverter() { List mediaTypes = new ArrayList(); - mediaTypes.addAll(Arrays.asList(MediaType.valueOf("application/schema+json"), - MediaType.valueOf("application/x-spring-data-verbose+json"), - MediaType.valueOf("application/x-spring-data-compact+json"))); + mediaTypes.addAll(Arrays.asList(RestMediaTypes.SCHEMA_JSON, // + RestMediaTypes.JSON_PATCH_JSON, RestMediaTypes.MERGE_PATCH_JSON, // + RestMediaTypes.SPRING_DATA_VERBOSE_JSON, RestMediaTypes.SPRING_DATA_COMPACT_JSON)); // Configure this mapper to be used if HAL is not the default media type if (!config().useHalAsDefaultJsonMediaType()) { diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/ResourceMetadataHandlerMethodArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/ResourceMetadataHandlerMethodArgumentResolver.java index bd0b91847..c85b9a138 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/ResourceMetadataHandlerMethodArgumentResolver.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/ResourceMetadataHandlerMethodArgumentResolver.java @@ -81,7 +81,7 @@ public class ResourceMetadataHandlerMethodArgumentResolver implements HandlerMet NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { String lookupPath = baseUri.getRepositoryLookupPath(webRequest); - String repositoryKey = UriUtils.findMappingVariable("repository", parameter, lookupPath); + String repositoryKey = UriUtils.findMappingVariable("repository", parameter.getMethod(), lookupPath); if (!hasText(repositoryKey)) { return null; diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java new file mode 100644 index 000000000..3fa3e7556 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java @@ -0,0 +1,169 @@ +/* + * Copyright 2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.rest.webmvc.json; + +import java.io.InputStream; +import java.util.Iterator; +import java.util.Map.Entry; + +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.context.PersistentEntities; +import org.springframework.data.mapping.model.BeanWrapper; +import org.springframework.data.rest.core.mapping.ResourceMappings; +import org.springframework.data.rest.webmvc.mapping.AssociationLinks; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.introspect.BasicClassIntrospector; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; +import com.fasterxml.jackson.databind.introspect.ClassIntrospector; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Component to apply an {@link ObjectNode} to an existing domain object. This is effectively a best-effort workaround + * for Jacksons inability to apply a (partial) JSON document to an existing object in a deeply nestes way. We manually + * detect nested objects, lookup the original value and apply the merge recursively. + * + * @author Oliver Gierke + * @since 2.2 + */ +public class DomainObjectReader { + + private final PersistentEntities entities; + private final AssociationLinks associationLinks; + private final ClassIntrospector introspector; + + /** + * Creates a new {@link DomainObjectReader} using the given {@link PersistentEntities} and {@link ResourceMappings}. + * + * @param entities must not be {@literal null}. + * @param mappings must not be {@literal null}. + */ + public DomainObjectReader(PersistentEntities entities, ResourceMappings mappings) { + + Assert.notNull(entities, "PersistentEntites must not be null!"); + Assert.notNull(mappings, "ResourceMappings must not be null!"); + + this.entities = entities; + this.associationLinks = new AssociationLinks(mappings); + this.introspector = new BasicClassIntrospector(); + } + + /** + * Reads the given input stream into an {@link ObjectNode} and applies that to the given existing instance. + * + * @param request must not be {@literal null}. + * @param target must not be {@literal null}. + * @param mapper must not be {@literal null}. + * @return + */ + public T read(InputStream source, T target, ObjectMapper mapper) { + + Assert.notNull(target, "Target object must not be null!"); + Assert.notNull(source, "InputStream must not be null!"); + Assert.notNull(mapper, "ObjectMapper must not be null!"); + + try { + return merge((ObjectNode) mapper.readTree(source), target, mapper); + } catch (Exception e) { + throw new HttpMessageNotReadableException("Could not read payload!", e); + } + } + + /** + * Merges the given {@link ObjectNode} onto the given object. + * + * @param root must not be {@literal null}. + * @param existingObject + * @param mapper + * @return + * @throws Exception + */ + public T merge(ObjectNode root, T existingObject, ObjectMapper mapper) throws Exception { + + Assert.notNull(root, "Root ObjectNode must not be null!"); + Assert.notNull(existingObject, "Existing object instance must not be null!"); + Assert.notNull(mapper, "ObjectMapper must not be null!"); + + for (Iterator> i = root.fields(); i.hasNext();) { + + Entry entry = i.next(); + JsonNode child = entry.getValue(); + + if (child.isArray()) { + // We ignore arrays so they get instantiated fresh every time + } else if (child.isObject()) { + + PersistentProperty property = findProperty(existingObject, entry.getKey(), mapper); + + if (property == null || associationLinks.isLinkableAssociation(property)) { + continue; + } + + BeanWrapper wrapper = BeanWrapper.create(existingObject, null); + Object nested = wrapper.getProperty(property); + + if (nested != null) { + + // Only remove the JsonNode if the object already exists. Otherwise it will be instantiated when the parent + // gets deserialized. + + i.remove(); + merge((ObjectNode) child, nested, mapper); + } + } + } + + ObjectReader jsonReader = mapper.readerForUpdating(existingObject); + jsonReader.readValue(root); + + return existingObject; + } + + /** + * Finds the {@link PersistentProperty} for the JSON field of the given name on the given object. + * + * @param object must not be {@literal null}. + * @param fieldName must not be {@literal null} or empty. + * @param mapper must not be {@literal null}. + * @return the {@link PersistentProperty} for the JSON field of the given name on the given object or {@literal null} + * if either the given source object is no persistent entity or the property cannot be found. + */ + private PersistentProperty findProperty(Object object, String fieldName, ObjectMapper mapper) { + + PersistentEntity entity = entities.getPersistentEntity(object.getClass()); + + if (entity == null) { + return null; + } + + BeanDescription description = introspector.forDeserialization(mapper.getDeserializationConfig(), + mapper.constructType(object.getClass()), mapper.getDeserializationConfig()); + + for (BeanPropertyDefinition definition : description.findProperties()) { + if (definition.getName().equals(fieldName)) { + return entity.getPersistentProperty(definition.getInternalName()); + } + } + + return null; + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/BackendIdHandlerMethodArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/BackendIdHandlerMethodArgumentResolver.java index 595cbc319..47370c13d 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/BackendIdHandlerMethodArgumentResolver.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/BackendIdHandlerMethodArgumentResolver.java @@ -77,12 +77,12 @@ public class BackendIdHandlerMethodArgumentResolver implements HandlerMethodArgu * @see org.springframework.web.method.support.HandlerMethodArgumentResolver#resolveArgument(org.springframework.core.MethodParameter, org.springframework.web.method.support.ModelAndViewContainer, org.springframework.web.context.request.NativeWebRequest, org.springframework.web.bind.support.WebDataBinderFactory) */ @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + public Serializable resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest request, WebDataBinderFactory binderFactory) throws Exception { Class parameterType = parameter.getParameterType(); - if (!parameterType.equals(Serializable.class)) { + if (parameter.getMethodAnnotation(BackendId.class) != null && !parameterType.equals(Serializable.class)) { throw new IllegalArgumentException(String.format( "Method parameter for @%s must be of type %s! Got %s for method %s.", BackendId.class.getSimpleName(), Serializable.class.getSimpleName(), parameterType.getSimpleName(), parameter.getMethod())); @@ -97,6 +97,7 @@ public class BackendIdHandlerMethodArgumentResolver implements HandlerMethodArgu BackendIdConverter pluginFor = idConverters.getPluginFor(metadata.getDomainType(), DefaultIdConverter.INSTANCE); String lookupPath = baseUri.getRepositoryLookupPath(request); - return pluginFor.fromRequestId(UriUtils.findMappingVariable("id", parameter, lookupPath), metadata.getDomainType()); + return pluginFor.fromRequestId(UriUtils.findMappingVariable("id", parameter.getMethod(), lookupPath), + metadata.getDomainType()); } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/util/UriUtils.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/util/UriUtils.java index d62740ea4..cbd679e25 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/util/UriUtils.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/util/UriUtils.java @@ -15,9 +15,9 @@ */ package org.springframework.data.rest.webmvc.util; +import java.lang.reflect.Method; import java.util.Map; -import org.springframework.core.MethodParameter; import org.springframework.util.Assert; import org.springframework.web.bind.annotation.RequestMapping; @@ -38,12 +38,12 @@ public abstract class UriUtils { * @param request * @return */ - public static String findMappingVariable(String variable, MethodParameter parameter, String lookupPath) { + public static String findMappingVariable(String variable, Method method, String lookupPath) { Assert.hasText(variable, "Variable name must not be null or empty!"); - Assert.notNull(parameter, "Method parameter must not be null!"); + Assert.notNull(method, "Method must not be null!"); - RequestMapping annotation = parameter.getMethodAnnotation(RequestMapping.class); + RequestMapping annotation = method.getAnnotation(RequestMapping.class); for (String mapping : annotation.value()) { diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/JsonPatchHandlerUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/JsonPatchHandlerUnitTests.java new file mode 100644 index 000000000..0d5012f5e --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/JsonPatchHandlerUnitTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.rest.webmvc.config; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; +import static org.springframework.data.rest.webmvc.util.TestUtils.*; + +import java.util.Arrays; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.data.mapping.context.PersistentEntities; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.rest.core.mapping.ResourceMappings; +import org.springframework.data.rest.webmvc.json.DomainObjectReader; +import org.springframework.data.rest.webmvc.mongodb.Address; +import org.springframework.data.rest.webmvc.mongodb.User; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Unit tests for {@link JsonPatchHandler}. + * + * @author Oliver Gierke + */ +@RunWith(MockitoJUnitRunner.class) +public class JsonPatchHandlerUnitTests { + + JsonPatchHandler handler; + User user; + + @Mock ResourceMappings mappings; + + @Before + public void setUp() { + + MongoMappingContext context = new MongoMappingContext(); + context.getPersistentEntity(User.class); + + PersistentEntities entities = new PersistentEntities(Arrays.asList(context)); + + this.handler = new JsonPatchHandler(new ObjectMapper(), new DomainObjectReader(entities, mappings)); + + Address address = new Address(); + address.street = "Foo"; + address.zipCode = "Bar"; + + this.user = new User(); + this.user.firstname = "Oliver"; + this.user.lastname = "Gierke"; + this.user.address = address; + } + + /** + * @see DATAREST-348 + */ + @Test + public void appliesRemoveOperationCorrectly() throws Exception { + + String input = "[{ \"op\": \"replace\", \"path\": \"/address/zipCode\", \"value\": \"ZIP\" }," + + "{ \"op\": \"remove\", \"path\": \"/lastname\" }]"; + + User result = handler.applyPatch(asStream(input), user); + + assertThat(result.lastname, is(nullValue())); + assertThat(result.address.zipCode, is("ZIP")); + } + + /** + * @see DATAREST-348 + */ + @Test + public void appliesMergePatchCorrectly() throws Exception { + + String input = "{ \"address\" : { \"zipCode\" : \"ZIP\"}, \"lastname\" : null }"; + + User result = handler.applyMergePatch(asStream(input), user); + + assertThat(result.lastname, is(nullValue())); + assertThat(result.address.zipCode, is("ZIP")); + } +} 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 5e0463a70..d7ff222b7 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 @@ -215,13 +215,18 @@ public class JpaWebTests extends AbstractWebIntegrationTests { Link bilboLink = assertHasLinkWithRel("self", bilbo); - assertThat((String) JsonPath.read(bilbo.getContentAsString(), "$.firstName"), equalTo("Bilbo")); - assertThat((String) JsonPath.read(bilbo.getContentAsString(), "$.lastName"), equalTo("Baggins")); + assertThat((String) JsonPath.read(bilbo.getContentAsString(), "$.firstName"), is("Bilbo")); + assertThat((String) JsonPath.read(bilbo.getContentAsString(), "$.lastName"), is("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")); + assertThat((String) JsonPath.read(frodo.getContentAsString(), "$.firstName"), is("Frodo")); + assertThat((String) JsonPath.read(frodo.getContentAsString(), "$.lastName"), is("Baggins")); + + frodo = patchAndGet(bilboLink, "{ \"firstName\" : null }", MediaType.APPLICATION_JSON); + + assertThat((String) JsonPath.read(frodo.getContentAsString(), "$.firstName"), is(nullValue())); + assertThat((String) JsonPath.read(frodo.getContentAsString(), "$.lastName"), is("Baggins")); } /** diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java index e692e66e1..e20b6c81e 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java @@ -26,10 +26,14 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.rest.webmvc.AbstractWebIntegrationTests; +import org.springframework.data.rest.webmvc.RestMediaTypes; import org.springframework.hateoas.Link; +import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.context.ContextConfiguration; +import com.jayway.jsonpath.JsonPath; + /** * Integration tests for MongoDB repositories. * @@ -114,4 +118,33 @@ public class MongoWebTests extends AbstractWebIntegrationTests { MockHttpServletResponse response = request(countByTypeLink.expand("Twitter")); assertThat(response.getContentAsString(), is("1")); } + + @Test + public void testname() throws Exception { + + Link usersLink = discoverUnique("users"); + Link userLink = assertHasContentLinkWithRel("self", request(usersLink)); + + MockHttpServletResponse response = patchAndGet(userLink, + "{\"lastname\" : null, \"address\" : { \"zipCode\" : \"ZIP\"}}", MediaType.APPLICATION_JSON); + + assertThat(JsonPath.read(response.getContentAsString(), "$.lastname"), is(nullValue())); + assertThat(JsonPath.read(response.getContentAsString(), "$.address.zipCode"), is((Object) "ZIP")); + } + + @Test + public void testname2() throws Exception { + + Link usersLink = discoverUnique("users"); + Link userLink = assertHasContentLinkWithRel("self", request(usersLink)); + + MockHttpServletResponse response = patchAndGet(userLink, + "[{ \"op\": \"replace\", \"path\": \"/address/zipCode\", \"value\": \"ZIP\" }," + // + "{ \"op\": \"replace\", \"path\": \"/lastname\", \"value\": null }]", // + + "{ \"op\": \"remove\", \"path\": \"/lastname\" }]", // + RestMediaTypes.JSON_PATCH_JSON); + + assertThat(JsonPath.read(response.getContentAsString(), "$.lastname"), is(nullValue())); + assertThat(JsonPath.read(response.getContentAsString(), "$.address.zipCode"), is((Object) "ZIP")); + } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/TestUtils.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/TestUtils.java index 745d9e0e5..4e75d962c 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/TestUtils.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/util/TestUtils.java @@ -15,10 +15,14 @@ */ package org.springframework.data.rest.webmvc.util; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.Charset; import java.util.Scanner; import org.springframework.core.io.ClassPathResource; import org.springframework.data.rest.webmvc.jpa.JpaWebTests; +import org.springframework.util.Assert; /** * Test helper methods. @@ -46,4 +50,15 @@ public class TestUtils { return builder.toString(); } + + /** + * Returns the given {@link String} as {@link InputStream}. + * + * @param source must not be {@literal null}. + * @return + */ + public static InputStream asStream(String source) { + Assert.notNull(source, "Source string must not be null!"); + return new ByteArrayInputStream(source.getBytes(Charset.forName("UTF-8"))); + } }