From 216686c2b6d5e33e426fcc9e473c5b024722cc38 Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Tue, 25 Mar 2014 10:44:28 +0100 Subject: [PATCH] DATAREST-330 - Exposed resources now support HEAD requests where reasonable. The collection and item resources as well as the search and query method resources now answer HEAD requests according to the repository definition. --- .../webmvc/RepositoryEntityController.java | 93 +++++++++++++++---- .../webmvc/RepositorySearchController.java | 62 ++++++++++--- .../rest/webmvc/RootResourceInformation.java | 9 +- ...itoryEntityControllerIntegrationTests.java | 39 ++++++++ ...itorySearchControllerIntegrationTests.java | 40 ++++++++ .../RootResourceInformationUnitTests.java | 51 ++++++++++ .../data/rest/webmvc/jpa/Address.java | 2 +- src/docbkx/repository-resources.xml | 25 ++++- 8 files changed, 288 insertions(+), 33 deletions(-) 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 035d16363..a288d3d1b 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 @@ -104,6 +104,39 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem this.publisher = publisher; } + /** + * HEAD /{repository} + * + * @param resourceInformation + * @return + * @throws HttpRequestMethodNotSupportedException + */ + @RequestMapping(value = BASE_MAPPING, method = RequestMethod.HEAD) + public ResponseEntity headCollectionResource(RootResourceInformation resourceInformation) + throws HttpRequestMethodNotSupportedException { + + resourceInformation.verifySupportedMethod(HttpMethod.HEAD, ResourceType.COLLECTION); + + RepositoryInvoker invoker = resourceInformation.getInvoker(); + + if (null == invoker) { + throw new ResourceNotFoundException(); + } + + return new ResponseEntity(HttpStatus.NO_CONTENT); + } + + /** + * GET /{repository} - Returns the collection resource (paged or unpaged). + * + * @param resourceInformation + * @param pageable + * @param sort + * @param assembler + * @return + * @throws ResourceNotFoundException + * @throws HttpRequestMethodNotSupportedException + */ @ResponseBody @RequestMapping(value = BASE_MAPPING, method = RequestMethod.GET) public Resources listEntities(final RootResourceInformation resourceInformation, Pageable pageable, Sort sort) @@ -178,6 +211,25 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem return createAndReturn(payload.getContent(), resourceInformation.getInvoker()); } + /** + * HEAD /{repsoitory}/{id} + * + * @param resourceInformation + * @param id + * @return + * @throws HttpRequestMethodNotSupportedException + */ + @RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.HEAD) + public ResponseEntity headItemResource(RootResourceInformation resourceInformation, @BackendId Serializable id) + throws HttpRequestMethodNotSupportedException { + + if (getItemResource(resourceInformation, id) != null) { + return new ResponseEntity(HttpStatus.NO_CONTENT); + } + + throw new ResourceNotFoundException(); + } + /** * GET /{repository}/{id} - Returns a single entity. * @@ -190,15 +242,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem public ResponseEntity> getSingleEntity(RootResourceInformation resourceInformation, @BackendId Serializable id) throws HttpRequestMethodNotSupportedException { - resourceInformation.verifySupportedMethod(HttpMethod.GET, ResourceType.ITEM); - - RepositoryInvoker repoMethodInvoker = resourceInformation.getInvoker(); - - if (!repoMethodInvoker.exposesFindOne()) { - return new ResponseEntity>(HttpStatus.NOT_FOUND); - } - - Object domainObj = repoMethodInvoker.invokeFindOne(id); + Object domainObj = getItemResource(resourceInformation, id); if (domainObj == null) { return new ResponseEntity>(HttpStatus.NOT_FOUND); @@ -279,14 +323,6 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem resourceInformation.verifySupportedMethod(HttpMethod.DELETE, ResourceType.ITEM); RepositoryInvoker invoker = resourceInformation.getInvoker(); - - // TODO: re-enable not exposing delete method if hidden - - // ResourceMapping methodMapping = repoRequest.getRepositoryResourceMapping().getResourceMappingFor("delete"); - // if (null != methodMapping && !methodMapping.isExported()) { - // throw new HttpRequestMethodNotSupportedException("DELETE"); - // } - Object domainObj = invoker.invokeFindOne(id); publisher.publishEvent(new BeforeDeleteEvent(domainObj)); @@ -362,4 +398,27 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem String selfLink = assembler.getSelfLinkFor(source).getHref(); headers.setLocation(new UriTemplate(selfLink).expand()); } + + /** + * Returns the object backing the item resource for the given {@link RootResourceInformation} and id. + * + * @param resourceInformation + * @param id + * @return + * @throws HttpRequestMethodNotSupportedException + * @throws {@link ResourceNotFoundException} + */ + private Object getItemResource(RootResourceInformation resourceInformation, Serializable id) + throws HttpRequestMethodNotSupportedException, ResourceNotFoundException { + + resourceInformation.verifySupportedMethod(HttpMethod.GET, ResourceType.ITEM); + + RepositoryInvoker repoMethodInvoker = resourceInformation.getInvoker(); + + if (!repoMethodInvoker.exposesFindOne()) { + throw new ResourceNotFoundException(); + } + + return repoMethodInvoker.invokeFindOne(id); + } } 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 da8e01684..12eaee44a 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,7 +28,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.rest.core.invoke.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.web.PagedResourcesAssembler; import org.springframework.hateoas.EntityLinks; @@ -38,6 +37,7 @@ import org.springframework.hateoas.Links; import org.springframework.hateoas.Resource; import org.springframework.hateoas.ResourceSupport; import org.springframework.hateoas.Resources; +import org.springframework.http.HttpEntity; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; @@ -90,7 +90,22 @@ class RepositorySearchController extends AbstractRepositoryRestController { } /** - * Exposes links to the individual search resources exposed by the backing repository. + * HEAD /{repository}/search - Checks whether the search resource is present. + * + * @param resourceInformation + * @return + */ + @RequestMapping(value = BASE_MAPPING, method = RequestMethod.HEAD) + public HttpEntity headForSearches(RootResourceInformation resourceInformation) { + + verifySearchesExposed(resourceInformation); + + return new ResponseEntity(HttpStatus.NO_CONTENT); + } + + /** + * GET /{repository}/search - Exposes links to the individual search resources exposed by the backing + * repository. * * @param resourceInformation * @return @@ -99,11 +114,7 @@ class RepositorySearchController extends AbstractRepositoryRestController { @RequestMapping(value = BASE_MAPPING, method = RequestMethod.GET) public ResourceSupport listSearches(RootResourceInformation resourceInformation) { - SearchResourceMappings resourceMappings = resourceInformation.getSearchMappings(); - - if (!resourceMappings.isExported()) { - throw new ResourceNotFoundException(); - } + verifySearchesExposed(resourceInformation); Links queryMethodLinks = getSearchLinks(resourceInformation.getDomainType()); @@ -176,6 +187,20 @@ class RepositorySearchController extends AbstractRepositoryRestController { return new Resources>(EMPTY_RESOURCE_LIST, links); } + /** + * Handles a {@code HEAD} request for individual searches. + * + * @param information + * @param search + * @return + */ + @RequestMapping(value = BASE_MAPPING + "/{search}", method = RequestMethod.HEAD) + public ResponseEntity headForSearch(RootResourceInformation information, @PathVariable String search) { + + checkExecutability(information, search); + return new ResponseEntity(HttpStatus.NO_CONTENT); + } + /** * Checks that the given request is actually executable. Will reject execution if we don't find a search with the * given name. @@ -186,12 +211,7 @@ class RepositorySearchController extends AbstractRepositoryRestController { */ private Method checkExecutability(RootResourceInformation resourceInformation, String searchName) { - ResourceMetadata metadata = resourceInformation.getResourceMetadata(); - SearchResourceMappings searchMapping = metadata.getSearchResourceMappings(); - - if (!searchMapping.isExported()) { - throw new ResourceNotFoundException(); - } + SearchResourceMappings searchMapping = verifySearchesExposed(resourceInformation); Method method = searchMapping.getMappedMethod(searchName); @@ -260,4 +280,20 @@ class RepositorySearchController extends AbstractRepositoryRestController { String parameterString = StringUtils.collectionToCommaDelimitedString(parameters); return parameters.isEmpty() ? "" : String.format(PARAMETER_NAME_TEMPALTE_PATTERN, parameterString); } + + /** + * Verifies that the given {@link RootResourceInformation} has searches exposed. + * + * @param resourceInformation + */ + private SearchResourceMappings verifySearchesExposed(RootResourceInformation resourceInformation) { + + SearchResourceMappings resourceMappings = resourceInformation.getSearchMappings(); + + if (!resourceMappings.isExported()) { + throw new ResourceNotFoundException(); + } + + return resourceMappings; + } } diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RootResourceInformation.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RootResourceInformation.java index 2d8174746..3b0d19512 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RootResourceInformation.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/RootResourceInformation.java @@ -95,6 +95,7 @@ class RootResourceInformation { if (invoker.exposesFindAll()) { methods.add(HttpMethod.GET); + methods.add(HttpMethod.HEAD); } if (invoker.exposesSave()) { @@ -111,6 +112,7 @@ class RootResourceInformation { if (invoker.exposesFindOne()) { methods.add(HttpMethod.GET); + methods.add(HttpMethod.HEAD); } if (invoker.exposesSave()) { @@ -147,11 +149,16 @@ class RootResourceInformation { * * @param httpMethod must not be {@literal null}. * @param resourceType must not be {@literal null}. + * @throws ResourceNotFoundException if the repository is not exported at all. * @throws HttpRequestMethodNotSupportedException if the {@link ResourceType} does not support the given * {@link HttpMethod}. Will contain all supported methods as indicators for clients. */ public void verifySupportedMethod(HttpMethod httpMethod, ResourceType resourceType) - throws HttpRequestMethodNotSupportedException { + throws HttpRequestMethodNotSupportedException, ResourceNotFoundException { + + if (!resourceMetadata.isExported()) { + throw new ResourceNotFoundException(); + } Assert.notNull(httpMethod, "HTTP method must not be null!"); Assert.notNull(resourceType, "Resource type must not be null!"); 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 ed5904898..c4ce2a4f3 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 @@ -24,9 +24,11 @@ import org.springframework.data.repository.support.Repositories; import org.springframework.data.rest.core.config.RepositoryRestConfiguration; import org.springframework.data.rest.webmvc.jpa.Address; import org.springframework.data.rest.webmvc.jpa.AddressRepository; +import org.springframework.data.rest.webmvc.jpa.CreditCard; import org.springframework.data.rest.webmvc.jpa.JpaRepositoryConfig; import org.springframework.data.rest.webmvc.jpa.Order; import org.springframework.data.rest.webmvc.jpa.Person; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ContextConfiguration; import org.springframework.transaction.annotation.Transactional; @@ -83,4 +85,41 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll assertThat(entity.getHeaders().getLocation().toString(), not(endsWith("{?projection}"))); } + + /** + * @see DATAREST-330 + */ + @Test + public void exposesHeadForCollectionResourceIfExported() throws Exception { + ResponseEntity entity = controller.headCollectionResource(getResourceInformation(Person.class)); + assertThat(entity.getStatusCode(), is(HttpStatus.NO_CONTENT)); + } + + /** + * @see DATAREST-330 + */ + @Test(expected = ResourceNotFoundException.class) + public void doesNotExposeHeadForCollectionResourceIfNotExported() throws Exception { + controller.headCollectionResource(getResourceInformation(CreditCard.class)); + } + + /** + * @see DATAREST-330 + */ + @Test + public void exposesHeadForItemResourceIfExported() throws Exception { + + Address address = repository.save(new Address()); + + ResponseEntity entity = controller.headItemResource(getResourceInformation(Address.class), address.id); + assertThat(entity.getStatusCode(), is(HttpStatus.NO_CONTENT)); + } + + /** + * @see DATAREST-330 + */ + @Test(expected = ResourceNotFoundException.class) + public void doesNotExposeHeadForItemResourceIfNotExisting() throws Exception { + controller.headItemResource(getResourceInformation(CreditCard.class), 1L); + } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java index 8eb4e5444..d71aada45 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RepositorySearchControllerIntegrationTests.java @@ -94,4 +94,44 @@ public class RepositorySearchControllerIntegrationTests extends AbstractControll ResourceMetadata metadata = getMetadata(Person.class); tester.withContentResource(new HasSelfLink(BASE.slash(metadata.getPath()).slash("{id}"))); } + + /** + * @see DATAREST-330 + */ + @Test(expected = ResourceNotFoundException.class) + public void doesNotExposeHeadForSearchResourceIfResourceDoesnHaveSearches() { + controller.headForSearches(getResourceInformation(Order.class)); + } + + /** + * @see DATAREST-330 + */ + @Test(expected = ResourceNotFoundException.class) + public void exposesHeadForSearchResourceIfResourceIsNotExposed() { + controller.headForSearches(getResourceInformation(CreditCard.class)); + } + + /** + * @see DATAREST-330 + */ + @Test + public void exposesHeadForSearchResourceIfResourceIsExposed() { + controller.headForSearches(getResourceInformation(Person.class)); + } + + /** + * @see DATAREST-330 + */ + @Test + public void exposesHeadForExistingQueryMethodResource() { + controller.headForSearch(getResourceInformation(Person.class), "findByCreatedUsingISO8601Date"); + } + + /** + * @see DATAREST-330 + */ + @Test(expected = ResourceNotFoundException.class) + public void doesNotExposeHeadForInvalidQueryMethodResource() { + controller.headForSearch(getResourceInformation(Person.class), "foobar"); + } } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RootResourceInformationUnitTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RootResourceInformationUnitTests.java index cd7819824..0ad386b5f 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RootResourceInformationUnitTests.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/RootResourceInformationUnitTests.java @@ -32,6 +32,7 @@ import org.mockito.stubbing.Answer; import org.springframework.data.mapping.PersistentEntity; import org.springframework.data.rest.core.invoke.RepositoryInvoker; import org.springframework.data.rest.core.mapping.ResourceMetadata; +import org.springframework.web.HttpRequestMethodNotSupportedException; /** * Unit tests for {@link RootResourceInformation}. @@ -99,6 +100,56 @@ public class RootResourceInformationUnitTests { assertThat(information.supports(POST, ITEM), is(false)); } + /** + * @see DATAREST-330 + */ + @Test + public void supportsHeadIfFindAllIsExposed() { + + when(invoker.exposesFindAll()).thenReturn(true); + assertThat(information.supports(HEAD, COLLECTION), is(true)); + } + + /** + * @see DATAREST-330 + */ + @Test + public void doesNotSupportHeadIfFindAllIsNotExposed() { + + when(invoker.exposesFindAll()).thenReturn(false); + assertThat(information.supports(HEAD, COLLECTION), is(false)); + } + + /** + * @see DATAREST-330 + */ + @Test + public void supportsHeadIfFindOneIsExposed() { + + when(invoker.exposesFindOne()).thenReturn(true); + assertThat(information.supports(HEAD, ITEM), is(true)); + } + + /** + * @see DATAREST-330 + */ + @Test + public void doesNotSupportHeadIfFindOneIsNotExposed() { + + when(invoker.exposesFindOne()).thenReturn(false); + assertThat(information.supports(HEAD, ITEM), is(false)); + } + + /** + * @see DATAREST-330 + */ + @Test(expected = ResourceNotFoundException.class) + public void throwsExceptionOnVerificationIfResourceIsNotExported() throws HttpRequestMethodNotSupportedException { + + when(metadata.isExported()).thenReturn(false); + information.verifySupportedMethod(HEAD, COLLECTION); + } + /** * Helper class to default boolean methods to return {@literal true} instead of {@literal false} by default. * diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Address.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Address.java index 086ce4563..b4ac3fb21 100644 --- a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Address.java +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/jpa/Address.java @@ -25,5 +25,5 @@ import javax.persistence.Id; @Entity public class Address { - @Id @GeneratedValue Long id; + public @Id @GeneratedValue Long id; } diff --git a/src/docbkx/repository-resources.xml b/src/docbkx/repository-resources.xml index 48443a7d3..12497e036 100644 --- a/src/docbkx/repository-resources.xml +++ b/src/docbkx/repository-resources.xml @@ -21,7 +21,7 @@ For this repository, Spring Data REST exposes a collection resource at /orders. The path is derived from the uncapitalized, pluralized, simple class name of the domain class being managed. It also - exposes a an item resource for each of the items managed by te repository + exposes an item resource for each of the items managed by the repository under the URI template /orders/{id}. By default the HTTP methods to interact with these resources map to @@ -191,6 +191,12 @@ +
+ <code>HEAD</code> + + Returns whether the collection resource is available. +
+
<code>POST</code> @@ -283,6 +289,12 @@
+
+ <code>HEAD</code> + + Returns whether the item resource is available. +
+
<code>PUT</code> @@ -513,6 +525,12 @@ pointing to it will be a URI template containing the pagination parameters. +
+
+ <code>HEAD</code> + + Returns whether the search resource is available. A 404 return + code indicates no query method resources available at all.
@@ -572,6 +590,11 @@ + +
+ <code>HEAD</code> + + Returns whether a query method resource is available.