diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java index 8c757a3de..b5aa4f143 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java @@ -34,6 +34,7 @@ import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Page; import org.springframework.data.rest.core.RepositoryConstraintViolationException; import org.springframework.data.rest.core.mapping.ResourceMetadata; +import org.springframework.data.rest.webmvc.support.ETagDoesntMatchException; import org.springframework.data.rest.webmvc.support.ExceptionMessage; import org.springframework.data.rest.webmvc.support.RepositoryConstraintViolationExceptionMessage; import org.springframework.data.web.PagedResourcesAssembler; @@ -132,7 +133,6 @@ class AbstractRepositoryRestController implements MessageSourceAware { * @return */ @ExceptionHandler({ OptimisticLockingFailureException.class, DataIntegrityViolationException.class }) - @ResponseBody public ResponseEntity handleConflict(Exception ex) { return errorResponse(null, ex, HttpStatus.CONFLICT); } @@ -144,7 +144,6 @@ class AbstractRepositoryRestController implements MessageSourceAware { * @return */ @ExceptionHandler - @ResponseBody public ResponseEntity handle(HttpRequestMethodNotSupportedException o_O) { HttpHeaders headers = new HttpHeaders(); @@ -153,6 +152,13 @@ class AbstractRepositoryRestController implements MessageSourceAware { return new ResponseEntity(headers, HttpStatus.METHOD_NOT_ALLOWED); } + @ExceptionHandler + public ResponseEntity handle(ETagDoesntMatchException o_O) { + + HttpHeaders headers = o_O.getExpectedETag().addTo(new HttpHeaders()); + return new ResponseEntity(headers, HttpStatus.PRECONDITION_FAILED); + } + protected ResponseEntity notFound() { return notFound(null, null); } 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 8c319e3a6..4180c0488 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 @@ -46,9 +46,9 @@ import org.springframework.data.rest.core.mapping.SearchResourceMappings; import org.springframework.data.rest.core.mapping.SupportedHttpMethods; import org.springframework.data.rest.webmvc.support.BackendId; import org.springframework.data.rest.webmvc.support.DefaultedPageable; -import org.springframework.data.rest.webmvc.support.IfMatch; +import org.springframework.data.rest.webmvc.support.ETag; +import org.springframework.data.rest.webmvc.support.ETagDoesntMatchException; import org.springframework.data.rest.webmvc.support.RepositoryEntityLinks; -import org.springframework.data.rest.webmvc.support.EtagValidator; import org.springframework.data.web.PagedResourcesAssembler; import org.springframework.hateoas.Link; import org.springframework.hateoas.PagedResources; @@ -62,7 +62,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.HttpRequestMethodNotSupportedException; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; @@ -84,21 +83,19 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem private final RepositoryEntityLinks entityLinks; private final RepositoryRestConfiguration config; private final ConversionService conversionService; - private final EtagValidator etagValidator; private ApplicationEventPublisher publisher; @Autowired public RepositoryEntityController(Repositories repositories, RepositoryRestConfiguration config, RepositoryEntityLinks entityLinks, PagedResourcesAssembler assembler, - @Qualifier("defaultConversionService") ConversionService conversionService, EtagValidator etagValidator) { + @Qualifier("defaultConversionService") ConversionService conversionService) { super(assembler); this.entityLinks = entityLinks; this.config = config; this.conversionService = conversionService; - this.etagValidator = etagValidator; } /* @@ -300,11 +297,10 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem return new ResponseEntity>(HttpStatus.NOT_FOUND); } - PersistentEntityResource persistentEntityResource = assembler.toFullResource(domainObj); - HttpHeaders httpHeaders = new HttpHeaders(); - etagValidator.addEtagHeader(httpHeaders, persistentEntityResource); + PersistentEntityResource resource = assembler.toFullResource(domainObj); + HttpHeaders headers = ETag.from(resource).addTo(new HttpHeaders()); - return new ResponseEntity>(persistentEntityResource, httpHeaders, HttpStatus.OK); + return new ResponseEntity>(resource, headers, HttpStatus.OK); } /** @@ -318,8 +314,9 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem * @throws HttpRequestMethodNotSupportedException */ @RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.PUT) - public ResponseEntity putItemResource(RootResourceInformation resourceInformation, PersistentEntityResource payload, @BackendId Serializable id, - PersistentEntityResourceAssembler assembler, @IfMatch String eTagMatch) throws HttpRequestMethodNotSupportedException { + public ResponseEntity putItemResource(RootResourceInformation resourceInformation, + PersistentEntityResource payload, @BackendId Serializable id, PersistentEntityResourceAssembler assembler, + ETag eTag) throws HttpRequestMethodNotSupportedException { resourceInformation.verifySupportedMethod(HttpMethod.PUT, ResourceType.ITEM); @@ -332,9 +329,8 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem Object objectToSave = incomingWrapper.getBean(); Object domainObject = invoker.invokeFindOne(id); - if (domainObject != null) { - etagValidator.validateEtag(eTagMatch, resourceInformation, domainObject); - } + + eTag.verify(resourceInformation.getPersistentEntity(), domainObject); return domainObject == null ? createAndReturn(objectToSave, invoker, assembler) : saveAndReturn(objectToSave, invoker, PUT, assembler); @@ -343,18 +339,20 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem /** * PUT /{repository}/{id} - Updates an existing entity or creates one at exactly that place. * - * @param eTagMatch * @param resourceInformation * @param payload * @param id + * @param assembler + * @param eTag * @return * @throws HttpRequestMethodNotSupportedException * @throws ResourceNotFoundException + * @throws ETagDoesntMatchException */ @RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.PATCH) - public ResponseEntity patchItemResource(RootResourceInformation resourceInformation, PersistentEntityResource payload, @BackendId Serializable id, - PersistentEntityResourceAssembler assembler, @IfMatch String eTagMatch) throws HttpRequestMethodNotSupportedException, - ResourceNotFoundException { + public ResponseEntity patchItemResource(RootResourceInformation resourceInformation, + PersistentEntityResource payload, @BackendId Serializable id, PersistentEntityResourceAssembler assembler, + ETag eTag) throws HttpRequestMethodNotSupportedException, ResourceNotFoundException { resourceInformation.verifySupportedMethod(HttpMethod.PATCH, ResourceType.ITEM); @@ -364,7 +362,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem throw new ResourceNotFoundException(); } - etagValidator.validateEtag(eTagMatch, resourceInformation, domainObject); + eTag.verify(resourceInformation.getPersistentEntity(), domainObject); return saveAndReturn(payload.getContent(), resourceInformation.getInvoker(), PATCH, assembler); } @@ -372,16 +370,17 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem /** * DELETE /{repository}/{id} - Deletes the entity backing the item resource. * - * @param eTagMatch * @param resourceInformation * @param id + * @param eTag * @return * @throws ResourceNotFoundException * @throws HttpRequestMethodNotSupportedException + * @throws ETagDoesntMatchException */ @RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.DELETE) - public ResponseEntity deleteItemResource(RootResourceInformation resourceInformation, @BackendId Serializable id, @IfMatch String eTagMatch) - throws ResourceNotFoundException, HttpRequestMethodNotSupportedException { + public ResponseEntity deleteItemResource(RootResourceInformation resourceInformation, @BackendId Serializable id, + ETag eTag) throws ResourceNotFoundException, HttpRequestMethodNotSupportedException { resourceInformation.verifySupportedMethod(HttpMethod.DELETE, ResourceType.ITEM); @@ -392,7 +391,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem throw new ResourceNotFoundException(); } - etagValidator.validateEtag(eTagMatch, resourceInformation, domainObj); + eTag.verify(resourceInformation.getPersistentEntity(), domainObj); publisher.publishEvent(new BeforeDeleteEvent(domainObj)); invoker.invokeDelete(id); @@ -424,10 +423,10 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem if (config.isReturnBodyOnUpdate()) { - PersistentEntityResource persistentEntityResource = assembler.toFullResource(obj); - etagValidator.addEtagHeader(headers, persistentEntityResource); + PersistentEntityResource resource = assembler.toFullResource(obj); + headers = ETag.from(resource).addTo(headers); - return ControllerUtils.toResponseEntity(HttpStatus.OK, headers, persistentEntityResource); + return ControllerUtils.toResponseEntity(HttpStatus.OK, headers, resource); } else { return ControllerUtils.toEmptyResponse(HttpStatus.NO_CONTENT, headers); } @@ -451,10 +450,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem addLocationHeader(headers, assembler, savedObject); PersistentEntityResource resource = config.isReturnBodyOnCreate() ? assembler.toFullResource(savedObject) : null; - - if (resource != null) { - etagValidator.addEtagHeader(headers, resource); - } + headers = ETag.from(resource).addTo(headers); return ControllerUtils.toResponseEntity(HttpStatus.CREATED, headers, resource); } 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 c82edddcd..e8c090047 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 @@ -88,11 +88,10 @@ import org.springframework.data.rest.webmvc.spi.BackendIdConverter; import org.springframework.data.rest.webmvc.spi.BackendIdConverter.DefaultIdConverter; import org.springframework.data.rest.webmvc.support.BackendIdHandlerMethodArgumentResolver; import org.springframework.data.rest.webmvc.support.DefaultedPageableHandlerMethodArgumentResolver; +import org.springframework.data.rest.webmvc.support.ETagArgumentResolver; import org.springframework.data.rest.webmvc.support.HttpMethodHandlerMethodArgumentResolver; -import org.springframework.data.rest.webmvc.support.IfMatchHeaderArgumentResolver; import org.springframework.data.rest.webmvc.support.JpaHelper; import org.springframework.data.rest.webmvc.support.RepositoryEntityLinks; -import org.springframework.data.rest.webmvc.support.EtagValidator; import org.springframework.data.util.AnnotatedTypeScanner; import org.springframework.data.web.HateoasPageableHandlerMethodArgumentResolver; import org.springframework.data.web.HateoasSortHandlerMethodArgumentResolver; @@ -309,9 +308,10 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon } @Bean - public IfMatchHeaderArgumentResolver ifMatchHeaderArgumentResolver(){ - return new IfMatchHeaderArgumentResolver(); + public ETagArgumentResolver eTagArgumentResolver() { + return new ETagArgumentResolver(); } + /** * A special {@link org.springframework.hateoas.EntityLinks} implementation that takes repository and current * configuration into account when generating links. @@ -621,11 +621,6 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon return OrderAwarePluginRegistry.create(converters); } - @Bean - public EtagValidator etagValidator() { - return new EtagValidator(defaultConversionService()); - } - private List defaultMethodArgumentResolvers() { PersistentEntityResourceAssemblerArgumentResolver peraResolver = new PersistentEntityResourceAssemblerArgumentResolver( @@ -639,7 +634,7 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon return Arrays.asList(defaultedPageableResolver, pageableResolver, sortResolver(), serverHttpRequestMethodArgumentResolver(), repoRequestArgumentResolver(), persistentEntityArgumentResolver(), resourceMetadataHandlerMethodArgumentResolver(), HttpMethodHandlerMethodArgumentResolver.INSTANCE, - peraResolver, backendIdHandlerMethodArgumentResolver(), ifMatchHeaderArgumentResolver()); + peraResolver, backendIdHandlerMethodArgumentResolver(), eTagArgumentResolver()); } @Autowired GeoModule geoModule; diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/ETag.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/ETag.java new file mode 100644 index 000000000..38917b4db --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/ETag.java @@ -0,0 +1,177 @@ +/* + * 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.support; + +import static org.springframework.util.StringUtils.*; + +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.rest.webmvc.PersistentEntityResource; +import org.springframework.http.HttpHeaders; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * A value object to represent ETags. + * + * @author Oliver Gierke + */ +public final class ETag { + + public static final ETag NO_ETAG = new ETag(null); + + private final String value; + + /** + * Creates a new {@link ETag} from the given value. + * + * @param value can be {@literal null}. + */ + private ETag(String value) { + this.value = trimTrailingCharacter(trimLeadingCharacter(value, '"'), '"'); + } + + /** + * Creates a new {@link ETag} for the given {@link String} value. Falls back to {@link #NO_ETAG} in case + * {@literal null} is provided. + * + * @param value the source ETag value, can be {@literal null}. + * @return + */ + public static ETag from(String value) { + return value == null ? NO_ETAG : new ETag(value); + } + + /** + * Creates a new {@link ETag} for the given {@link PersistentEntityResource}. + * + * @param resource can be {@literal null}. + * @return + */ + public static ETag from(PersistentEntityResource resource) { + return resource == null ? NO_ETAG : from(resource.getPersistentEntity(), resource.getContent()); + } + + /** + * Verifies the ETag to be created for the given target bean with the current one and raises a + * {@link ETagDoesntMatchException} in case they don't match. + * + * @param entity must not be {@literal null}. + * @param target can be {@literal null}. + * @throws ETagDoesntMatchException in case the calculated {@link ETag} for the given bean does not match the current + * one. + */ + public void verify(PersistentEntity entity, Object target) { + + if (this == NO_ETAG || target == null) { + return; + } + + if (!this.equals(from(entity, target))) { + throw new ETagDoesntMatchException(target, this); + } + } + + /** + * Adds the current {@link ETag} to the given headers. + * + * @param headers must not be {@literal null}. + * @return the {@link HttpHeaders} with the ETag header been set if the current {@link ETag} instance is not + * {@link #NO_ETAG}. + */ + public HttpHeaders addTo(HttpHeaders headers) { + + Assert.notNull(headers, "HttpHeaders must not be null!"); + String stringValue = toString(); + + if (stringValue == null) { + return headers; + } + + headers.setETag(stringValue); + return headers; + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return value == null ? null : "\"".concat(value).concat("\""); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof ETag)) { + return false; + } + + ETag that = (ETag) obj; + + return ObjectUtils.nullSafeEquals(this.value, that.value); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + return value.hashCode(); + } + + /** + * Creates a new {@link ETag} from the given {@link PersistentEntity} and target bean. + * + * @param entity + * @param bean + * @return + */ + private static ETag from(PersistentEntity entity, Object bean) { + return from(getVersionInformation(entity, bean)); + } + + /** + * Returns the quoted version property of a domain object, returns null if it doesn't contains the property + * + * @param entity + * @param bean + * @return + */ + @SuppressWarnings("rawtypes") + private static String getVersionInformation(PersistentEntity entity, Object bean) { + + Assert.notNull(entity, "PersistentEntity must not be null!"); + Assert.notNull(bean, "Target bean must not be null!"); + + if (!entity.hasVersionProperty()) { + return null; + } + + PersistentPropertyAccessor accessor = entity.getPropertyAccessor(bean); + return accessor.getProperty(entity.getVersionProperty()).toString(); + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatchHeaderArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/ETagArgumentResolver.java similarity index 75% rename from spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatchHeaderArgumentResolver.java rename to spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/ETagArgumentResolver.java index 6be9c875a..02674acb0 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatchHeaderArgumentResolver.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/ETagArgumentResolver.java @@ -16,8 +16,6 @@ package org.springframework.data.rest.webmvc.support; -import javax.servlet.http.HttpServletRequest; - import org.springframework.core.MethodParameter; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; @@ -28,8 +26,9 @@ import org.springframework.web.method.support.ModelAndViewContainer; * {@link HandlerMethodArgumentResolver} to resolve If-Match headers for optimistic locking handling {@link IfMatch}. * * @author Pablo Lozano + * @author Oliver Gierke */ -public class IfMatchHeaderArgumentResolver implements HandlerMethodArgumentResolver { +public class ETagArgumentResolver implements HandlerMethodArgumentResolver { /* * (non-Javadoc) @@ -37,7 +36,7 @@ public class IfMatchHeaderArgumentResolver implements HandlerMethodArgumentResol */ @Override public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(IfMatch.class); + return parameter.getParameterType().equals(ETag.class); } /* @@ -45,16 +44,8 @@ public class IfMatchHeaderArgumentResolver implements HandlerMethodArgumentResol * @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 ETag resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - - IfMatch ifMatchHeader = parameter.getParameterAnnotation(IfMatch.class); - HttpServletRequest httprequest = (HttpServletRequest) webRequest.getNativeRequest(); - Object result = httprequest.getHeader("If-Match"); - - if (result == null) { - result = ifMatchHeader.defaultValue(); - } - return result; + return ETag.from(webRequest.getHeader("If-Match")); } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/ETagDoesntMatchException.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/ETagDoesntMatchException.java new file mode 100644 index 000000000..70471ebbb --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/ETagDoesntMatchException.java @@ -0,0 +1,66 @@ +/* + * 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.support; + +import org.springframework.util.Assert; + +/** + * An exception being thrown in case the {@link ETag} calculated for a particular object does not match an expected one. + * + * @author Oliver Gierke + * @see ETag#verify(org.springframework.data.mapping.PersistentEntity, Object) + */ +public class ETagDoesntMatchException extends RuntimeException { + + private static final long serialVersionUID = 415835592506644699L; + + private final ETag expected; + private final Object bean; + + /** + * Creates a new {@link ETagDoesntMatchException} for the given bean as well as the {@link ETag} it was expected to + * match. + * + * @param bean must not be {@literal null}. + * @param expected must not be {@literal null}. + */ + public ETagDoesntMatchException(Object bean, ETag expected) { + + Assert.notNull(bean, "Target bean must not be null!"); + Assert.notNull(expected, "Expected ETag must not be null!"); + + this.expected = expected; + this.bean = bean; + } + + /** + * Returns the bean not matching the {@link ETag}. + * + * @return the bean + */ + public Object getBean() { + return bean; + } + + /** + * Returns the {@link ETag} the bean was expected to match. + * + * @return the expected + */ + public ETag getExpectedETag() { + return expected; + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/EtagValidator.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/EtagValidator.java deleted file mode 100644 index 11dde0582..000000000 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/EtagValidator.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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.support; - -import org.springframework.core.convert.ConversionService; -import org.springframework.dao.OptimisticLockingFailureException; -import org.springframework.data.mapping.PersistentEntity; -import org.springframework.data.mapping.model.BeanWrapper; -import org.springframework.data.rest.webmvc.PersistentEntityResource; -import org.springframework.data.rest.webmvc.RootResourceInformation; -import org.springframework.http.HttpHeaders; -import org.springframework.util.Assert; -import org.springframework.web.bind.annotation.ValueConstants; - -/** - * An ETag validator that verifies concurrency issues and the validity of the Etag/ If-Match header values to implement - * optimistic locking. - * - * @author Pablo Lozano - */ - -public class EtagValidator { - - private final ConversionService conversionService; - - public EtagValidator(ConversionService conversionService) { - this.conversionService = conversionService; - Assert.notNull(conversionService, "Conversion service must not be null"); - } - - /** - * Compares the given eTag with the entity's version property, if there are different an - * OptimisticLockingFailureException is thrown. - * - * @param requestEtag - * @param resourceInformation - * @param domainObject - * @return - */ - public void validateEtag(String requestEtag, RootResourceInformation resourceInformation, Object domainObject) { - - if (requestEtag != null && !requestEtag.equals(ValueConstants.DEFAULT_NONE)) { - final String entityEtag = getVersionInformation(resourceInformation.getPersistentEntity(), domainObject); - if (!requestEtag.equals(entityEtag)) { - throw new OptimisticLockingFailureException("Invalid If-Match version provided, the resource has gone thru" - + " changes after resource's request"); - } - } - } - - /** - * Sets the Etag to the header, if the domain object does not contain a Version property it will return and leave the - * headers as is. - * - * @param headers - * @param persistentEntityResource - */ - public void addEtagHeader(HttpHeaders headers, PersistentEntityResource persistentEntityResource) { - - String version = getVersionInformation(persistentEntityResource.getPersistentEntity(), - persistentEntityResource.getContent()); - if (version != null) { - headers.setETag(version); - } - } - - /** - * Returns the quoted version property of a domain object, returns null if it doesn't contains the property - * - * @param persistentEntity - * @param domainObject - * @return - */ - @SuppressWarnings("rawtypes") - private String getVersionInformation(PersistentEntity persistentEntity, Object domainObject) { - - if (persistentEntity.hasVersionProperty()) { - - BeanWrapper beanWrapper = BeanWrapper.create(domainObject, conversionService); - Object version = beanWrapper.getProperty(persistentEntity.getVersionProperty()); - return "\"" + version.toString() + "\""; - } - return null; - } - -} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java index 04bf3ae40..8c63d4444 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java @@ -32,6 +32,7 @@ import org.springframework.data.rest.webmvc.jpa.CreditCard; import org.springframework.data.rest.webmvc.jpa.JpaRepositoryConfig; import org.springframework.data.rest.webmvc.jpa.Order; import org.springframework.data.rest.webmvc.jpa.Person; +import org.springframework.data.rest.webmvc.support.ETag; import org.springframework.http.HttpEntity; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -89,7 +90,8 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll PersistentEntityResource persistentEntityResource = PersistentEntityResource.build(new Order(new Person()), entities.getPersistentEntity(Order.class)).build(); - ResponseEntity entity = controller.putItemResource(information, persistentEntityResource, 1L, assembler, null); + ResponseEntity entity = controller.putItemResource(information, persistentEntityResource, 1L, assembler, + ETag.NO_ETAG); assertThat(entity.getHeaders().getLocation().toString(), not(endsWith("{?projection}"))); } 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 4f4e2cb23..d8057db92 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 @@ -611,7 +611,7 @@ public class JpaWebTests extends CommonWebTests { mvc.perform( patch(builder.build().toUriString()).content("{ \"saleItem\" : \"SpringyTequila\" }") .contentType(MediaType.APPLICATION_JSON).header("If-Match", "\"falseETag\"")).andExpect( - status().isConflict()); + status().isPreconditionFailed()); } /** 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 d22f3064b..29f3e1413 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 @@ -17,13 +17,12 @@ package org.springframework.data.rest.webmvc.mongodb; import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.math.BigDecimal; import java.util.Arrays; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -34,10 +33,11 @@ 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; import org.springframework.web.util.UriComponentsBuilder; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; + /** * Integration tests for MongoDB repositories. * @@ -50,7 +50,7 @@ public class MongoWebTests extends CommonWebTests { @Autowired ProfileRepository repository; @Autowired UserRepository userRepository; - ObjectMapper mapper = new ObjectMapper(); + ObjectMapper mapper = new ObjectMapper(); @Before public void populateProfiles() { @@ -66,7 +66,7 @@ public class MongoWebTests extends CommonWebTests { repository.save(Arrays.asList(twitter, linkedIn)); Address address = new Address(); - address.street = "Foo"; + address.street = "ETagDoesntMatchExceptionUnitTests"; address.zipCode = "Bar"; User user = new User(); @@ -160,7 +160,8 @@ public class MongoWebTests extends CommonWebTests { */ @Test public void returnConflictWhenConcurrentlyEditingVersionedEntity() throws Exception { - Link receiptLink = discoverUnique("receipts"); + + Link receiptLink = client.discoverUnique("receipts"); Receipt receipt = new Receipt(); receipt.setAmount(new BigDecimal(50)); @@ -169,7 +170,7 @@ public class MongoWebTests extends CommonWebTests { String stringReceipt = mapper.writeValueAsString(receipt); MockHttpServletResponse createdReceipt = postAndGet(receiptLink, stringReceipt, MediaType.APPLICATION_JSON); - Link tacosLink = assertHasLinkWithRel("self", createdReceipt); + Link tacosLink = client.assertHasLinkWithRel("self", createdReceipt); assertJsonPathEquals("$.saleItem", "Springy Tacos", createdReceipt); UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(tacosLink.getHref()); @@ -183,6 +184,6 @@ public class MongoWebTests extends CommonWebTests { mvc.perform( patch(builder.build().toUriString()).content("{ \"saleItem\" : \"SpringyTequila\" }") .contentType(MediaType.APPLICATION_JSON).header("If-Match", concurrencyTag)).andExpect( - status().isConflict()); + status().isPreconditionFailed()); } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatch.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/ETagDoesntMatchExceptionUnitTests.java similarity index 57% rename from spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatch.java rename to spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/ETagDoesntMatchExceptionUnitTests.java index 59844910d..e5aacc6a1 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatch.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/ETagDoesntMatchExceptionUnitTests.java @@ -13,25 +13,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.data.rest.webmvc.support; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import org.springframework.web.bind.annotation.ValueConstants; +import org.junit.Test; /** - * Annotation to find the If-Match header of a request - * - * @author Pablo Lozano + * Unit tests for {@link ETagDoesntMatchException}. + * + * @author Oliver Gierke */ +public class ETagDoesntMatchExceptionUnitTests { -@Target(ElementType.PARAMETER) -@Retention(RetentionPolicy.RUNTIME) -public @interface IfMatch { + /** + * @see DATAREST-160 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsNullTargetBean() { + new ETagDoesntMatchException(null, ETag.from("1")); + } - String defaultValue() default ValueConstants.DEFAULT_NONE; + /** + * @see DATAREST-160 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsNullExpectedETag() { + new ETagDoesntMatchException(new Object(), null); + } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/ETagUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/ETagUnitTests.java new file mode 100644 index 000000000..ed9aa79f7 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/ETagUnitTests.java @@ -0,0 +1,188 @@ +/* + * 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.support; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.data.annotation.Version; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.rest.webmvc.PersistentEntityResource; +import org.springframework.http.HttpHeaders; + +/** + * Tests for the ETagValidator used for optimistic locking + * + * @author Pablo Lozano + * @author Oliver Gierke + */ +@RunWith(MockitoJUnitRunner.class) +public class ETagUnitTests { + + MongoMappingContext context = new MongoMappingContext(); + + /** + * @see DATAREST-160 + */ + @Test(expected = ETagDoesntMatchException.class) + public void expectWrongEtag() throws Exception { + + ETag eTag = ETag.from("1"); + eTag.verify(context.getPersistentEntity(Sample.class), new Sample(0L)); + } + + /** + * @see DATAREST-160 + */ + @Test + public void expectCorrectEtag() throws Exception { + ETag.from("0").verify(context.getPersistentEntity(Sample.class), new Sample(0L)); + } + + /** + * @see DATAREST-160 + */ + @Test + public void createsETagFromVersionValue() throws Exception { + + PersistentEntity entity = context.getPersistentEntity(Sample.class); + ETag from = ETag.from(PersistentEntityResource.build(new Sample(0L), entity).build()); + + assertThat(from.toString(), is((Object) "\"0\"")); + } + + /** + * @see DATAREST-160 + */ + @Test + public void surroundsValueWithQuotationMarksOnToString() { + assertThat(ETag.from("1").toString(), is("\"1\"")); + } + + /** + * @see DATAREST-160 + */ + @Test + public void returnsNoEtagForNullStringSource() { + assertThat(ETag.from((String) null), is(ETag.NO_ETAG)); + } + + /** + * @see DATAREST-160 + */ + @Test + public void returnsNoEtagForNullPersistentEntityResourceSource() { + assertThat(ETag.from((PersistentEntityResource) null), is(ETag.NO_ETAG)); + } + + /** + * @see DATAREST-160 + */ + @Test + public void hasValueObjectEqualsSemantics() { + + ETag one = ETag.from("1"); + ETag two = ETag.from("2"); + ETag nullETag = ETag.from((String) null); + + assertThat(one.equals(one), is(true)); + assertThat(one.equals(two), is(false)); + assertThat(two.equals(one), is(false)); + assertThat(nullETag.equals(one), is(false)); + assertThat(one.equals(two), is(false)); + assertThat(one.equals(""), is(false)); + } + + /** + * @see DATAREST-160 + */ + @Test + public void returnsNoEtagForEntityWithoutVersionProperty() { + + PersistentEntity entity = context.getPersistentEntity(SampleWithoutVersion.class); + assertThat(ETag.from(PersistentEntityResource.build(new SampleWithoutVersion(), entity).build()), is(ETag.NO_ETAG)); + } + + /** + * @see DATAREST-160 + */ + @Test + public void noETagReturnsNullForToString() { + assertThat(ETag.NO_ETAG.toString(), is(nullValue())); + } + + /** + * @see DATAREST-160 + */ + @Test + public void noETagDoesNotRejectVerification() { + ETag.NO_ETAG.verify(context.getPersistentEntity(Sample.class), new Sample(5L)); + } + + /** + * @see DATAREST-160 + */ + @Test + public void verificationDoesNotRejectNullEntity() { + ETag.from("5").verify(context.getPersistentEntity(Sample.class), null); + } + + /** + * @see DATAREST-160 + */ + @Test + public void stripsTrailingAndLeadingQuotesOnCreation() { + + assertThat(ETag.from("\"1\""), is(ETag.from("1"))); + assertThat(ETag.from("\"\"1\"\""), is(ETag.from("1"))); + } + + /** + * @see DATAREST-160 + */ + @Test + public void addsETagToHeadersIfNotNoETag() { + + HttpHeaders headers = ETag.from("1").addTo(new HttpHeaders()); + assertThat(headers.getETag(), is(notNullValue())); + } + + /** + * @see DATAREST-160 + */ + @Test + public void doesNotAddHeaderForNoETag() { + + HttpHeaders headers = ETag.NO_ETAG.addTo(new HttpHeaders()); + + assertThat(headers.containsKey("ETag"), is(false)); + } + + public class Sample { + + @Version Long version; + + Sample(Long version) { + this.version = version; + } + } + + public class SampleWithoutVersion {} +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/EtagValidatorTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/EtagValidatorTests.java deleted file mode 100644 index 1bbb139a7..000000000 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/EtagValidatorTests.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * 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.support; - -import static org.hamcrest.CoreMatchers.is; -import static org.junit.Assert.assertThat; - -import javax.persistence.Version; -import javax.persistence.metamodel.Metamodel; - -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.dao.OptimisticLockingFailureException; -import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; -import org.springframework.data.jpa.mapping.JpaPersistentEntity; -import org.springframework.data.rest.core.mapping.MappingResourceMetadata; -import org.springframework.data.rest.core.mapping.ResourceMetadata; -import org.springframework.data.rest.webmvc.PersistentEntityResource; -import org.springframework.data.rest.webmvc.RootResourceInformation; -import org.springframework.format.support.DefaultFormattingConversionService; -import org.springframework.http.HttpHeaders; - -/** - * Tests for the EtagValidator used for optimistic locking - * - * @author Pablo Lozano - */ - -@RunWith(MockitoJUnitRunner.class) -public class EtagValidatorTests { - - EtagValidator etagValidator; - - @Mock Metamodel model; - - JpaMetamodelMappingContext context; - JpaPersistentEntity entity; - - @Before - public void setUp() { - context = new JpaMetamodelMappingContext(model); - entity = context.getPersistentEntity(Sample.class); - etagValidator = new EtagValidator(new DefaultFormattingConversionService()); - } - - /** - * @see DATAREST-160 - */ - @Test(expected = OptimisticLockingFailureException.class) - public void expectWrongEtag() throws Exception { - Sample sampleEntity = new Sample(); - sampleEntity.version = 0; - - ResourceMetadata resourceMetadata = new MappingResourceMetadata(entity); - RootResourceInformation rootResourceInformation = new RootResourceInformation(resourceMetadata, entity, null); - - etagValidator.validateEtag("\"1\"", rootResourceInformation, sampleEntity); - } - - /** - * @see DATAREST-160 - */ - @Test - public void expectCorrectEtag() throws Exception { - Sample sampleEntity = new Sample(); - sampleEntity.version = 0; - - ResourceMetadata resourceMetadata = new MappingResourceMetadata(entity); - RootResourceInformation rootResourceInformation = new RootResourceInformation(resourceMetadata, entity, null); - - etagValidator.validateEtag("\"0\"", rootResourceInformation, sampleEntity); - } - - /** - * @see DATAREST-160 - */ - @Test - public void setCorrectEtagHeader() throws Exception { - Sample sampleEntity = new Sample(); - sampleEntity.version = 0; - - HttpHeaders headers = new HttpHeaders(); - PersistentEntityResource perf = PersistentEntityResource.build(sampleEntity, entity).build(); - etagValidator.addEtagHeader(headers, perf); - Object ifMatch = headers.getETag(); - - assertThat(ifMatch, is((Object) "\"0\"")); - } - - public class Sample { - - private @Version long version; - - public long getVersion() { - return version; - } - - public void setVersion(long version) { - this.version = version; - } - - } -}