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.
This commit is contained in:
Oliver Gierke
2014-03-25 10:44:28 +01:00
parent b5f8d97ba3
commit 5dbbbf9703
8 changed files with 292 additions and 35 deletions

View File

@@ -101,6 +101,39 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
this.publisher = publisher;
}
/**
* <code>HEAD /{repository}</code>
*
* @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<Object>(HttpStatus.NO_CONTENT);
}
/**
* <code>GET /{repository}</code> - 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<?> getCollectionResource(final RootResourceInformation resourceInformation,
@@ -181,6 +214,25 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
return createAndReturn(payload.getContent(), resourceInformation.getInvoker(), assembler);
}
/**
* <code>HEAD /{repsoitory}/{id}</code>
*
* @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<Object>(HttpStatus.NO_CONTENT);
}
throw new ResourceNotFoundException();
}
/**
* <code>GET /{repository}/{id}</code> - Returns a single entity.
*
@@ -194,15 +246,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
@BackendId Serializable id, PersistentEntityResourceAssembler assembler)
throws HttpRequestMethodNotSupportedException {
resourceInformation.verifySupportedMethod(HttpMethod.GET, ResourceType.ITEM);
RepositoryInvoker repoMethodInvoker = resourceInformation.getInvoker();
if (!repoMethodInvoker.exposesFindOne()) {
return new ResponseEntity<Resource<?>>(HttpStatus.NOT_FOUND);
}
Object domainObj = repoMethodInvoker.invokeFindOne(id);
Object domainObj = getItemResource(resourceInformation, id);
if (domainObj == null) {
return new ResponseEntity<Resource<?>>(HttpStatus.NOT_FOUND);
@@ -283,14 +327,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));
@@ -366,4 +402,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);
}
}

View File

@@ -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;
@@ -89,7 +89,22 @@ class RepositorySearchController extends AbstractRepositoryRestController {
}
/**
* Exposes links to the individual search resources exposed by the backing repository.
* <code>HEAD /{repository}/search</code> - 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<Object>(HttpStatus.NO_CONTENT);
}
/**
* <code>GET /{repository}/search</code> - Exposes links to the individual search resources exposed by the backing
* repository.
*
* @param resourceInformation
* @return
@@ -98,11 +113,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<Resource<?>>(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<Object> headForSearch(RootResourceInformation information, @PathVariable String search) {
checkExecutability(information, search);
return new ResponseEntity<Object>(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;
}
}

View File

@@ -95,6 +95,7 @@ public class RootResourceInformation {
if (invoker.exposesFindAll()) {
methods.add(HttpMethod.GET);
methods.add(HttpMethod.HEAD);
}
if (invoker.exposesSave()) {
@@ -111,6 +112,7 @@ public class RootResourceInformation {
if (invoker.exposesFindOne()) {
methods.add(HttpMethod.GET);
methods.add(HttpMethod.HEAD);
}
if (invoker.exposesSave()) {
@@ -147,11 +149,16 @@ public 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!");

View File

@@ -24,9 +24,11 @@ import org.springframework.data.mapping.context.PersistentEntities;
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;
@@ -85,4 +87,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);
}
}

View File

@@ -95,4 +95,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");
}
}

View File

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

View File

@@ -25,5 +25,5 @@ import javax.persistence.Id;
@Entity
public class Address {
@Id @GeneratedValue Long id;
public @Id @GeneratedValue Long id;
}

View File

@@ -22,7 +22,7 @@
at <code>/orders</code>. The path is derived from the uncapitalized,
pluralized, simple class name of the domain class being managed. It also
exposes an item resource for each of the items managed by the repository
under the URI template <uri>/orders/{id}</uri>. </para>
under the URI template <uri>/orders/{id}</uri>.</para>
<para>By default the HTTP methods to interact with these resources map to
the according methods of <interfacename>CrudRepository</interfacename>.
@@ -191,6 +191,12 @@
</simplesect>
</section>
<section>
<title><code>HEAD</code></title>
<para>Returns whether the collection resource is available.</para>
</section>
<section>
<title><code>POST</code></title>
@@ -283,6 +289,12 @@
</simplesect>
</section>
<section>
<title><code>HEAD</code></title>
<para>Returns whether the item resource is available.</para>
</section>
<section>
<title><code>PUT</code></title>
@@ -379,7 +391,7 @@
each of the associations the item resource has. The name and path of the
of the resource defaults to the name of the association property and can
be customized using <interfacename>@RestResource</interfacename> on the
association property. </para>
association property.</para>
<section>
<title>Supported HTTP methods</title>
@@ -408,7 +420,7 @@
<title>PUT</title>
<para>Binds the resource pointed to by the given URI(s) to the
resource. This </para>
resource. This</para>
<simplesect>
<title>Custom status codes</title>
@@ -514,6 +526,13 @@
parameters.</para>
</simplesect>
</section>
<section>
<title><code>HEAD</code></title>
<para>Returns whether the search resource is available. A 404 return
code indicates no query method resources available at all.</para>
</section>
</section>
</section>
@@ -573,6 +592,12 @@
</itemizedlist>
</simplesect>
</section>
<section>
<title><code>HEAD</code></title>
<para>Returns whether a query method resource is available.</para>
</section>
</section>
</section>
</chapter>