diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java index fa3ace60a..8c319e3a6 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositoryEntityController.java @@ -46,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 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>(HttpStatus.NOT_FOUND); } - return new ResponseEntity>(assembler.toFullResource(domainObj), HttpStatus.OK); + PersistentEntityResource persistentEntityResource = assembler.toFullResource(domainObj); + HttpHeaders httpHeaders = new HttpHeaders(); + etagValidator.addEtagHeader(httpHeaders, persistentEntityResource); + + return new ResponseEntity>(persistentEntityResource, httpHeaders, HttpStatus.OK); } /** * PUT /{repository}/{id} - 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 putItemResource(RootResourceInformation resourceInformation, - PersistentEntityResource payload, @BackendId Serializable id, PersistentEntityResourceAssembler assembler) - throws HttpRequestMethodNotSupportedException { + public ResponseEntity 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); } /** * PUT /{repository}/{id} - 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 patchItemResource(RootResourceInformation resourceInformation, - PersistentEntityResource payload, @BackendId Serializable id, PersistentEntityResourceAssembler assembler) - throws HttpRequestMethodNotSupportedException, ResourceNotFoundException { + public ResponseEntity 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); } /** * DELETE /{repository}/{id} - 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); } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java index d0b8bdc01..c82edddcd 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java @@ -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 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; diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/EtagValidator.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/EtagValidator.java new file mode 100644 index 000000000..11dde0582 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/EtagValidator.java @@ -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 beanWrapper = BeanWrapper.create(domainObject, conversionService); + Object version = beanWrapper.getProperty(persistentEntity.getVersionProperty()); + return "\"" + version.toString() + "\""; + } + return null; + } + +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatch.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatch.java new file mode 100644 index 000000000..59844910d --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatch.java @@ -0,0 +1,37 @@ +/* + * Copyright 2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.rest.webmvc.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; +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatchHeaderArgumentResolver.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatchHeaderArgumentResolver.java new file mode 100644 index 000000000..6be9c875a --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/support/IfMatchHeaderArgumentResolver.java @@ -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; + } +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryControllerIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryControllerIntegrationTests.java index 4e74f3488..32d493243 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryControllerIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryControllerIntegrationTests.java @@ -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)); } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java index 31b258644..04bf3ae40 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java @@ -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}"))); } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java index ebe075c71..4f4e2cb23 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java @@ -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. * diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Receipt.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Receipt.java new file mode 100644 index 000000000..9472a1a20 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Receipt.java @@ -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; + } +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/ReceiptRepository.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/ReceiptRepository.java new file mode 100644 index 000000000..79b12e2f4 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/ReceiptRepository.java @@ -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 { + +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java index fba1421e1..d22f3064b 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/MongoWebTests.java @@ -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()); + } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Receipt.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Receipt.java new file mode 100644 index 000000000..f617e0e40 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Receipt.java @@ -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; + } +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/ReceiptRepository.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/ReceiptRepository.java new file mode 100644 index 000000000..275027c1a --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/ReceiptRepository.java @@ -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 { + +} diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/EtagValidatorTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/EtagValidatorTests.java new file mode 100644 index 000000000..1bbb139a7 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/EtagValidatorTests.java @@ -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; + } + + } +}