DATAREST-454 - Document conditional operations.
Added documentation of ETag and Last-Modified header handling with Spring Data managed domain types. Original pull request: #184.
This commit is contained in:
committed by
Oliver Gierke
parent
4294e9ec9a
commit
46b08b9fe5
@@ -16,6 +16,8 @@
|
||||
|
||||
package org.springframework.data.rest.webmvc.support;
|
||||
|
||||
import static org.springframework.http.HttpHeaders.*;
|
||||
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||
import org.springframework.web.context.request.NativeWebRequest;
|
||||
@@ -46,6 +48,6 @@ public class ETagArgumentResolver implements HandlerMethodArgumentResolver {
|
||||
@Override
|
||||
public ETag resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
|
||||
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
|
||||
return ETag.from(webRequest.getHeader("If-Match"));
|
||||
return ETag.from(webRequest.getHeader(IF_MATCH));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package org.springframework.data.rest.webmvc.jpa;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.Assert.*;
|
||||
import static org.springframework.data.rest.webmvc.util.TestUtils.*;
|
||||
import static org.springframework.http.HttpHeaders.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@@ -37,6 +38,7 @@ import org.springframework.data.rest.core.mapping.ResourceMappings;
|
||||
import org.springframework.data.rest.webmvc.CommonWebTests;
|
||||
import org.springframework.hateoas.Link;
|
||||
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;
|
||||
@@ -586,12 +588,12 @@ public class JpaWebTests extends CommonWebTests {
|
||||
|
||||
mvc.perform(
|
||||
patch(builder.build().toUriString()).content("{ \"saleItem\" : \"SpringyBurritos\" }")
|
||||
.contentType(MediaType.APPLICATION_JSON).header("If-Match", concurrencyTag)).andExpect(
|
||||
.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(
|
||||
.contentType(MediaType.APPLICATION_JSON).header(IF_MATCH, "\"falseETag\"")).andExpect(
|
||||
status().isPreconditionFailed());
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ package org.springframework.data.rest.webmvc.mongodb;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.Assert.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -192,12 +193,12 @@ public class MongoWebTests extends CommonWebTests {
|
||||
|
||||
mvc.perform(
|
||||
patch(builder.build().toUriString()).content("{ \"saleItem\" : \"SpringyBurritos\" }")
|
||||
.contentType(MediaType.APPLICATION_JSON).header("If-Match", concurrencyTag)).andExpect(
|
||||
.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", concurrencyTag)).andExpect(
|
||||
.contentType(MediaType.APPLICATION_JSON).header(IF_MATCH, concurrencyTag)).andExpect(
|
||||
status().isPreconditionFailed());
|
||||
}
|
||||
|
||||
|
||||
@@ -27,14 +27,16 @@ import org.springframework.data.mongodb.core.mapping.Document;
|
||||
/**
|
||||
* @author Pablo Lozano
|
||||
*/
|
||||
// tag::code[]
|
||||
@Document
|
||||
public class Receipt {
|
||||
|
||||
public @Id String id;
|
||||
public @Version Long version;
|
||||
public @LastModifiedDate Date date;
|
||||
public @LastModifiedDate Date date; // <1>
|
||||
|
||||
public String saleItem;
|
||||
public BigDecimal amount;
|
||||
|
||||
}
|
||||
// end::code[]
|
||||
|
||||
@@ -175,14 +175,16 @@ public class ETagUnitTests {
|
||||
assertThat(headers.containsKey("ETag"), is(false));
|
||||
}
|
||||
|
||||
// tag::versioned-sample[]
|
||||
public class Sample {
|
||||
|
||||
@Version Long version;
|
||||
@Version Long version; // <1>
|
||||
|
||||
Sample(Long version) {
|
||||
this.version = version;
|
||||
}
|
||||
}
|
||||
// end::versioned-sample[]
|
||||
|
||||
public class SampleWithoutVersion {}
|
||||
}
|
||||
|
||||
95
src/main/asciidoc/etags-and-other-conditionals.adoc
Normal file
95
src/main/asciidoc/etags-and-other-conditionals.adoc
Normal file
@@ -0,0 +1,95 @@
|
||||
[[conditional]]
|
||||
= Conditional Operations with Headers
|
||||
:spring-data-rest-root: ../../..
|
||||
|
||||
This section shows how Spring Data REST uses standard HTTP headers to enhance performance, conditionalize operations, and easily contribute to a more sophisticated frontend.
|
||||
|
||||
[[conditional.etag]]
|
||||
== ETag, If-Match, and If-None-Match headers
|
||||
|
||||
The http://tools.ietf.org/html/rfc7232#section-2.3[ETag header] provides a way to tag resources. This can prevent clients from overriding each other while also making it possible to reduce unnecessary calls.
|
||||
|
||||
The following code shows a POJO with a version number.
|
||||
|
||||
.spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/ETagUnitTests.java
|
||||
====
|
||||
[source,java,indent=0]
|
||||
----
|
||||
include::{spring-data-rest-root}/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/support/ETagUnitTests.java[tag=versioned-sample]
|
||||
----
|
||||
====
|
||||
|
||||
<1> Spring Data Commons's `@Version` annotation flags this field as a version marker.
|
||||
|
||||
This POJO, when served up as a REST resource by Spring Data REST, will have an *ETag* header with the value of the *version* field.
|
||||
|
||||
We can conditionally *PUT*, *PATCH*, or *DELETE* that resource if we supply a *If-Match* header like this:
|
||||
|
||||
----
|
||||
curl -v -X PATCH -H 'If-Match: <value of previous ETag>' ...
|
||||
----
|
||||
|
||||
Only if the resource's current ETag state matches this *If-Match* header will the operation be carried out. This prevents clients from stomping on each other. Two different clients can fetch the resource and have an identical ETag. If one client updates the resource, it will get a new ETag in the response. But the first client still has the old header. If that client attempts an update with the *If-Match* header, the update will fail because they no longer match. Instead, that client will receive an *HTTP/1.1 412 Precondition Failed* message to be sent back. The client can then catch up however is necessary.
|
||||
|
||||
WARNING: The term "version" may carrry different semantics with different data stores, and even different semantics within your application. Spring Data REST effectively delegates to the data store's metamodel to discern if a field is versioned, and if so, only allow the listed updates if ETags match.
|
||||
|
||||
The http://tools.ietf.org/html/rfc7232#section-3.2[If-None-Match header] provides an alternative. Instead of conditional updates, *If-None-Match* allow conditional queries.
|
||||
|
||||
----
|
||||
curl -v -H 'If-None-Match: <value of previous etag>' ...
|
||||
----
|
||||
|
||||
This command (by default) executes a *GET*. Spring Data REST will check for *If-None-Match* headers while doing a GET. If the header MATCHES the ETag, it will conclude nothing has changed, and instead of sending a copy of the resource, instead send back an *HTTP 304 Nothing Changed* status code. Semantically, it reads "If this supplied header value doesn't match the server-side version, then send me the whole resource, otherwise, don't send me anything."
|
||||
|
||||
NOTE: This POJO is from an ETag-based unit test, so it doesn't have `@Entity` (JPA) or `@Document` (MongoDB) annotations as expected in application code. It simply focuses on how a field with `@Version` results in an ETag header.
|
||||
|
||||
[[conditional.if-modified-since]]
|
||||
== If-Modified-Since header
|
||||
|
||||
The http://tools.ietf.org/html/rfc7232#section-3.3[If-Modified-Since header] provides a way to check if a resource has been updated since the last request to avoid resending the same data.
|
||||
|
||||
The following code shows how to capture the timestamp for updates:
|
||||
|
||||
.spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Receipt.java
|
||||
====
|
||||
[source,java]
|
||||
----
|
||||
include::{spring-data-rest-root}/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/mongodb/Receipt.java[tag=code]
|
||||
----
|
||||
====
|
||||
|
||||
<1> Spring Data Commons's `@LastModifiedDate` annotation allows capturing this information in multiple formats (JodaTime's `DateTime`, legacy Java `Date` and `Calendar`, JDK8 date/time types, as well as `long`/`Long`).
|
||||
|
||||
With this field, Spring Data REST will return a *Last-Modified* header like this:
|
||||
|
||||
----
|
||||
Last-Modified: Wed, 24 Jun 2015 20:28:15 GMT
|
||||
----
|
||||
|
||||
This value can be capture and used for subsequent queries to avoid fetching the same data twice if it hasn't been updated.
|
||||
|
||||
----
|
||||
curl -H "If-Modified-Since: Wed, 24 Jun 2015 20:28:15 GMT" ...
|
||||
----
|
||||
|
||||
With this simple command, you are asking that a resource only be fetched if it's changed since that time. If so, you will get a revised *Last-Modified* header to update the client. If not, you will receive an *HTTP 304 Not Modified* status code.
|
||||
|
||||
The header is perfectly formatted to send back for a future query.
|
||||
|
||||
WARNING: Don't mix and match header value with different queries. Results could be disastrous. ONLY use the header values when you are requesting the exact same URI and parameters.
|
||||
|
||||
[[headers.better-client-architecture]]
|
||||
== Architecting a more efficient frontend
|
||||
|
||||
ETags combined with the *If-Match* and *If-None-Match* headers empower you to build frontend that is more friendly to consumer's data plans and mobile battery lives.
|
||||
|
||||
. Identify the entities that need locking and add a version attribute. HTML5 nicely supports data-* attributes, so store it in the DOM somewhere like *data-etag* attribute.
|
||||
. Identify the entiries that would benefit from tracking most recent updates. When fetching these resources, store the *Last-Modified* in the DOM (*data-last-modified* perhaps).
|
||||
. When fetching resources, also embed self URIs into your DOM nodes (perhaps *data-uri* or *data-self*) so it's very easy to go back to the resource.
|
||||
. Adjust *PUT*/*PATCH*/*DELETE* operations to use *If-Match* and also handle HTTP 412 status codes.
|
||||
. Adjust *GET* operations to use *If-None-Match*, *If-Modified-Since*, and also handle HTTP 304 status codes.
|
||||
|
||||
By embedding *ETags* and *Last-Modified* values into your DOM (or perhaps elsewhere for a native mobile app), you can then reduce the consumption of data/battery by NOT retrieving the same thing over and over. You can also avoid colliding with other clients, and instead, be alerted when you need to reconcile differences.
|
||||
|
||||
Hence, with just a little tweaking on your frontend and some entity-level edits, the backend will serve up time sensitive details you can cash in on when building a customer-friendly client.
|
||||
|
||||
@@ -23,6 +23,7 @@ include::repository-resources.adoc[leveloffset=+1]
|
||||
include::paging-and-sorting.adoc[leveloffset=+1]
|
||||
include::representations.adoc[leveloffset=+1]
|
||||
include::projections-excerpts.adoc[leveloffset=+1]
|
||||
include::etags-and-other-conditionals.adoc[leveloffset=+1]
|
||||
include::validation.adoc[leveloffset=+1]
|
||||
include::events.adoc[leveloffset=+1]
|
||||
include::metadata.adoc[leveloffset=+1]
|
||||
|
||||
Reference in New Issue
Block a user