DATAREST-160 - Added support for ETag/If-Match based on entity's version property.

Original pull request: #148.
This commit is contained in:
Pablo Lozano
2014-11-10 19:17:50 +01:00
committed by Oliver Gierke
parent 2e03e8397e
commit 8fe634cb0d
14 changed files with 661 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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