DATAREST-160 - Polished support for ETag headers based on version properties.

Introduced a dedicated value object for ETags and moved the logic of ETagValidator into it. Switched from an annotation based injection model to a type based one.

The HTTP status code for a non-matching ETag is now 412 Precondition failed as the HTTP specification suggests.

Original pull request: #148.
This commit is contained in:
Oliver Gierke
2014-11-24 16:21:09 +01:00
parent 8fe634cb0d
commit a32d1867b4
13 changed files with 509 additions and 302 deletions

View File

@@ -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<Void> handle(HttpRequestMethodNotSupportedException o_O) {
HttpHeaders headers = new HttpHeaders();
@@ -153,6 +152,13 @@ class AbstractRepositoryRestController implements MessageSourceAware {
return new ResponseEntity<Void>(headers, HttpStatus.METHOD_NOT_ALLOWED);
}
@ExceptionHandler
public ResponseEntity<Void> handle(ETagDoesntMatchException o_O) {
HttpHeaders headers = o_O.getExpectedETag().addTo(new HttpHeaders());
return new ResponseEntity<Void>(headers, HttpStatus.PRECONDITION_FAILED);
}
protected <T> ResponseEntity<T> notFound() {
return notFound(null, null);
}

View File

@@ -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<Object> 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<Resource<?>>(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<Resource<?>>(persistentEntityResource, httpHeaders, HttpStatus.OK);
return new ResponseEntity<Resource<?>>(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<? extends ResourceSupport> putItemResource(RootResourceInformation resourceInformation, PersistentEntityResource payload, @BackendId Serializable id,
PersistentEntityResourceAssembler assembler, @IfMatch String eTagMatch) throws HttpRequestMethodNotSupportedException {
public ResponseEntity<? extends ResourceSupport> 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
/**
* <code>PUT /{repository}/{id}</code> - 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<ResourceSupport> patchItemResource(RootResourceInformation resourceInformation, PersistentEntityResource payload, @BackendId Serializable id,
PersistentEntityResourceAssembler assembler, @IfMatch String eTagMatch) throws HttpRequestMethodNotSupportedException,
ResourceNotFoundException {
public ResponseEntity<ResourceSupport> 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
/**
* <code>DELETE /{repository}/{id}</code> - 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);
}

View File

@@ -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<HandlerMethodArgumentResolver> 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;

View File

@@ -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();
}
}

View File

@@ -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"));
}
}

View File

@@ -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;
}
}

View File

@@ -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<Object> beanWrapper = BeanWrapper.create(domainObject, conversionService);
Object version = beanWrapper.getProperty(persistentEntity.getVersionProperty());
return "\"" + version.toString() + "\"";
}
return null;
}
}

View File

@@ -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}")));
}

View File

@@ -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());
}
/**

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}
}

View File

@@ -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;
}
}
}