DATAREST-658 - HEAD requests now return resource links.

HEAD requests to both item and collection resource now return the top-level links as a Link header. Fixed HEAD requests to the item resource now use the raw domain object instead of the projected resource to calculate ETag and Last-Modified headers.
This commit is contained in:
Oliver Gierke
2015-08-22 20:01:52 +02:00
parent 8ed188cbc2
commit 1c1766ba12
4 changed files with 143 additions and 69 deletions

View File

@@ -54,6 +54,7 @@ import org.springframework.data.rest.webmvc.support.ETagDoesntMatchException;
import org.springframework.data.rest.webmvc.support.RepositoryEntityLinks;
import org.springframework.data.web.PagedResourcesAssembler;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Links;
import org.springframework.hateoas.PagedResources;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceSupport;
@@ -87,6 +88,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
MediaType.APPLICATION_JSON_VALUE);
private static final String ACCEPT_HEADER = "Accept";
private static final String LINK_HEADER = "Link";
private final RepositoryEntityLinks entityLinks;
private final RepositoryRestConfiguration config;
@@ -94,6 +96,18 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
private ApplicationEventPublisher publisher;
/**
* Creates a new {@link RepositoryEntityController} for the given {@link Repositories},
* {@link RepositoryRestConfiguration}, {@link RepositoryEntityLinks}, {@link PagedResourcesAssembler},
* {@link ConversionService} and {@link AuditableBeanWrapperFactory}.
*
* @param repositories must not be {@literal null}.
* @param config must not be {@literal null}.
* @param entityLinks must not be {@literal null}.
* @param assembler must not be {@literal null}.
* @param conversionService must not be {@literal null}.
* @param auditableBeanWrapperFactory must not be {@literal null}.
*/
@Autowired
public RepositoryEntityController(Repositories repositories, RepositoryRestConfiguration config,
RepositoryEntityLinks entityLinks, PagedResourcesAssembler<Object> assembler,
@@ -143,8 +157,8 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
* @since 2.2
*/
@RequestMapping(value = BASE_MAPPING, method = RequestMethod.HEAD)
public ResponseEntity<?> headCollectionResource(RootResourceInformation resourceInformation)
throws HttpRequestMethodNotSupportedException {
public ResponseEntity<?> headCollectionResource(RootResourceInformation resourceInformation,
DefaultedPageable pageable) throws HttpRequestMethodNotSupportedException {
resourceInformation.verifySupportedMethod(HttpMethod.HEAD, ResourceType.COLLECTION);
@@ -154,7 +168,13 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
throw new ResourceNotFoundException();
}
return new ResponseEntity<Object>(HttpStatus.NO_CONTENT);
List<Link> links = getCollectionResourceLinks(resourceInformation, pageable);
links.add(0, getDefaultSelfLink());
HttpHeaders headers = new HttpHeaders();
headers.add(LINK_HEADER, new Links(links).toString());
return new ResponseEntity<Object>(headers, HttpStatus.NO_CONTENT);
}
/**
@@ -171,8 +191,8 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
@ResponseBody
@RequestMapping(value = BASE_MAPPING, method = RequestMethod.GET)
public Resources<?> getCollectionResource(RootResourceInformation resourceInformation, DefaultedPageable pageable,
Sort sort, PersistentEntityResourceAssembler assembler) throws ResourceNotFoundException,
HttpRequestMethodNotSupportedException {
Sort sort, PersistentEntityResourceAssembler assembler)
throws ResourceNotFoundException, HttpRequestMethodNotSupportedException {
resourceInformation.verifySupportedMethod(HttpMethod.GET, ResourceType.COLLECTION);
@@ -190,32 +210,40 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
results = invoker.invokeFindAll(sort);
}
ResourceMetadata metadata = resourceInformation.getResourceMetadata();
Link baseLink = entityLinks.linkToPagedResource(resourceInformation.getDomainType(),
pageable.isDefault() ? null : pageable.getPageable());
Resources<?> result = toResources(results, assembler, metadata.getDomainType(), baseLink);
result.add(getCollectionResourceLinks(resourceInformation, pageable));
return result;
}
private List<Link> getCollectionResourceLinks(RootResourceInformation resourceInformation,
DefaultedPageable pageable) {
ResourceMetadata metadata = resourceInformation.getResourceMetadata();
SearchResourceMappings searchMappings = metadata.getSearchResourceMappings();
List<Link> links = new ArrayList<Link>();
links.add(new Link(ProfileController.getPath(this.config, metadata), ProfileResourceProcessor.PROFILE_REL));
if (searchMappings.isExported()) {
links.add(entityLinks.linkFor(metadata.getDomainType()).slash(searchMappings.getPath())
.withRel(searchMappings.getRel()));
}
Link baseLink = entityLinks.linkToPagedResource(resourceInformation.getDomainType(), pageable.isDefault() ? null
: pageable.getPageable());
links.add(new Link(ProfileController.getPath(this.config, metadata), ProfileResourceProcessor.PROFILE_REL));
Resources<?> result = toResources(results, assembler, metadata.getDomainType(), baseLink);
result.add(links);
return result;
return links;
}
@ResponseBody
@SuppressWarnings({ "unchecked" })
@RequestMapping(value = BASE_MAPPING, method = RequestMethod.GET, produces = {
"application/x-spring-data-compact+json", "text/uri-list" })
@RequestMapping(value = BASE_MAPPING, method = RequestMethod.GET,
produces = { "application/x-spring-data-compact+json", "text/uri-list" })
public Resources<?> getCollectionResourceCompact(RootResourceInformation repoRequest, DefaultedPageable pageable,
Sort sort, PersistentEntityResourceAssembler assembler) throws ResourceNotFoundException,
HttpRequestMethodNotSupportedException {
Sort sort, PersistentEntityResourceAssembler assembler)
throws ResourceNotFoundException, HttpRequestMethodNotSupportedException {
Resources<?> resources = getCollectionResource(repoRequest, pageable, sort, assembler);
List<Link> links = new ArrayList<Link>(resources.getLinks());
@@ -244,8 +272,9 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
@ResponseBody
@RequestMapping(value = BASE_MAPPING, method = RequestMethod.POST)
public ResponseEntity<ResourceSupport> postCollectionResource(RootResourceInformation resourceInformation,
PersistentEntityResource payload, PersistentEntityResourceAssembler assembler, @RequestHeader(
value = ACCEPT_HEADER, required = false) String acceptHeader) throws HttpRequestMethodNotSupportedException {
PersistentEntityResource payload, PersistentEntityResourceAssembler assembler,
@RequestHeader(value = ACCEPT_HEADER, required = false) String acceptHeader)
throws HttpRequestMethodNotSupportedException {
resourceInformation.verifySupportedMethod(HttpMethod.POST, ResourceType.COLLECTION);
@@ -291,9 +320,12 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
throw new ResourceNotFoundException();
}
PersistentEntityResource resource = assembler.toResource(domainObject);
Links links = new Links(assembler.toResource(domainObject).getLinks());
return new ResponseEntity<Object>(prepareHeaders(resource), HttpStatus.NO_CONTENT);
HttpHeaders headers = prepareHeaders(resourceInformation.getPersistentEntity(), domainObject);
headers.add(LINK_HEADER, links.toString());
return new ResponseEntity<Object>(headers, HttpStatus.NO_CONTENT);
}
/**
@@ -361,7 +393,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
public ResponseEntity<? extends ResourceSupport> 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);
@@ -399,7 +431,7 @@ class RepositoryEntityController extends AbstractRepositoryRestController implem
public ResponseEntity<ResourceSupport> 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);

View File

@@ -17,18 +17,19 @@
package org.springframework.data.rest.webmvc;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import static org.junit.Assume.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import net.minidev.json.JSONArray;
import java.util.List;
import java.util.Map;
import net.minidev.json.JSONArray;
import org.junit.Test;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Links;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
@@ -205,7 +206,8 @@ public abstract class CommonWebTests extends AbstractWebIntegrationTests {
mvc.perform(//
get(profileLink.expand().getHref()).//
accept(ALPS_MEDIA_TYPE)).//
accept(ALPS_MEDIA_TYPE))
.//
andExpect(status().isOk()).//
andExpect(content().contentType(ALPS_MEDIA_TYPE));
}
@@ -219,4 +221,25 @@ public abstract class CommonWebTests extends AbstractWebIntegrationTests {
mvc.perform(get("/index.html")).//
andExpect(status().isNotFound());
}
/**
* @see DATAREST-658
*/
@Test
public void collectionResourcesExposeLinksAsHeadersForHeadRequest() throws Exception {
for (String rel : expectedRootLinkRels()) {
Link link = client.discoverUnique(rel);
MockHttpServletResponse response = mvc.perform(head(link.expand().getHref()))//
.andExpect(status().isNoContent())//
.andReturn().getResponse();
Links links = Links.valueOf(response.getHeader("Link"));
assertThat(links.hasLink(Link.REL_SELF), is(true));
assertThat(links.hasLink("profile"), is(true));
}
}
}

View File

@@ -34,6 +34,7 @@ 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.data.rest.webmvc.support.DefaultedPageable;
import org.springframework.data.rest.webmvc.support.ETag;
import org.springframework.hateoas.Resource;
import org.springframework.http.HttpEntity;
@@ -92,8 +93,8 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll
RootResourceInformation information = getResourceInformation(Order.class);
PersistentEntityResource persistentEntityResource = PersistentEntityResource.build(new Order(new Person()),
entities.getPersistentEntity(Order.class)).build();
PersistentEntityResource persistentEntityResource = PersistentEntityResource
.build(new Order(new Person()), entities.getPersistentEntity(Order.class)).build();
ResponseEntity<?> entity = controller.putItemResource(information, persistentEntityResource, 1L, assembler,
ETag.NO_ETAG, MediaType.APPLICATION_JSON_VALUE);
@@ -106,7 +107,8 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll
*/
@Test
public void exposesHeadForCollectionResourceIfExported() throws Exception {
ResponseEntity<?> entity = controller.headCollectionResource(getResourceInformation(Person.class));
ResponseEntity<?> entity = controller.headCollectionResource(getResourceInformation(Person.class),
new DefaultedPageable(null, false));
assertThat(entity.getStatusCode(), is(HttpStatus.NO_CONTENT));
}
@@ -115,7 +117,7 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll
*/
@Test(expected = ResourceNotFoundException.class)
public void doesNotExposeHeadForCollectionResourceIfNotExported() throws Exception {
controller.headCollectionResource(getResourceInformation(CreditCard.class));
controller.headCollectionResource(getResourceInformation(CreditCard.class), new DefaultedPageable(null, false));
}
/**
@@ -181,10 +183,11 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll
List<String> value = entity.getHeaders().get("Accept-Patch");
assertThat(value, hasSize(3));
assertThat(value, hasItems(//
RestMediaTypes.JSON_PATCH_JSON.toString(), //
RestMediaTypes.MERGE_PATCH_JSON.toString(), //
MediaType.APPLICATION_JSON_VALUE));
assertThat(value,
hasItems(//
RestMediaTypes.JSON_PATCH_JSON.toString(), //
RestMediaTypes.MERGE_PATCH_JSON.toString(), //
MediaType.APPLICATION_JSON_VALUE));
}
/**
@@ -196,12 +199,11 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll
RootResourceInformation request = getResourceInformation(Order.class);
Order order = request.getInvoker().invokeSave(new Order(new Person()));
PersistentEntityResource persistentEntityResource = PersistentEntityResource.build(new Order(new Person()),
entities.getPersistentEntity(Order.class)).build();
PersistentEntityResource persistentEntityResource = PersistentEntityResource
.build(new Order(new Person()), entities.getPersistentEntity(Order.class)).build();
assertThat(
controller.putItemResource(request, persistentEntityResource, order.getId(), assembler, ETag.NO_ETAG,
MediaType.APPLICATION_JSON_VALUE).hasBody(), is(true));
assertThat(controller.putItemResource(request, persistentEntityResource, order.getId(), assembler, ETag.NO_ETAG,
MediaType.APPLICATION_JSON_VALUE).hasBody(), is(true));
}
/**
@@ -211,12 +213,11 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll
public void returnsBodyForCreatingPutIfAcceptHeaderPresentByDefault() throws HttpRequestMethodNotSupportedException {
RootResourceInformation request = getResourceInformation(Order.class);
PersistentEntityResource persistentEntityResource = PersistentEntityResource.build(new Order(new Person()),
entities.getPersistentEntity(Order.class)).build();
PersistentEntityResource persistentEntityResource = PersistentEntityResource
.build(new Order(new Person()), entities.getPersistentEntity(Order.class)).build();
assertThat(
controller.putItemResource(request, persistentEntityResource, 1L, assembler, ETag.NO_ETAG,
MediaType.APPLICATION_JSON_VALUE).hasBody(), is(true));
assertThat(controller.putItemResource(request, persistentEntityResource, 1L, assembler, ETag.NO_ETAG,
MediaType.APPLICATION_JSON_VALUE).hasBody(), is(true));
}
/**
@@ -226,12 +227,12 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll
public void returnsBodyForPostIfAcceptHeaderIsPresentByDefault() throws Exception {
RootResourceInformation request = getResourceInformation(Order.class);
PersistentEntityResource persistentEntityResource = PersistentEntityResource.build(new Order(new Person()),
entities.getPersistentEntity(Order.class)).build();
PersistentEntityResource persistentEntityResource = PersistentEntityResource
.build(new Order(new Person()), entities.getPersistentEntity(Order.class)).build();
assertThat(
controller.postCollectionResource(request, persistentEntityResource, assembler,
MediaType.APPLICATION_JSON_VALUE).hasBody(), is(true));
assertThat(controller
.postCollectionResource(request, persistentEntityResource, assembler, MediaType.APPLICATION_JSON_VALUE)
.hasBody(), is(true));
}
/**
@@ -241,12 +242,13 @@ public class RepositoryEntityControllerIntegrationTests extends AbstractControll
public void doesNotReturnBodyForPostIfNoAcceptHeaderPresentByDefault() throws Exception {
RootResourceInformation request = getResourceInformation(Order.class);
PersistentEntityResource persistentEntityResource = PersistentEntityResource.build(new Order(new Person()),
entities.getPersistentEntity(Order.class)).build();
PersistentEntityResource persistentEntityResource = PersistentEntityResource
.build(new Order(new Person()), entities.getPersistentEntity(Order.class)).build();
assertThat(controller.postCollectionResource(request, persistentEntityResource, assembler, null).hasBody(),
is(false));
assertThat(controller.postCollectionResource(request, persistentEntityResource, assembler, "").hasBody(), is(false));
assertThat(controller.postCollectionResource(request, persistentEntityResource, assembler, "").hasBody(),
is(false));
}
/**

View File

@@ -22,6 +22,8 @@ import static org.springframework.http.HttpHeaders.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import net.minidev.json.JSONArray;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
@@ -29,16 +31,14 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import net.minidev.json.JSONArray;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.rest.core.mapping.ResourceMappings;
import org.springframework.data.rest.webmvc.CommonWebTests;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Links;
import org.springframework.hateoas.RelProvider;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.context.ContextConfiguration;
@@ -240,8 +240,8 @@ public class JpaWebTests extends CommonWebTests {
Link peopleLink = client.discoverUnique("people");
MockHttpServletResponse bilbo = postAndGet(peopleLink,//
"{ \"firstName\" : \"Bilbo\", \"lastName\" : \"Baggins\" }",//
MockHttpServletResponse bilbo = postAndGet(peopleLink, //
"{ \"firstName\" : \"Bilbo\", \"lastName\" : \"Baggins\" }", //
MediaType.APPLICATION_JSON);
Link bilboLink = client.assertHasLinkWithRel("self", bilbo);
@@ -249,8 +249,8 @@ public class JpaWebTests extends CommonWebTests {
assertThat((String) JsonPath.read(bilbo.getContentAsString(), "$.firstName"), equalTo("Bilbo"));
assertThat((String) JsonPath.read(bilbo.getContentAsString(), "$.lastName"), equalTo("Baggins"));
MockHttpServletResponse frodo = putAndGet(bilboLink,//
"{ \"firstName\" : \"Frodo\" }",//
MockHttpServletResponse frodo = putAndGet(bilboLink, //
"{ \"firstName\" : \"Frodo\" }", //
MediaType.APPLICATION_JSON);
assertThat((String) JsonPath.read(frodo.getContentAsString(), "$.firstName"), equalTo("Frodo"));
@@ -411,7 +411,8 @@ public class JpaWebTests extends CommonWebTests {
String bilboWithFrodosLinks = createdPerson.getContentAsString().replace("Frodo", "Bilbo");
MockHttpServletResponse overwrittenResponse = putAndGet(frodoLink, bilboWithFrodosLinks, MediaType.APPLICATION_JSON);
MockHttpServletResponse overwrittenResponse = putAndGet(frodoLink, bilboWithFrodosLinks,
MediaType.APPLICATION_JSON);
client.assertHasLinkWithRel("self", overwrittenResponse);
assertJsonPathEquals("$.firstName", "Bilbo", overwrittenResponse);
@@ -588,15 +589,13 @@ public class JpaWebTests extends CommonWebTests {
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().is2xxSuccessful());
mvc.perform(patch(builder.build().toUriString()).content("{ \"saleItem\" : \"SpringyBurritos\" }")
.contentType(MediaType.APPLICATION_JSON).header(IF_MATCH, concurrencyTag))
.andExpect(status().is2xxSuccessful());
mvc.perform(
patch(builder.build().toUriString()).content("{ \"saleItem\" : \"SpringyTequila\" }")
.contentType(MediaType.APPLICATION_JSON).header(IF_MATCH, "\"falseETag\"")).andExpect(
status().isPreconditionFailed());
mvc.perform(patch(builder.build().toUriString()).content("{ \"saleItem\" : \"SpringyTequila\" }")
.contentType(MediaType.APPLICATION_JSON).header(IF_MATCH, "\"falseETag\""))
.andExpect(status().isPreconditionFailed());
}
/**
@@ -637,6 +636,24 @@ public class JpaWebTests extends CommonWebTests {
}
}
/**
* @see DATAREST-658
*/
@Test
public void returnsLinkHeadersForHeadRequestToItemResource() throws Exception {
MockHttpServletResponse response = client.request(client.discoverUnique("people"));
String personHref = JsonPath.read(response.getContentAsString(), "$._embedded.people[0]._links.self.href");
response = mvc.perform(head(personHref))//
.andExpect(status().isNoContent())//
.andReturn().getResponse();
Links links = Links.valueOf(response.getHeader("Link"));
assertThat(links.hasLink("self"), is(true));
assertThat(links.hasLink("person"), is(true));
}
private List<Link> preparePersonResources(Person primary, Person... persons) throws Exception {
Link peopleLink = client.discoverUnique("people");