diff --git a/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/util/Supplier.java b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/util/Supplier.java new file mode 100644 index 000000000..fe506c483 --- /dev/null +++ b/spring-data-rest-core/src/main/java/org/springframework/data/rest/core/util/Supplier.java @@ -0,0 +1,28 @@ +/* + * Copyright 2016 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.core.util; + +/** + * Mimics Java 8's Supplier interface to allow deferring a computation. + * + * @author Oliver Gierke + * @since 2.6 + * @soundtrack KRS-One - Sound Of Da Police (Return Of The Boom Bap) + */ +public interface Supplier { + + T get(); +} diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java index f1df4d4bc..3e06acc11 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/RepositoryEntityControllerIntegrationTests.java @@ -42,12 +42,12 @@ import org.springframework.data.rest.webmvc.support.DefaultedPageable; import org.springframework.data.rest.webmvc.support.ETag; import org.springframework.hateoas.Resource; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.LinkedMultiValueMap; import org.springframework.web.HttpRequestMethodNotSupportedException; /** @@ -273,7 +273,7 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll Mockito.when(assembler.toFullResource(Mockito.any(Object.class))).thenReturn(resource); ResponseEntity> entity = controller.getItemResource(getResourceInformation(Address.class), address.id, - assembler, new LinkedMultiValueMap()); + assembler, new HttpHeaders()); assertThat(entity.getHeaders().getETag(), is(notNullValue())); } diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java index 3edd5aa8a..19da01246 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java @@ -39,6 +39,7 @@ import org.springframework.hateoas.PagedResources; import org.springframework.hateoas.ResourceSupport; import org.springframework.hateoas.Resources; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ContextConfiguration; @@ -100,8 +101,8 @@ public class RepositorySearchControllerIntegrationTests extends AbstractControll MultiValueMap parameters = new LinkedMultiValueMap(1); parameters.add("firstname", "John"); - ResponseEntity response = controller.executeSearch(resourceInformation, parameters, "firstname", PAGEABLE, - null, assembler); + ResponseEntity response = controller.executeSearch(resourceInformation, parameters, "firstname", PAGEABLE, null, + assembler, new HttpHeaders()); ResourceTester tester = ResourceTester.of(response.getBody()); PagedResources pagedResources = tester.assertIsPage(); @@ -190,8 +191,8 @@ public class RepositorySearchControllerIntegrationTests extends AbstractControll RootResourceInformation resourceInformation = getResourceInformation(Book.class); - ResponseEntity result = controller.executeSearch(resourceInformation, parameters, "findByAuthorsContains", - PAGEABLE, null, assembler); + ResponseEntity result = controller.executeSearch(resourceInformation, parameters, "findByAuthorsContains", + PAGEABLE, null, assembler, new HttpHeaders()); assertThat(result.getBody(), is(instanceOf(Resources.class))); } diff --git a/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/main/java/org/springframework/data/rest/tests/mongodb/Profile.java b/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/main/java/org/springframework/data/rest/tests/mongodb/Profile.java index aa720711a..e264fe28e 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/main/java/org/springframework/data/rest/tests/mongodb/Profile.java +++ b/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/main/java/org/springframework/data/rest/tests/mongodb/Profile.java @@ -21,6 +21,7 @@ import java.util.Map; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.Version; import org.springframework.data.mongodb.core.mapping.Document; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -36,6 +37,7 @@ public class Profile { private Long person; private @JsonProperty(required = true) String type; private @LastModifiedDate Date lastModifiedDate; + private @Version Long version; private @JsonProperty("renamed") String aliased; private Map metadata = new HashMap(); diff --git a/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/tests/mongodb/MongoWebTests.java b/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/tests/mongodb/MongoWebTests.java index 41bdc5dcb..a43ec6619 100644 --- a/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/tests/mongodb/MongoWebTests.java +++ b/spring-data-rest-tests/spring-data-rest-tests-mongodb/src/test/java/org/springframework/data/rest/tests/mongodb/MongoWebTests.java @@ -343,4 +343,34 @@ public class MongoWebTests extends CommonWebTests { mvc.perform(get(href)).andExpect(status().isOk()); } + + /** + * @see DATAREST-835 + */ + @Test + public void exposesETagHeaderForSearchResourceYieldingItemResource() throws Exception { + + Link link = client.discoverUnique("profiles", "search", "findById"); + + Profile profile = repository.findAll().iterator().next(); + + mvc.perform(get(link.expand(profile.getId()).getHref()))// + .andExpect(header().string("ETag", is("\"0\"")))// + .andExpect(header().string("Last-Modified", is(notNullValue()))); + } + + /** + * @see DATAREST-835 + */ + @Test + public void doesNotAddETagHeaderForCollectionQueryResource() throws Exception { + + Link link = client.discoverUnique("profiles", "search", "findByType"); + + Profile profile = repository.findAll().iterator().next(); + + mvc.perform(get(link.expand(profile.getType()).getHref()))// + .andExpect(header().string("ETag", is(nullValue())))// + .andExpect(header().string("Last-Modified", is(nullValue()))); + } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java index 220a52a96..4a1ef352b 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/AbstractRepositoryRestController.java @@ -27,11 +27,9 @@ import org.springframework.data.rest.core.mapping.ResourceMetadata; import org.springframework.data.web.PagedResourcesAssembler; import org.springframework.hateoas.Link; import org.springframework.hateoas.Resource; -import org.springframework.hateoas.ResourceSupport; import org.springframework.hateoas.Resources; import org.springframework.hateoas.core.EmbeddedWrappers; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; /** @@ -83,30 +81,6 @@ class AbstractRepositoryRestController { } } - /** - * Turns the given source into a {@link ResourceSupport} if needed and possible. Uses the given - * {@link PersistentEntityResourceAssembler} for the actual conversion. - * - * @param source can be must not be {@literal null}. - * @param assembler must not be {@literal null}. - * @param domainType the domain type in case the source is an empty iterable, must not be {@literal null}. - * @param baseLink can be {@literal null}. - * @return - */ - protected Object toResource(Object source, PersistentEntityResourceAssembler assembler, Class domainType, - Link baseLink) { - - if (source instanceof Iterable) { - return toResources((Iterable) source, assembler, domainType, baseLink); - } else if (source == null) { - throw new ResourceNotFoundException(); - } else if (ClassUtils.isPrimitiveOrWrapper(source.getClass())) { - return source; - } - - return assembler.toFullResource(source); - } - protected Resources entitiesToResources(Page page, PersistentEntityResourceAssembler assembler, Class domainType, Link baseLink) { 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 c7c6a59cf..982fca5ee 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 @@ -44,6 +44,7 @@ import org.springframework.data.rest.core.mapping.ResourceMetadata; import org.springframework.data.rest.core.mapping.ResourceType; import org.springframework.data.rest.core.mapping.SearchResourceMappings; import org.springframework.data.rest.core.mapping.SupportedHttpMethods; +import org.springframework.data.rest.core.util.Supplier; import org.springframework.data.rest.webmvc.support.BackendId; import org.springframework.data.rest.webmvc.support.DefaultedPageable; import org.springframework.data.rest.webmvc.support.ETag; @@ -62,7 +63,6 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.util.MultiValueMap; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -90,6 +90,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem private final RepositoryEntityLinks entityLinks; private final RepositoryRestConfiguration config; private final HttpHeadersPreparer headersPreparer; + private final ResourceStatus resourceStatus; private ApplicationEventPublisher publisher; @@ -107,13 +108,14 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem @Autowired public RepositoryEntityController(Repositories repositories, RepositoryRestConfiguration config, RepositoryEntityLinks entityLinks, PagedResourcesAssembler assembler, - AuditableBeanWrapperFactory auditableBeanWrapperFactory) { + HttpHeadersPreparer headersPreparer) { super(assembler); this.entityLinks = entityLinks; this.config = config; - this.headersPreparer = new HttpHeadersPreparer(auditableBeanWrapperFactory); + this.headersPreparer = headersPreparer; + this.resourceStatus = ResourceStatus.of(headersPreparer); } /* @@ -187,7 +189,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem @RequestMapping(value = BASE_MAPPING, method = RequestMethod.GET) public Resources getCollectionResource(@QuerydslPredicate RootResourceInformation resourceInformation, DefaultedPageable pageable, Sort sort, PersistentEntityResourceAssembler assembler) - throws ResourceNotFoundException, HttpRequestMethodNotSupportedException { + throws ResourceNotFoundException, HttpRequestMethodNotSupportedException { resourceInformation.verifySupportedMethod(HttpMethod.GET, ResourceType.COLLECTION); @@ -232,7 +234,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem produces = { "application/x-spring-data-compact+json", "text/uri-list" }) public Resources getCollectionResourceCompact(@QuerydslPredicate RootResourceInformation resourceinformation, DefaultedPageable pageable, Sort sort, PersistentEntityResourceAssembler assembler) - throws ResourceNotFoundException, HttpRequestMethodNotSupportedException { + throws ResourceNotFoundException, HttpRequestMethodNotSupportedException { Resources resources = getCollectionResource(resourceinformation, pageable, sort, assembler); List links = new ArrayList(resources.getLinks()); @@ -263,7 +265,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem public ResponseEntity postCollectionResource(RootResourceInformation resourceInformation, PersistentEntityResource payload, PersistentEntityResourceAssembler assembler, @RequestHeader(value = ACCEPT_HEADER, required = false) String acceptHeader) - throws HttpRequestMethodNotSupportedException { + throws HttpRequestMethodNotSupportedException { resourceInformation.verifySupportedMethod(HttpMethod.POST, ResourceType.COLLECTION); @@ -327,37 +329,24 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem */ @RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.GET) public ResponseEntity> getItemResource(RootResourceInformation resourceInformation, - @BackendId Serializable id, PersistentEntityResourceAssembler assembler, - @RequestHeader MultiValueMap rawHeaders) throws HttpRequestMethodNotSupportedException { + @BackendId Serializable id, final PersistentEntityResourceAssembler assembler, @RequestHeader HttpHeaders headers) + throws HttpRequestMethodNotSupportedException { - Object domainObj = getItemResource(resourceInformation, id); + final Object domainObj = getItemResource(resourceInformation, id); if (domainObj == null) { return new ResponseEntity>(HttpStatus.NOT_FOUND); } - HttpHeaders headers = new HttpHeaders(); - headers.putAll(rawHeaders); - - // Check ETag for If-Non-Match - - List ifNoneMatch = headers.getIfNoneMatch(); - ETag eTag = ifNoneMatch.isEmpty() ? ETag.NO_ETAG : ETag.from(ifNoneMatch.get(0)); PersistentEntity entity = resourceInformation.getPersistentEntity(); - HttpHeaders responseHeaders = headersPreparer.prepareHeaders(entity, domainObj); - if (eTag.matches(entity, domainObj)) { - return new ResponseEntity>(responseHeaders, HttpStatus.NOT_MODIFIED); - } - - // Check last modification for If-Modified-Since - - if (headersPreparer.isObjectStillValid(domainObj, headers)) { - return new ResponseEntity>(responseHeaders, HttpStatus.NOT_MODIFIED); - } - - PersistentEntityResource resource = assembler.toFullResource(domainObj); - return new ResponseEntity>(resource, responseHeaders, HttpStatus.OK); + return resourceStatus.getStatusAndHeaders(headers, domainObj, entity).toResponseEntity(// + new Supplier() { + @Override + public PersistentEntityResource get() { + return assembler.toFullResource(domainObj); + } + }); } /** @@ -376,7 +365,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem public ResponseEntity putItemResource(RootResourceInformation resourceInformation, PersistentEntityResource payload, @BackendId Serializable id, PersistentEntityResourceAssembler assembler, ETag eTag, @RequestHeader(value = ACCEPT_HEADER, required = false) String acceptHeader) - throws HttpRequestMethodNotSupportedException { + throws HttpRequestMethodNotSupportedException { resourceInformation.verifySupportedMethod(HttpMethod.PUT, ResourceType.ITEM); @@ -406,7 +395,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem public ResponseEntity patchItemResource(RootResourceInformation resourceInformation, PersistentEntityResource payload, @BackendId Serializable id, PersistentEntityResourceAssembler assembler, ETag eTag, @RequestHeader(value = ACCEPT_HEADER, required = false) String acceptHeader) - throws HttpRequestMethodNotSupportedException, ResourceNotFoundException { + throws HttpRequestMethodNotSupportedException, ResourceNotFoundException { resourceInformation.verifySupportedMethod(HttpMethod.PATCH, ResourceType.ITEM); diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java index 43a4f44f3..ee5a41963 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RepositorySearchController.java @@ -28,12 +28,14 @@ import java.util.Map.Entry; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.MethodParameter; import org.springframework.data.domain.Sort; +import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.repository.query.Param; import org.springframework.data.repository.support.RepositoryInvoker; import org.springframework.data.rest.core.mapping.MethodResourceMapping; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.core.mapping.ResourceMetadata; import org.springframework.data.rest.core.mapping.SearchResourceMappings; +import org.springframework.data.rest.core.util.Supplier; import org.springframework.data.rest.webmvc.support.DefaultedPageable; import org.springframework.data.rest.webmvc.support.RepositoryEntityLinks; import org.springframework.data.util.ClassTypeInformation; @@ -53,9 +55,11 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; @@ -75,6 +79,7 @@ class RepositorySearchController extends AbstractRepositoryRestController { private final RepositoryEntityLinks entityLinks; private final ResourceMappings mappings; + private ResourceStatus resourceStatus; /** * Creates a new {@link RepositorySearchController} using the given {@link PagedResourcesAssembler}, @@ -86,7 +91,7 @@ class RepositorySearchController extends AbstractRepositoryRestController { */ @Autowired public RepositorySearchController(PagedResourcesAssembler assembler, RepositoryEntityLinks entityLinks, - ResourceMappings mappings) { + ResourceMappings mappings, HttpHeadersPreparer headersPreparer) { super(assembler); @@ -95,6 +100,7 @@ class RepositorySearchController extends AbstractRepositoryRestController { this.entityLinks = entityLinks; this.mappings = mappings; + this.resourceStatus = ResourceStatus.of(headersPreparer); } /** @@ -112,7 +118,7 @@ class RepositorySearchController extends AbstractRepositoryRestController { HttpHeaders headers = new HttpHeaders(); headers.setAllow(Collections.singleton(HttpMethod.GET)); - return new ResponseEntity(headers, HttpStatus.OK); + return ResponseEntity.ok().headers(headers).build(); } /** @@ -126,7 +132,7 @@ class RepositorySearchController extends AbstractRepositoryRestController { verifySearchesExposed(resourceInformation); - return new ResponseEntity(HttpStatus.NO_CONTENT); + return ResponseEntity.noContent().build(); } /** @@ -169,9 +175,9 @@ class RepositorySearchController extends AbstractRepositoryRestController { */ @ResponseBody @RequestMapping(value = BASE_MAPPING + "/{search}", method = RequestMethod.GET) - public ResponseEntity executeSearch(RootResourceInformation resourceInformation, + public ResponseEntity executeSearch(RootResourceInformation resourceInformation, @RequestParam MultiValueMap parameters, @PathVariable String search, DefaultedPageable pageable, - Sort sort, PersistentEntityResourceAssembler assembler) { + Sort sort, PersistentEntityResourceAssembler assembler, @RequestHeader HttpHeaders headers) { Method method = checkExecutability(resourceInformation, search); Object result = executeQueryMethod(resourceInformation.getInvoker(), parameters, method, pageable, sort, assembler); @@ -180,7 +186,40 @@ class RepositorySearchController extends AbstractRepositoryRestController { MethodResourceMapping methodMapping = searchMappings.getExportedMethodMappingForPath(search); Class domainType = methodMapping.getReturnedDomainType(); - return new ResponseEntity(toResource(result, assembler, domainType, null), HttpStatus.OK); + return toResource(result, assembler, domainType, null, headers, resourceInformation); + } + + /** + * Turns the given source into a {@link ResourceSupport} if needed and possible. Uses the given + * {@link PersistentEntityResourceAssembler} for the actual conversion. + * + * @param source can be must not be {@literal null}. + * @param assembler must not be {@literal null}. + * @param domainType the domain type in case the source is an empty iterable, must not be {@literal null}. + * @param baseLink can be {@literal null}. + * @return + */ + protected ResponseEntity toResource(final Object source, final PersistentEntityResourceAssembler assembler, + Class domainType, Link baseLink, HttpHeaders headers, RootResourceInformation information) { + + if (source instanceof Iterable) { + return ResponseEntity.ok(toResources((Iterable) source, assembler, domainType, baseLink)); + } else if (source == null) { + throw new ResourceNotFoundException(); + } else if (ClassUtils.isPrimitiveOrWrapper(source.getClass())) { + return ResponseEntity.ok(source); + } + + PersistentEntity entity = information.getPersistentEntity(); + + return resourceStatus.getStatusAndHeaders(headers, source, entity).toResponseEntity(// + new Supplier() { + + @Override + public PersistentEntityResource get() { + return assembler.toFullResource(source); + } + }); } /** @@ -199,13 +238,17 @@ class RepositorySearchController extends AbstractRepositoryRestController { @RequestMapping(value = BASE_MAPPING + "/{search}", method = RequestMethod.GET, // produces = { "application/x-spring-data-compact+json" }) public ResourceSupport executeSearchCompact(RootResourceInformation resourceInformation, - @RequestParam MultiValueMap parameters, @PathVariable String repository, - @PathVariable String search, DefaultedPageable pageable, Sort sort, PersistentEntityResourceAssembler assembler) { + @RequestHeader HttpHeaders headers, @RequestParam MultiValueMap parameters, + @PathVariable String repository, @PathVariable String search, DefaultedPageable pageable, Sort sort, + PersistentEntityResourceAssembler assembler) { Method method = checkExecutability(resourceInformation, search); Object result = executeQueryMethod(resourceInformation.getInvoker(), parameters, method, pageable, sort, assembler); ResourceMetadata metadata = resourceInformation.getResourceMetadata(); - Object resource = toResource(result, assembler, metadata.getDomainType(), null); + ResponseEntity entity = toResource(result, assembler, metadata.getDomainType(), null, headers, + resourceInformation); + Object resource = entity.getBody(); + List links = new ArrayList(); if (resource instanceof Resources && ((Resources) resource).getContent() != null) { diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ResourceStatus.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ResourceStatus.java new file mode 100644 index 000000000..ca1c05a23 --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/ResourceStatus.java @@ -0,0 +1,96 @@ +/* + * Copyright 2016 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; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.rest.core.util.Supplier; +import org.springframework.data.rest.webmvc.support.ETag; +import org.springframework.hateoas.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * Simple abstraction to capture the status of a resource to determine whether it has been modified or not and produce + * {@link ResponseEntity} via its {@link StatusAndHeaders} sub component. + * + * @author Oliver Gierke + * @since 2.6 + * @soundtrack Blumentopf - Dass ich nicht lache (Kein Zufall) + */ +@RequiredArgsConstructor(staticName = "of") +class ResourceStatus { + + private final @NonNull HttpHeadersPreparer preparer; + + /** + * Returns the {@link StatusAndHeaders} calculated from the given {@link HttpHeaders}, domain object and + * {@link RootResourceInformation. + * + * @param requestHeaders must not be {@literal null}. + * @param domainObject must not be {@literal null}. + * @param information + * @return + */ + public StatusAndHeaders getStatusAndHeaders(HttpHeaders requestHeaders, Object domainObject, + PersistentEntity entity) { + + // Check ETag for If-Non-Match + + List ifNoneMatch = requestHeaders.getIfNoneMatch(); + ETag eTag = ifNoneMatch.isEmpty() ? ETag.NO_ETAG : ETag.from(ifNoneMatch.get(0)); + HttpHeaders responseHeaders = preparer.prepareHeaders(entity, domainObject); + + // Check last modification for If-Modified-Since + + return eTag.matches(entity, domainObject) || preparer.isObjectStillValid(domainObject, requestHeaders) + ? StatusAndHeaders.notModified(responseHeaders) : StatusAndHeaders.modified(responseHeaders); + } + + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public static class StatusAndHeaders { + + private final @NonNull HttpHeaders headers; + private final @Getter(AccessLevel.PACKAGE) boolean modified; + + private static StatusAndHeaders notModified(HttpHeaders headers) { + return new StatusAndHeaders(headers, false); + } + + private static StatusAndHeaders modified(HttpHeaders headers) { + return new StatusAndHeaders(headers, true); + } + + /** + * Creates a {@link ResponseEntity} based on the given {@link PersistentEntityResource}. + * + * @param supplier a {@link Supplier} to provide a {@link PersistentEntityResource} eventually, must not be + * {@literal null}. + * @return + */ + public ResponseEntity> toResponseEntity(Supplier supplier) { + return modified ? new ResponseEntity>(supplier.get(), headers, HttpStatus.OK) + : new ResponseEntity>(headers, HttpStatus.NOT_MODIFIED); + } + } +} 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 ff2381371..19c058a3f 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 @@ -77,6 +77,7 @@ import org.springframework.data.rest.webmvc.BasePathAwareController; import org.springframework.data.rest.webmvc.BasePathAwareHandlerMapping; import org.springframework.data.rest.webmvc.BaseUri; import org.springframework.data.rest.webmvc.EmbeddedResourcesAssembler; +import org.springframework.data.rest.webmvc.HttpHeadersPreparer; import org.springframework.data.rest.webmvc.ProfileResourceProcessor; import org.springframework.data.rest.webmvc.RepositoryRestController; import org.springframework.data.rest.webmvc.RepositoryRestExceptionHandler; @@ -773,6 +774,11 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon return new MappingAuditableBeanWrapperFactory(persistentEntities()); } + @Bean + public HttpHeadersPreparer httpHeadersPreparer() { + return new HttpHeadersPreparer(auditableBeanWrapperFactory()); + } + @Bean public SelfLinkProvider selfLinkProvider() { return new DefaultSelfLinkProvider(persistentEntities(), entityLinks(), getEntityLookups()); diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/ResourceStatusUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/ResourceStatusUnitTests.java new file mode 100644 index 000000000..f022c8244 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/ResourceStatusUnitTests.java @@ -0,0 +1,120 @@ +/* + * Copyright 2016 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; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; + +import lombok.Value; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.data.annotation.Version; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentEntity; +import org.springframework.data.keyvalue.core.mapping.context.KeyValueMappingContext; +import org.springframework.data.rest.core.util.Supplier; +import org.springframework.data.rest.webmvc.ResourceStatus.StatusAndHeaders; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; + +/** + * Unit tests for {@link ResourceStatus}. + * + * @author Oliver Gierke + */ +@RunWith(MockitoJUnitRunner.class) +public class ResourceStatusUnitTests { + + ResourceStatus status; + KeyValuePersistentEntity entity; + + @Mock HttpHeadersPreparer preparer; + @Mock Supplier supplier; + + @Before + public void setUp() { + + this.status = ResourceStatus.of(preparer); + + KeyValueMappingContext context = new KeyValueMappingContext(); + this.entity = context.getPersistentEntity(Sample.class); + + doReturn(new HttpHeaders()).when(preparer).prepareHeaders(eq(entity), Matchers.any()); + } + + /** + * @see DATAREST-835 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsNullPreparer() { + ResourceStatus.of(null); + } + + /** + * @see DATAREST-835 + */ + @Test + public void returnsModifiedIfNoHeadersGiven() { + assertModified(status.getStatusAndHeaders(new HttpHeaders(), new Sample(0), entity)); + } + + /** + * @see DATAREST-835 + */ + @Test + public void returnsNotModifiedForEntityWithRequestedETag() { + + HttpHeaders headers = new HttpHeaders(); + headers.setIfNoneMatch("\"1\""); + + assertNotModified(status.getStatusAndHeaders(headers, new Sample(1), entity)); + } + + /** + * @see DATAREST-835 + */ + @Test + public void returnsNotModifiedIfEntityIsStillConsideredValid() { + + doReturn(true).when(preparer).isObjectStillValid(Matchers.any(), Matchers.any(HttpHeaders.class)); + + assertNotModified(status.getStatusAndHeaders(new HttpHeaders(), new Sample(0), entity)); + } + + private void assertModified(StatusAndHeaders statusAndHeaders) { + + assertThat(statusAndHeaders.isModified(), is(true)); + assertThat(statusAndHeaders.toResponseEntity(supplier).getStatusCode(), is(HttpStatus.OK)); + verify(supplier).get(); + } + + private void assertNotModified(StatusAndHeaders statusAndHeaders) { + + assertThat(statusAndHeaders.isModified(), is(false)); + assertThat(statusAndHeaders.toResponseEntity(supplier).getStatusCode(), is(HttpStatus.NOT_MODIFIED)); + } + + @Value + static class Sample { + @Version int version; + } +}