Skip to content

Commit 0f124ff

Browse files
authored
Merge pull request eugenp#6397 from rozagerardo/geroza/BAEL-11597_Move-and-update-etags-article
[BAEL-11597] Move and update Etags article
2 parents 4173f8a + 1e9efcd commit 0f124ff

File tree

18 files changed

+276
-266
lines changed

18 files changed

+276
-266
lines changed

spring-boot-rest/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ Module for the articles that are part of the Spring REST E-book:
88
6. [REST API Discoverability and HATEOAS](http://www.baeldung.com/restful-web-service-discoverability)
99
7. [Versioning a REST API](http://www.baeldung.com/rest-versioning)
1010
8. [Http Message Converters with the Spring Framework](http://www.baeldung.com/spring-httpmessageconverter-rest)
11+
9. [ETags for REST with Spring](http://www.baeldung.com/etags-for-rest-with-spring)

spring-boot-rest/src/main/java/com/baeldung/persistence/model/Foo.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import javax.persistence.GeneratedValue;
88
import javax.persistence.GenerationType;
99
import javax.persistence.Id;
10+
import javax.persistence.Version;
1011

1112
import com.thoughtworks.xstream.annotations.XStreamAlias;
1213

@@ -20,6 +21,9 @@ public class Foo implements Serializable {
2021

2122
@Column(nullable = false)
2223
private String name;
24+
25+
@Version
26+
private long version;
2327

2428
public Foo() {
2529
super();
@@ -49,6 +53,14 @@ public void setName(final String name) {
4953
this.name = name;
5054
}
5155

56+
public long getVersion() {
57+
return version;
58+
}
59+
60+
public void setVersion(long version) {
61+
this.version = version;
62+
}
63+
5264
//
5365

5466
@Override

spring-boot-rest/src/main/java/com/baeldung/spring/WebConfig.java

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import java.util.List;
44

5+
import org.springframework.boot.web.servlet.FilterRegistrationBean;
56
import org.springframework.context.annotation.Bean;
67
import org.springframework.context.annotation.Configuration;
78
import org.springframework.context.annotation.ImportResource;
89
import org.springframework.http.converter.HttpMessageConverter;
910
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
1011
import org.springframework.http.converter.xml.MarshallingHttpMessageConverter;
1112
import org.springframework.oxm.xstream.XStreamMarshaller;
13+
import org.springframework.web.filter.ShallowEtagHeaderFilter;
1214
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
1315

1416
@Configuration
@@ -34,16 +36,34 @@ public class WebConfig implements WebMvcConfigurer {
3436
// }
3537

3638
// Another possibility is to create a bean which will be automatically added to the Spring Boot Autoconfigurations
37-
// @Bean
38-
// public HttpMessageConverter<Object> createXmlHttpMessageConverter() {
39-
// final MarshallingHttpMessageConverter xmlConverter = new MarshallingHttpMessageConverter();
40-
//
41-
// final XStreamMarshaller xstreamMarshaller = new XStreamMarshaller();
42-
// xstreamMarshaller.setAutodetectAnnotations(true);
43-
// xmlConverter.setMarshaller(xstreamMarshaller);
44-
// xmlConverter.setUnmarshaller(xstreamMarshaller);
45-
//
46-
// return xmlConverter;
47-
// }
39+
// @Bean
40+
// public HttpMessageConverter<Object> createXmlHttpMessageConverter() {
41+
// final MarshallingHttpMessageConverter xmlConverter = new MarshallingHttpMessageConverter();
42+
//
43+
// final XStreamMarshaller xstreamMarshaller = new XStreamMarshaller();
44+
// xstreamMarshaller.setAutodetectAnnotations(true);
45+
// xmlConverter.setMarshaller(xstreamMarshaller);
46+
// xmlConverter.setUnmarshaller(xstreamMarshaller);
47+
//
48+
// return xmlConverter;
49+
// }
50+
51+
// Etags
52+
53+
// If we're not using Spring Boot we can make use of
54+
// AbstractAnnotationConfigDispatcherServletInitializer#getServletFilters
55+
@Bean
56+
public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter() {
57+
FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean = new FilterRegistrationBean<>( new ShallowEtagHeaderFilter());
58+
filterRegistrationBean.addUrlPatterns("/auth/foos/*");
59+
filterRegistrationBean.setName("etagFilter");
60+
return filterRegistrationBean;
61+
}
62+
63+
// We can also just declare the filter directly
64+
// @Bean
65+
// public ShallowEtagHeaderFilter shallowEtagHeaderFilter() {
66+
// return new ShallowEtagHeaderFilter();
67+
// }
4868

4969
}

spring-boot-rest/src/main/java/com/baeldung/web/controller/FooController.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.springframework.data.domain.Page;
1010
import org.springframework.data.domain.Pageable;
1111
import org.springframework.http.HttpStatus;
12+
import org.springframework.http.ResponseEntity;
1213
import org.springframework.web.bind.annotation.DeleteMapping;
1314
import org.springframework.web.bind.annotation.GetMapping;
1415
import org.springframework.web.bind.annotation.PathVariable;
@@ -45,6 +46,18 @@ public FooController() {
4546
}
4647

4748
// API
49+
50+
// Note: the global filter overrides the ETag value we set here. We can still analyze its behaviour in the Integration Test.
51+
@GetMapping(value = "/{id}/custom-etag")
52+
public ResponseEntity<Foo> findByIdWithCustomEtag(@PathVariable("id") final Long id,
53+
final HttpServletResponse response) {
54+
final Foo resourceById = RestPreconditions.checkFound(service.findOne(id));
55+
56+
eventPublisher.publishEvent(new SingleResourceRetrievedEvent(this, response));
57+
return ResponseEntity.ok()
58+
.eTag(Long.toString(resourceById.getVersion()))
59+
.body(resourceById);
60+
}
4861

4962
// read - one
5063

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!-- NOTE: web.xml is not used in Spring Boot. This is just for guidance, showing how an Etag Filter would be implemented using XML-based configs -->
2+
3+
<!-- <?xml version="1.0" encoding="UTF-8"?> -->
4+
<!-- <web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" -->
5+
<!-- xsi:schemaLocation=" -->
6+
<!-- http://java.sun.com/xml/ns/javaee -->
7+
<!-- http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" id="WebApp_ID" version="3.0" -->
8+
<!-- > -->
9+
10+
<!-- <filter> -->
11+
<!-- <filter-name>etagFilter</filter-name> -->
12+
<!-- <filter-class>org.springframework.web.filter.ShallowEtagHeaderFilter</filter-class> -->
13+
<!-- </filter> -->
14+
<!-- <filter-mapping> -->
15+
<!-- <filter-name>etagFilter</filter-name> -->
16+
<!-- <url-pattern>/*</url-pattern> -->
17+
<!-- </filter-mapping> -->
18+
<!-- </web-app> -->

spring-boot-rest/src/test/java/com/baeldung/common/web/AbstractBasicLiveTest.java

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
11
package com.baeldung.common.web;
22

33
import static com.baeldung.web.util.HTTPLinkHeaderUtil.extractURIByRel;
4+
import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
45
import static org.apache.commons.lang3.RandomStringUtils.randomNumeric;
6+
import static org.assertj.core.api.Assertions.assertThat;
57
import static org.hamcrest.Matchers.is;
68
import static org.junit.Assert.assertEquals;
79
import static org.junit.Assert.assertFalse;
10+
import static org.junit.Assert.assertNotNull;
811
import static org.junit.Assert.assertNull;
912
import static org.junit.Assert.assertThat;
13+
import static org.junit.Assert.assertTrue;
1014

1115
import java.io.Serializable;
1216
import java.util.List;
1317

18+
import org.junit.Ignore;
1419
import org.junit.Test;
1520

21+
import com.baeldung.persistence.model.Foo;
1622
import com.google.common.net.HttpHeaders;
1723

1824
import io.restassured.RestAssured;
25+
import io.restassured.http.ContentType;
1926
import io.restassured.response.Response;
2027

2128
public abstract class AbstractBasicLiveTest<T extends Serializable> extends AbstractLiveTest<T> {
@@ -97,7 +104,82 @@ public void whenLastPageOfResourcesIsRetrieved_thenNoNextPageIsDiscoverable() {
97104
final String uriToNextPage = extractURIByRel(response.getHeader(HttpHeaders.LINK), "next");
98105
assertNull(uriToNextPage);
99106
}
107+
108+
// etags
100109

101-
// count
110+
@Test
111+
public void givenResourceExists_whenRetrievingResource_thenEtagIsAlsoReturned() {
112+
// Given
113+
final String uriOfResource = createAsUri();
114+
115+
// When
116+
final Response findOneResponse = RestAssured.given()
117+
.header("Accept", "application/json")
118+
.get(uriOfResource);
119+
120+
// Then
121+
assertNotNull(findOneResponse.getHeader(HttpHeaders.ETAG));
122+
}
123+
124+
@Test
125+
public void givenResourceWasRetrieved_whenRetrievingAgainWithEtag_thenNotModifiedReturned() {
126+
// Given
127+
final String uriOfResource = createAsUri();
128+
final Response findOneResponse = RestAssured.given()
129+
.header("Accept", "application/json")
130+
.get(uriOfResource);
131+
final String etagValue = findOneResponse.getHeader(HttpHeaders.ETAG);
132+
133+
// When
134+
final Response secondFindOneResponse = RestAssured.given()
135+
.header("Accept", "application/json")
136+
.headers("If-None-Match", etagValue)
137+
.get(uriOfResource);
138+
139+
// Then
140+
assertTrue(secondFindOneResponse.getStatusCode() == 304);
141+
}
142+
143+
@Test
144+
public void givenResourceWasRetrievedThenModified_whenRetrievingAgainWithEtag_thenResourceIsReturned() {
145+
// Given
146+
final String uriOfResource = createAsUri();
147+
final Response firstFindOneResponse = RestAssured.given()
148+
.header("Accept", "application/json")
149+
.get(uriOfResource);
150+
final String etagValue = firstFindOneResponse.getHeader(HttpHeaders.ETAG);
151+
final long createdId = firstFindOneResponse.jsonPath().getLong("id");
152+
153+
Foo updatedFoo = new Foo("updated value");
154+
updatedFoo.setId(createdId);
155+
Response updatedResponse = RestAssured.given().contentType(ContentType.JSON).body(updatedFoo)
156+
.put(uriOfResource);
157+
assertThat(updatedResponse.getStatusCode() == 200);
158+
159+
// When
160+
final Response secondFindOneResponse = RestAssured.given()
161+
.header("Accept", "application/json")
162+
.headers("If-None-Match", etagValue)
163+
.get(uriOfResource);
164+
165+
// Then
166+
assertTrue(secondFindOneResponse.getStatusCode() == 200);
167+
}
168+
169+
@Test
170+
@Ignore("Not Yet Implemented By Spring - https://jira.springsource.org/browse/SPR-10164")
171+
public void givenResourceExists_whenRetrievedWithIfMatchIncorrectEtag_then412IsReceived() {
172+
// Given
173+
final String uriOfResource = createAsUri();
174+
175+
// When
176+
final Response findOneResponse = RestAssured.given()
177+
.header("Accept", "application/json")
178+
.headers("If-Match", randomAlphabetic(8))
179+
.get(uriOfResource);
180+
181+
// Then
182+
assertTrue(findOneResponse.getStatusCode() == 412);
183+
}
102184

103185
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.baeldung.web;
2+
3+
import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
4+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
5+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
6+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
7+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
8+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
9+
10+
import org.junit.Test;
11+
import org.junit.runner.RunWith;
12+
import org.springframework.beans.factory.annotation.Autowired;
13+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
14+
import org.springframework.boot.test.context.SpringBootTest;
15+
import org.springframework.http.MediaType;
16+
import org.springframework.test.context.junit4.SpringRunner;
17+
import org.springframework.test.web.servlet.MockMvc;
18+
import org.springframework.test.web.servlet.ResultActions;
19+
20+
import com.baeldung.persistence.model.Foo;
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import com.google.common.net.HttpHeaders;
23+
24+
@RunWith(SpringRunner.class)
25+
@SpringBootTest
26+
@AutoConfigureMockMvc(addFilters = false)
27+
public class FooControllerCustomEtagIntegrationTest {
28+
29+
@Autowired
30+
private MockMvc mvc;
31+
32+
private String FOOS_ENDPOINT = "/auth/foos/";
33+
private String CUSTOM_ETAG_ENDPOINT_SUFFIX = "/custom-etag";
34+
35+
private static String serializeFoo(Foo foo) throws Exception {
36+
ObjectMapper mapper = new ObjectMapper();
37+
return mapper.writeValueAsString(foo);
38+
}
39+
40+
private static String createFooJson() throws Exception {
41+
return serializeFoo(new Foo(randomAlphabetic(6)));
42+
}
43+
44+
private static Foo deserializeFoo(String fooJson) throws Exception {
45+
ObjectMapper mapper = new ObjectMapper();
46+
return mapper.readValue(fooJson, Foo.class);
47+
}
48+
49+
@Test
50+
public void givenResourceExists_whenRetrievingResourceUsingCustomEtagEndpoint_thenEtagIsAlsoReturned()
51+
throws Exception {
52+
// Given
53+
String createdResourceUri = this.mvc.perform(post(FOOS_ENDPOINT).contentType(MediaType.APPLICATION_JSON)
54+
.content(createFooJson()))
55+
.andExpect(status().isCreated())
56+
.andReturn()
57+
.getResponse()
58+
.getHeader(HttpHeaders.LOCATION);
59+
60+
// When
61+
ResultActions result = this.mvc
62+
.perform(get(createdResourceUri + CUSTOM_ETAG_ENDPOINT_SUFFIX).contentType(MediaType.APPLICATION_JSON));
63+
64+
// Then
65+
result.andExpect(status().isOk())
66+
.andExpect(header().string(HttpHeaders.ETAG, "\"0\""));
67+
}
68+
69+
@Test
70+
public void givenResourceWasRetrieved_whenRetrievingAgainWithEtagUsingCustomEtagEndpoint_thenNotModifiedReturned() throws Exception {
71+
// Given
72+
String createdResourceUri = this.mvc.perform(post(FOOS_ENDPOINT).contentType(MediaType.APPLICATION_JSON)
73+
.content(createFooJson()))
74+
.andExpect(status().isCreated())
75+
.andReturn()
76+
.getResponse()
77+
.getHeader(HttpHeaders.LOCATION);
78+
ResultActions findOneResponse = this.mvc
79+
.perform(get(createdResourceUri + CUSTOM_ETAG_ENDPOINT_SUFFIX).contentType(MediaType.APPLICATION_JSON));
80+
String etag = findOneResponse.andReturn().getResponse().getHeader(HttpHeaders.ETAG);
81+
82+
// When
83+
ResultActions result = this.mvc
84+
.perform(get(createdResourceUri + CUSTOM_ETAG_ENDPOINT_SUFFIX).contentType(MediaType.APPLICATION_JSON).header(HttpHeaders.IF_NONE_MATCH, etag));
85+
86+
// Then
87+
result.andExpect(status().isNotModified());
88+
}
89+
90+
@Test
91+
public void givenResourceWasRetrievedThenModified_whenRetrievingAgainWithEtagUsingCustomEtagEndpoint_thenResourceIsReturned() throws Exception {
92+
// Given
93+
String createdResourceUri = this.mvc.perform(post(FOOS_ENDPOINT).contentType(MediaType.APPLICATION_JSON)
94+
.content(createFooJson()))
95+
.andExpect(status().isCreated())
96+
.andReturn()
97+
.getResponse()
98+
.getHeader(HttpHeaders.LOCATION);
99+
ResultActions findOneResponse = this.mvc
100+
.perform(get(createdResourceUri + CUSTOM_ETAG_ENDPOINT_SUFFIX).contentType(MediaType.APPLICATION_JSON));
101+
String etag = findOneResponse.andReturn().getResponse().getHeader(HttpHeaders.ETAG);
102+
Foo createdFoo = deserializeFoo(findOneResponse.andReturn().getResponse().getContentAsString());
103+
createdFoo.setName("updated name");
104+
this.mvc
105+
.perform(put(createdResourceUri).contentType(MediaType.APPLICATION_JSON).content(serializeFoo(createdFoo)));
106+
107+
// When
108+
ResultActions result = this.mvc
109+
.perform(get(createdResourceUri + CUSTOM_ETAG_ENDPOINT_SUFFIX).contentType(MediaType.APPLICATION_JSON).header(HttpHeaders.IF_NONE_MATCH, etag));
110+
111+
// Then
112+
result.andExpect(status().isOk())
113+
.andExpect(header().string(HttpHeaders.ETAG, "\"1\""));
114+
}
115+
116+
}

spring-boot-rest/src/test/java/com/baeldung/web/LiveTestSuiteLiveTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
@Suite.SuiteClasses({
99
// @formatter:off
1010
FooDiscoverabilityLiveTest.class,
11-
FooLiveTest.class
12-
,FooPageableLiveTest.class
11+
FooLiveTest.class,
12+
FooPageableLiveTest.class
1313
}) //
1414
public class LiveTestSuiteLiveTest {
1515

spring-rest-full/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ The "REST With Spring" Classes: http://bit.ly/restwithspring
88
The "Learn Spring Security" Classes: http://github.learnspringsecurity.com
99

1010
### Relevant Articles:
11-
- [ETags for REST with Spring](http://www.baeldung.com/etags-for-rest-with-spring)
1211
- [Integration Testing with the Maven Cargo plugin](http://www.baeldung.com/integration-testing-with-the-maven-cargo-plugin)
1312
- [Introduction to Spring Data JPA](http://www.baeldung.com/the-persistence-layer-with-spring-data-jpa)
1413
- [Project Configuration with Spring](http://www.baeldung.com/project-configuration-with-spring)

0 commit comments

Comments
 (0)