DATAREST-160 - Added support for ETag/If-Match based on entity's version property.
Original pull request: #148.
This commit is contained in:
committed by
Oliver Gierke
parent
2e03e8397e
commit
8fe634cb0d
@@ -46,7 +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.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;
|
||||
@@ -60,6 +62,7 @@ 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;
|
||||
@@ -81,19 +84,21 @@ 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) {
|
||||
@Qualifier("defaultConversionService") ConversionService conversionService, EtagValidator etagValidator) {
|
||||
|
||||
super(assembler);
|
||||
|
||||
this.entityLinks = entityLinks;
|
||||
this.config = config;
|
||||
this.conversionService = conversionService;
|
||||
this.etagValidator = etagValidator;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -295,12 +300,17 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
|
||||
return new ResponseEntity<Resource<?>>(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return new ResponseEntity<Resource<?>>(assembler.toFullResource(domainObj), HttpStatus.OK);
|
||||
PersistentEntityResource persistentEntityResource = assembler.toFullResource(domainObj);
|
||||
HttpHeaders httpHeaders = new HttpHeaders();
|
||||
etagValidator.addEtagHeader(httpHeaders, persistentEntityResource);
|
||||
|
||||
return new ResponseEntity<Resource<?>>(persistentEntityResource, httpHeaders, HttpStatus.OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>PUT /{repository}/{id}</code> - Updates an existing entity or creates one at exactly that place.
|
||||
*
|
||||
*
|
||||
* @param eTagMatch
|
||||
* @param resourceInformation
|
||||
* @param payload
|
||||
* @param id
|
||||
@@ -308,9 +318,8 @@ 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)
|
||||
throws HttpRequestMethodNotSupportedException {
|
||||
public ResponseEntity<? extends ResourceSupport> putItemResource(RootResourceInformation resourceInformation, PersistentEntityResource payload, @BackendId Serializable id,
|
||||
PersistentEntityResourceAssembler assembler, @IfMatch String eTagMatch) throws HttpRequestMethodNotSupportedException {
|
||||
|
||||
resourceInformation.verifySupportedMethod(HttpMethod.PUT, ResourceType.ITEM);
|
||||
|
||||
@@ -322,13 +331,19 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
|
||||
RepositoryInvoker invoker = resourceInformation.getInvoker();
|
||||
Object objectToSave = incomingWrapper.getBean();
|
||||
|
||||
return invoker.invokeFindOne(id) == null ? createAndReturn(objectToSave, invoker, assembler) : saveAndReturn(
|
||||
objectToSave, invoker, PUT, assembler);
|
||||
Object domainObject = invoker.invokeFindOne(id);
|
||||
if (domainObject != null) {
|
||||
etagValidator.validateEtag(eTagMatch, resourceInformation, domainObject);
|
||||
}
|
||||
|
||||
return domainObject == null ? createAndReturn(objectToSave, invoker, assembler) : saveAndReturn(objectToSave,
|
||||
invoker, PUT, assembler);
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>PUT /{repository}/{id}</code> - Updates an existing entity or creates one at exactly that place.
|
||||
*
|
||||
*
|
||||
* @param eTagMatch
|
||||
* @param resourceInformation
|
||||
* @param payload
|
||||
* @param id
|
||||
@@ -337,22 +352,27 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
@RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.PATCH)
|
||||
public ResponseEntity<ResourceSupport> patchItemResource(RootResourceInformation resourceInformation,
|
||||
PersistentEntityResource payload, @BackendId Serializable id, PersistentEntityResourceAssembler assembler)
|
||||
throws HttpRequestMethodNotSupportedException, ResourceNotFoundException {
|
||||
public ResponseEntity<ResourceSupport> patchItemResource(RootResourceInformation resourceInformation, PersistentEntityResource payload, @BackendId Serializable id,
|
||||
PersistentEntityResourceAssembler assembler, @IfMatch String eTagMatch) throws HttpRequestMethodNotSupportedException,
|
||||
ResourceNotFoundException {
|
||||
|
||||
resourceInformation.verifySupportedMethod(HttpMethod.PATCH, ResourceType.ITEM);
|
||||
|
||||
if (resourceInformation.getInvoker().invokeFindOne(id) == null) {
|
||||
Object domainObject = resourceInformation.getInvoker().invokeFindOne(id);
|
||||
|
||||
if (domainObject == null) {
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
etagValidator.validateEtag(eTagMatch, resourceInformation, domainObject);
|
||||
|
||||
return saveAndReturn(payload.getContent(), resourceInformation.getInvoker(), PATCH, assembler);
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>DELETE /{repository}/{id}</code> - Deletes the entity backing the item resource.
|
||||
*
|
||||
*
|
||||
* @param eTagMatch
|
||||
* @param resourceInformation
|
||||
* @param id
|
||||
* @return
|
||||
@@ -360,7 +380,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
|
||||
* @throws HttpRequestMethodNotSupportedException
|
||||
*/
|
||||
@RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.DELETE)
|
||||
public ResponseEntity<?> deleteItemResource(RootResourceInformation resourceInformation, @BackendId Serializable id)
|
||||
public ResponseEntity<?> deleteItemResource(RootResourceInformation resourceInformation, @BackendId Serializable id, @IfMatch String eTagMatch)
|
||||
throws ResourceNotFoundException, HttpRequestMethodNotSupportedException {
|
||||
|
||||
resourceInformation.verifySupportedMethod(HttpMethod.DELETE, ResourceType.ITEM);
|
||||
@@ -372,6 +392,8 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
etagValidator.validateEtag(eTagMatch, resourceInformation, domainObj);
|
||||
|
||||
publisher.publishEvent(new BeforeDeleteEvent(domainObj));
|
||||
invoker.invokeDelete(id);
|
||||
publisher.publishEvent(new AfterDeleteEvent(domainObj));
|
||||
@@ -381,8 +403,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
|
||||
|
||||
/**
|
||||
* Merges the given incoming object into the given domain object.
|
||||
*
|
||||
* @param incoming
|
||||
*
|
||||
* @param domainObject
|
||||
* @param invoker
|
||||
* @param httpMethod
|
||||
@@ -402,7 +423,11 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
|
||||
}
|
||||
|
||||
if (config.isReturnBodyOnUpdate()) {
|
||||
return ControllerUtils.toResponseEntity(HttpStatus.OK, headers, assembler.toFullResource(obj));
|
||||
|
||||
PersistentEntityResource persistentEntityResource = assembler.toFullResource(obj);
|
||||
etagValidator.addEtagHeader(headers, persistentEntityResource);
|
||||
|
||||
return ControllerUtils.toResponseEntity(HttpStatus.OK, headers, persistentEntityResource);
|
||||
} else {
|
||||
return ControllerUtils.toEmptyResponse(HttpStatus.NO_CONTENT, headers);
|
||||
}
|
||||
@@ -426,6 +451,11 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
|
||||
addLocationHeader(headers, assembler, savedObject);
|
||||
|
||||
PersistentEntityResource resource = config.isReturnBodyOnCreate() ? assembler.toFullResource(savedObject) : null;
|
||||
|
||||
if (resource != null) {
|
||||
etagValidator.addEtagHeader(headers, resource);
|
||||
}
|
||||
|
||||
return ControllerUtils.toResponseEntity(HttpStatus.CREATED, headers, resource);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,8 +89,10 @@ import org.springframework.data.rest.webmvc.spi.BackendIdConverter.DefaultIdConv
|
||||
import org.springframework.data.rest.webmvc.support.BackendIdHandlerMethodArgumentResolver;
|
||||
import org.springframework.data.rest.webmvc.support.DefaultedPageableHandlerMethodArgumentResolver;
|
||||
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;
|
||||
@@ -306,6 +308,10 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon
|
||||
resourceMetadataHandlerMethodArgumentResolver(), baseUri());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IfMatchHeaderArgumentResolver ifMatchHeaderArgumentResolver(){
|
||||
return new IfMatchHeaderArgumentResolver();
|
||||
}
|
||||
/**
|
||||
* A special {@link org.springframework.hateoas.EntityLinks} implementation that takes repository and current
|
||||
* configuration into account when generating links.
|
||||
@@ -615,6 +621,11 @@ 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(
|
||||
@@ -628,7 +639,7 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon
|
||||
return Arrays.asList(defaultedPageableResolver, pageableResolver, sortResolver(),
|
||||
serverHttpRequestMethodArgumentResolver(), repoRequestArgumentResolver(), persistentEntityArgumentResolver(),
|
||||
resourceMetadataHandlerMethodArgumentResolver(), HttpMethodHandlerMethodArgumentResolver.INSTANCE,
|
||||
peraResolver, backendIdHandlerMethodArgumentResolver());
|
||||
peraResolver, backendIdHandlerMethodArgumentResolver(), ifMatchHeaderArgumentResolver());
|
||||
}
|
||||
|
||||
@Autowired GeoModule geoModule;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.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;
|
||||
|
||||
/**
|
||||
* Annotation to find the If-Match header of a request
|
||||
*
|
||||
* @author Pablo Lozano
|
||||
*/
|
||||
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface IfMatch {
|
||||
|
||||
String defaultValue() default ValueConstants.DEFAULT_NONE;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||
import org.springframework.web.context.request.NativeWebRequest;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||
|
||||
/**
|
||||
* {@link HandlerMethodArgumentResolver} to resolve If-Match headers for optimistic locking handling {@link IfMatch}.
|
||||
*
|
||||
* @author Pablo Lozano
|
||||
*/
|
||||
public class IfMatchHeaderArgumentResolver implements HandlerMethodArgumentResolver {
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.springframework.web.method.support.HandlerMethodArgumentResolver#supportsParameter(org.springframework.core.MethodParameter)
|
||||
*/
|
||||
@Override
|
||||
public boolean supportsParameter(MethodParameter parameter) {
|
||||
return parameter.hasParameterAnnotation(IfMatch.class);
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -62,12 +62,13 @@ public class RepositoryControllerIntegrationTests extends AbstractControllerInte
|
||||
|
||||
RepositoryLinksResource resource = controller.listRepositories().getBody();
|
||||
|
||||
assertThat(resource.getLinks(), hasSize(5));
|
||||
assertThat(resource.getLinks(), hasSize(6));
|
||||
|
||||
assertThat(resource.hasLink("people"), is(true));
|
||||
assertThat(resource.hasLink("orders"), is(true));
|
||||
assertThat(resource.hasLink("addresses"), is(true));
|
||||
assertThat(resource.hasLink("books"), is(true));
|
||||
assertThat(resource.hasLink("authors"), is(true));
|
||||
assertThat(resource.hasLink("receipts"), is(true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ 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);
|
||||
ResponseEntity<?> entity = controller.putItemResource(information, persistentEntityResource, 1L, assembler, null);
|
||||
|
||||
assertThat(entity.getHeaders().getLocation().toString(), not(endsWith("{?projection}")));
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import static org.springframework.data.rest.webmvc.util.TestUtils.*;
|
||||
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.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
@@ -581,6 +582,38 @@ public class JpaWebTests extends CommonWebTests {
|
||||
andExpect(jsonPath("$._embedded.books[1].title").value("Spring Data (Second Edition)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @see DATAREST-160
|
||||
*/
|
||||
@Test
|
||||
public void returnConflictWhenConcurrentlyEditingVersionedEntity() throws Exception {
|
||||
|
||||
Link receiptLink = client.discoverUnique("receipts");
|
||||
|
||||
Receipt receipt = new Receipt();
|
||||
receipt.setAmount(new BigDecimal(50));
|
||||
receipt.setSaleItem("Springy Tacos");
|
||||
|
||||
String stringReceipt = mapper.writeValueAsString(receipt);
|
||||
|
||||
MockHttpServletResponse createdReceipt = postAndGet(receiptLink, stringReceipt, MediaType.APPLICATION_JSON);
|
||||
Link tacosLink = client.assertHasLinkWithRel("self", createdReceipt);
|
||||
assertJsonPathEquals("$.saleItem", "Springy Tacos", createdReceipt);
|
||||
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(tacosLink.getHref());
|
||||
String concurrencyTag = createdReceipt.getHeader("ETag");
|
||||
|
||||
mvc.perform(
|
||||
patch(builder.build().toUriString()).content("{ \"saleItem\" : \"SpringyBurritos\" }")
|
||||
.contentType(MediaType.APPLICATION_JSON).header("If-Match", concurrencyTag)).andExpect(
|
||||
status().isNoContent());
|
||||
|
||||
mvc.perform(
|
||||
patch(builder.build().toUriString()).content("{ \"saleItem\" : \"SpringyTequila\" }")
|
||||
.contentType(MediaType.APPLICATION_JSON).header("If-Match", "\"falseETag\"")).andExpect(
|
||||
status().isConflict());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the {@link Person} resource the given link points to contains siblings with the given names.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.jpa;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import javax.persistence.*;
|
||||
import javax.persistence.Entity;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* An entity that represents a receipt.
|
||||
*
|
||||
* @author Pablo Lozano
|
||||
*/
|
||||
@Entity
|
||||
@JsonIgnoreProperties({"version"})
|
||||
public class Receipt {
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
private Long id;
|
||||
|
||||
private String saleItem;
|
||||
|
||||
private BigDecimal amount;
|
||||
|
||||
@Version
|
||||
@Temporal(TemporalType.TIMESTAMP)
|
||||
private Date version;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getSaleItem() {
|
||||
return saleItem;
|
||||
}
|
||||
|
||||
public void setSaleItem(String saleItem) {
|
||||
this.saleItem = saleItem;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public void setAmount(BigDecimal amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
|
||||
public Date getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(Date version) {
|
||||
this.version = version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.jpa;
|
||||
|
||||
import org.springframework.data.repository.CrudRepository;
|
||||
|
||||
/**
|
||||
* A repository to manage {@link Receipt}s.
|
||||
*
|
||||
* @author Pablo Lozano
|
||||
*/
|
||||
|
||||
public interface ReceiptRepository extends CrudRepository<Receipt, Long> {
|
||||
|
||||
}
|
||||
@@ -18,9 +18,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 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;
|
||||
@@ -33,6 +36,7 @@ import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* Integration tests for MongoDB repositories.
|
||||
@@ -46,6 +50,8 @@ public class MongoWebTests extends CommonWebTests {
|
||||
@Autowired ProfileRepository repository;
|
||||
@Autowired UserRepository userRepository;
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Before
|
||||
public void populateProfiles() {
|
||||
|
||||
@@ -148,4 +154,35 @@ public class MongoWebTests extends CommonWebTests {
|
||||
assertThat(JsonPath.read(response.getContentAsString(), "$.lastname"), is(nullValue()));
|
||||
assertThat(JsonPath.read(response.getContentAsString(), "$.address.zipCode"), is((Object) "ZIP"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @see DATAREST-160
|
||||
*/
|
||||
@Test
|
||||
public void returnConflictWhenConcurrentlyEditingVersionedEntity() throws Exception {
|
||||
Link receiptLink = discoverUnique("receipts");
|
||||
|
||||
Receipt receipt = new Receipt();
|
||||
receipt.setAmount(new BigDecimal(50));
|
||||
receipt.setSaleItem("Springy Tacos");
|
||||
|
||||
String stringReceipt = mapper.writeValueAsString(receipt);
|
||||
|
||||
MockHttpServletResponse createdReceipt = postAndGet(receiptLink, stringReceipt, MediaType.APPLICATION_JSON);
|
||||
Link tacosLink = assertHasLinkWithRel("self", createdReceipt);
|
||||
assertJsonPathEquals("$.saleItem", "Springy Tacos", createdReceipt);
|
||||
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(tacosLink.getHref());
|
||||
String concurrencyTag = createdReceipt.getHeader("ETag");
|
||||
|
||||
mvc.perform(
|
||||
patch(builder.build().toUriString()).content("{ \"saleItem\" : \"SpringyBurritos\" }")
|
||||
.contentType(MediaType.APPLICATION_JSON).header("If-Match", concurrencyTag)).andExpect(
|
||||
status().isNoContent());
|
||||
|
||||
mvc.perform(
|
||||
patch(builder.build().toUriString()).content("{ \"saleItem\" : \"SpringyTequila\" }")
|
||||
.contentType(MediaType.APPLICATION_JSON).header("If-Match", concurrencyTag)).andExpect(
|
||||
status().isConflict());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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.mongodb;
|
||||
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.Version;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
|
||||
/**
|
||||
* @author Pablo Lozano
|
||||
*/
|
||||
@Document
|
||||
public class Receipt {
|
||||
|
||||
@Id
|
||||
public String id;
|
||||
|
||||
private String saleItem;
|
||||
|
||||
private BigDecimal amount;
|
||||
|
||||
@Version
|
||||
private Long version;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getSaleItem() {
|
||||
return saleItem;
|
||||
}
|
||||
|
||||
public void setSaleItem(String saleItem) {
|
||||
this.saleItem = saleItem;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public void setAmount(BigDecimal amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
public Long getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(Long version) {
|
||||
this.version = version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.mongodb;
|
||||
|
||||
import org.springframework.data.repository.CrudRepository;
|
||||
|
||||
/**
|
||||
* A repository to manage {@link Receipt}s.
|
||||
*
|
||||
* @author Pablo Lozano
|
||||
*/
|
||||
|
||||
public interface ReceiptRepository extends CrudRepository<Receipt, String> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user