[BAEL-11597] Move and update Etags article

* Moved etags article related code
* Fixed existing etag Ignored test
* Added etag scentsio adding etag support to a single endpoint
* Added tests for single endpoint etag scenario
* Cleaned now unused code in spring-rest-full module
This commit is contained in:
Ger Roza 2019-02-23 14:49:01 -03:00
parent 23eecb7cb1
commit 52b164ffca
18 changed files with 256 additions and 255 deletions

View File

@ -8,3 +8,4 @@ Module for the articles that are part of the Spring REST E-book:
6. [REST API Discoverability and HATEOAS](http://www.baeldung.com/restful-web-service-discoverability)
7. [Versioning a REST API](http://www.baeldung.com/rest-versioning)
8. [Http Message Converters with the Spring Framework](http://www.baeldung.com/spring-httpmessageconverter-rest)
9. [ETags for REST with Spring](http://www.baeldung.com/etags-for-rest-with-spring)

View File

@ -7,6 +7,7 @@ import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Version;
import com.thoughtworks.xstream.annotations.XStreamAlias;
@ -20,6 +21,9 @@ public class Foo implements Serializable {
@Column(nullable = false)
private String name;
@Version
private long version;
public Foo() {
super();
@ -49,6 +53,14 @@ public class Foo implements Serializable {
this.name = name;
}
public long getVersion() {
return version;
}
public void setVersion(long version) {
this.version = version;
}
//
@Override

View File

@ -9,6 +9,7 @@ import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.converter.xml.MarshallingHttpMessageConverter;
import org.springframework.oxm.xstream.XStreamMarshaller;
import org.springframework.web.filter.ShallowEtagHeaderFilter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@ -45,5 +46,15 @@ public class WebConfig implements WebMvcConfigurer {
//
// return xmlConverter;
// }
// Etags
// If we're not using Spring Boot we can make use of
// AbstractAnnotationConfigDispatcherServletInitializer#getServletFilters
@Bean
public ShallowEtagHeaderFilter shallowEtagHeaderFilter() {
return new ShallowEtagHeaderFilter();
}
}

View File

@ -9,6 +9,7 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@ -45,6 +46,18 @@ public class FooController {
}
// API
// Note: the global filter overrides the ETag value we set here. We can still analyze its behaviour in the Integration Test.
@GetMapping(value = "/{id}/custom-etag")
public ResponseEntity<Foo> findByIdWithCustomEtag(@PathVariable("id") final Long id,
final HttpServletResponse response) {
final Foo resourceById = RestPreconditions.checkFound(service.findOne(id));
eventPublisher.publishEvent(new SingleResourceRetrievedEvent(this, response));
return ResponseEntity.ok()
.eTag(Long.toString(resourceById.getVersion()))
.body(resourceById);
}
// read - one

View File

@ -0,0 +1,18 @@
<!-- 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 -->
<!-- <?xml version="1.0" encoding="UTF-8"?> -->
<!-- <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" -->
<!-- xsi:schemaLocation=" -->
<!-- http://java.sun.com/xml/ns/javaee -->
<!-- http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" id="WebApp_ID" version="3.0" -->
<!-- > -->
<!-- <filter> -->
<!-- <filter-name>etagFilter</filter-name> -->
<!-- <filter-class>org.springframework.web.filter.ShallowEtagHeaderFilter</filter-class> -->
<!-- </filter> -->
<!-- <filter-mapping> -->
<!-- <filter-name>etagFilter</filter-name> -->
<!-- <url-pattern>/*</url-pattern> -->
<!-- </filter-mapping> -->
<!-- </web-app> -->

View File

@ -1,21 +1,28 @@
package com.baeldung.common.web;
import static com.baeldung.web.util.HTTPLinkHeaderUtil.extractURIByRel;
import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
import static org.apache.commons.lang3.RandomStringUtils.randomNumeric;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import java.io.Serializable;
import java.util.List;
import org.junit.Ignore;
import org.junit.Test;
import com.baeldung.persistence.model.Foo;
import com.google.common.net.HttpHeaders;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.response.Response;
public abstract class AbstractBasicLiveTest<T extends Serializable> extends AbstractLiveTest<T> {
@ -97,7 +104,82 @@ public abstract class AbstractBasicLiveTest<T extends Serializable> extends Abst
final String uriToNextPage = extractURIByRel(response.getHeader(HttpHeaders.LINK), "next");
assertNull(uriToNextPage);
}
// etags
// count
@Test
public void givenResourceExists_whenRetrievingResource_thenEtagIsAlsoReturned() {
// Given
final String uriOfResource = createAsUri();
// When
final Response findOneResponse = RestAssured.given()
.header("Accept", "application/json")
.get(uriOfResource);
// Then
assertNotNull(findOneResponse.getHeader(HttpHeaders.ETAG));
}
@Test
public void givenResourceWasRetrieved_whenRetrievingAgainWithEtag_thenNotModifiedReturned() {
// Given
final String uriOfResource = createAsUri();
final Response findOneResponse = RestAssured.given()
.header("Accept", "application/json")
.get(uriOfResource);
final String etagValue = findOneResponse.getHeader(HttpHeaders.ETAG);
// When
final Response secondFindOneResponse = RestAssured.given()
.header("Accept", "application/json")
.headers("If-None-Match", etagValue)
.get(uriOfResource);
// Then
assertTrue(secondFindOneResponse.getStatusCode() == 304);
}
@Test
public void givenResourceWasRetrievedThenModified_whenRetrievingAgainWithEtag_thenResourceIsReturned() {
// Given
final String uriOfResource = createAsUri();
final Response firstFindOneResponse = RestAssured.given()
.header("Accept", "application/json")
.get(uriOfResource);
final String etagValue = firstFindOneResponse.getHeader(HttpHeaders.ETAG);
final long createdId = firstFindOneResponse.jsonPath().getLong("id");
Foo updatedFoo = new Foo("updated value");
updatedFoo.setId(createdId);
Response updatedResponse = RestAssured.given().contentType(ContentType.JSON).body(updatedFoo)
.put(uriOfResource);
assertThat(updatedResponse.getStatusCode() == 200);
// When
final Response secondFindOneResponse = RestAssured.given()
.header("Accept", "application/json")
.headers("If-None-Match", etagValue)
.get(uriOfResource);
// Then
assertTrue(secondFindOneResponse.getStatusCode() == 200);
}
@Test
@Ignore("Not Yet Implemented By Spring - https://jira.springsource.org/browse/SPR-10164")
public void givenResourceExists_whenRetrievedWithIfMatchIncorrectEtag_then412IsReceived() {
// Given
final String uriOfResource = createAsUri();
// When
final Response findOneResponse = RestAssured.given()
.header("Accept", "application/json")
.headers("If-Match", randomAlphabetic(8))
.get(uriOfResource);
// Then
assertTrue(findOneResponse.getStatusCode() == 412);
}
}

View File

@ -0,0 +1,116 @@
package com.baeldung.web;
import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import com.baeldung.persistence.model.Foo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.net.HttpHeaders;
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc(addFilters = false)
public class FooControllerCustomEtagIntegrationTest {
@Autowired
private MockMvc mvc;
private String FOOS_ENDPOINT = "/auth/foos/";
private String CUSTOM_ETAG_ENDPOINT_SUFFIX = "/custom-etag";
private static String serializeFoo(Foo foo) throws Exception {
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(foo);
}
private static String createFooJson() throws Exception {
return serializeFoo(new Foo(randomAlphabetic(6)));
}
private static Foo deserializeFoo(String fooJson) throws Exception {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(fooJson, Foo.class);
}
@Test
public void givenResourceExists_whenRetrievingResourceUsingCustomEtagEndpoint_thenEtagIsAlsoReturned()
throws Exception {
// Given
String createdResourceUri = this.mvc.perform(post(FOOS_ENDPOINT).contentType(MediaType.APPLICATION_JSON)
.content(createFooJson()))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getHeader(HttpHeaders.LOCATION);
// When
ResultActions result = this.mvc
.perform(get(createdResourceUri + CUSTOM_ETAG_ENDPOINT_SUFFIX).contentType(MediaType.APPLICATION_JSON));
// Then
result.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.ETAG, "\"0\""));
}
@Test
public void givenResourceWasRetrieved_whenRetrievingAgainWithEtagUsingCustomEtagEndpoint_thenNotModifiedReturned() throws Exception {
// Given
String createdResourceUri = this.mvc.perform(post(FOOS_ENDPOINT).contentType(MediaType.APPLICATION_JSON)
.content(createFooJson()))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getHeader(HttpHeaders.LOCATION);
ResultActions findOneResponse = this.mvc
.perform(get(createdResourceUri + CUSTOM_ETAG_ENDPOINT_SUFFIX).contentType(MediaType.APPLICATION_JSON));
String etag = findOneResponse.andReturn().getResponse().getHeader(HttpHeaders.ETAG);
// When
ResultActions result = this.mvc
.perform(get(createdResourceUri + CUSTOM_ETAG_ENDPOINT_SUFFIX).contentType(MediaType.APPLICATION_JSON).header(HttpHeaders.IF_NONE_MATCH, etag));
// Then
result.andExpect(status().isNotModified());
}
@Test
public void givenResourceWasRetrievedThenModified_whenRetrievingAgainWithEtagUsingCustomEtagEndpoint_thenResourceIsReturned() throws Exception {
// Given
String createdResourceUri = this.mvc.perform(post(FOOS_ENDPOINT).contentType(MediaType.APPLICATION_JSON)
.content(createFooJson()))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getHeader(HttpHeaders.LOCATION);
ResultActions findOneResponse = this.mvc
.perform(get(createdResourceUri + CUSTOM_ETAG_ENDPOINT_SUFFIX).contentType(MediaType.APPLICATION_JSON));
String etag = findOneResponse.andReturn().getResponse().getHeader(HttpHeaders.ETAG);
Foo createdFoo = deserializeFoo(findOneResponse.andReturn().getResponse().getContentAsString());
createdFoo.setName("updated name");
this.mvc
.perform(put(createdResourceUri).contentType(MediaType.APPLICATION_JSON).content(serializeFoo(createdFoo)));
// When
ResultActions result = this.mvc
.perform(get(createdResourceUri + CUSTOM_ETAG_ENDPOINT_SUFFIX).contentType(MediaType.APPLICATION_JSON).header(HttpHeaders.IF_NONE_MATCH, etag));
// Then
result.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.ETAG, "\"1\""));
}
}

View File

@ -8,8 +8,8 @@ import org.junit.runners.Suite;
@Suite.SuiteClasses({
// @formatter:off
FooDiscoverabilityLiveTest.class,
FooLiveTest.class
,FooPageableLiveTest.class
FooLiveTest.class,
FooPageableLiveTest.class
}) //
public class LiveTestSuiteLiveTest {

View File

@ -8,7 +8,6 @@ The "REST With Spring" Classes: http://bit.ly/restwithspring
The "Learn Spring Security" Classes: http://github.learnspringsecurity.com
### Relevant Articles:
- [ETags for REST with Spring](http://www.baeldung.com/etags-for-rest-with-spring)
- [Integration Testing with the Maven Cargo plugin](http://www.baeldung.com/integration-testing-with-the-maven-cargo-plugin)
- [Introduction to Spring Data JPA](http://www.baeldung.com/the-persistence-layer-with-spring-data-jpa)
- [Project Configuration with Spring](http://www.baeldung.com/project-configuration-with-spring)

View File

@ -8,11 +8,9 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.support.SpringBootServletInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.context.request.RequestContextListener;
import org.springframework.web.filter.ShallowEtagHeaderFilter;
/**
* Main Application Class - uses Spring Boot. Just run this as a normal Java
@ -40,8 +38,4 @@ public class Application extends SpringBootServletInitializer {
SpringApplication.run(Application.class, args);
}
@Bean
public ShallowEtagHeaderFilter shallowEtagHeaderFilter() {
return new ShallowEtagHeaderFilter();
}
}

View File

@ -6,10 +6,8 @@ import javax.servlet.http.HttpServletResponse;
import org.baeldung.persistence.model.Foo;
import org.baeldung.persistence.service.IFooService;
import org.baeldung.web.hateoas.event.ResourceCreatedEvent;
import org.baeldung.web.util.RestPreconditions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
@ -26,9 +24,6 @@ import com.google.common.base.Preconditions;
@RequestMapping(value = "/auth/foos")
public class FooController {
@Autowired
private ApplicationEventPublisher eventPublisher;
@Autowired
private IFooService service;
@ -71,9 +66,6 @@ public class FooController {
public Foo create(@RequestBody final Foo resource, final HttpServletResponse response) {
Preconditions.checkNotNull(resource);
final Foo foo = service.create(resource);
final Long idOfCreatedResource = foo.getId();
eventPublisher.publishEvent(new ResourceCreatedEvent(this, response, idOfCreatedResource));
return foo;
}

View File

@ -1,28 +0,0 @@
package org.baeldung.web.hateoas.event;
import javax.servlet.http.HttpServletResponse;
import org.springframework.context.ApplicationEvent;
public class ResourceCreatedEvent extends ApplicationEvent {
private final HttpServletResponse response;
private final long idOfNewResource;
public ResourceCreatedEvent(final Object source, final HttpServletResponse response, final long idOfNewResource) {
super(source);
this.response = response;
this.idOfNewResource = idOfNewResource;
}
// API
public HttpServletResponse getResponse() {
return response;
}
public long getIdOfNewResource() {
return idOfNewResource;
}
}

View File

@ -1,36 +0,0 @@
package org.baeldung.web.hateoas.listener;
import java.net.URI;
import javax.servlet.http.HttpServletResponse;
import org.apache.http.HttpHeaders;
import org.baeldung.web.hateoas.event.ResourceCreatedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import com.google.common.base.Preconditions;
@Component
class ResourceCreatedDiscoverabilityListener implements ApplicationListener<ResourceCreatedEvent> {
@Override
public void onApplicationEvent(final ResourceCreatedEvent resourceCreatedEvent) {
Preconditions.checkNotNull(resourceCreatedEvent);
final HttpServletResponse response = resourceCreatedEvent.getResponse();
final long idOfNewResource = resourceCreatedEvent.getIdOfNewResource();
addLinkHeaderOnResourceCreation(response, idOfNewResource);
}
void addLinkHeaderOnResourceCreation(final HttpServletResponse response, final long idOfNewResource) {
// final String requestUrl = request.getRequestURL().toString();
// final URI uri = new UriTemplate("{requestUrl}/{idOfNewResource}").expand(requestUrl, idOfNewResource);
final URI uri = ServletUriComponentsBuilder.fromCurrentRequestUri().path("/{idOfNewResource}").buildAndExpand(idOfNewResource).toUri();
response.setHeader(HttpHeaders.LOCATION, uri.toASCIIString());
}
}

View File

@ -23,15 +23,6 @@
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<filter>
<filter-name>etagFilter</filter-name>
<filter-class>org.springframework.web.filter.ShallowEtagHeaderFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>etagFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- Spring child -->
<servlet>
<servlet-name>api</servlet-name>

View File

@ -1,16 +0,0 @@
package org.baeldung;
import org.baeldung.persistence.PersistenceTestSuite;
import org.baeldung.web.LiveTestSuiteLiveTest;
import org.junit.runner.RunWith;
import org.junit.runners.Suite;
@RunWith(Suite.class)
@Suite.SuiteClasses({
// @formatter:off
PersistenceTestSuite.class
,LiveTestSuiteLiveTest.class
}) //
public class TestSuiteLiveTest {
}

View File

@ -1,100 +0,0 @@
package org.baeldung.common.web;
import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.io.Serializable;
import org.junit.Ignore;
import org.junit.Test;
import com.google.common.net.HttpHeaders;
import io.restassured.RestAssured;
import io.restassured.response.Response;
public abstract class AbstractBasicLiveTest<T extends Serializable> extends AbstractLiveTest<T> {
public AbstractBasicLiveTest(final Class<T> clazzToSet) {
super(clazzToSet);
}
// tests
@Test
public void givenResourceExists_whenRetrievingResource_thenEtagIsAlsoReturned() {
// Given
final String uriOfResource = createAsUri();
// When
final Response findOneResponse = RestAssured.given()
.header("Accept", "application/json")
.get(uriOfResource);
// Then
assertNotNull(findOneResponse.getHeader(HttpHeaders.ETAG));
}
@Test
public void givenResourceWasRetrieved_whenRetrievingAgainWithEtag_thenNotModifiedReturned() {
// Given
final String uriOfResource = createAsUri();
final Response findOneResponse = RestAssured.given()
.header("Accept", "application/json")
.get(uriOfResource);
final String etagValue = findOneResponse.getHeader(HttpHeaders.ETAG);
// When
final Response secondFindOneResponse = RestAssured.given()
.header("Accept", "application/json")
.headers("If-None-Match", etagValue)
.get(uriOfResource);
// Then
assertTrue(secondFindOneResponse.getStatusCode() == 304);
}
@Test
@Ignore("No Update operation yet")
public void givenResourceWasRetrievedThenModified_whenRetrievingAgainWithEtag_thenResourceIsReturned() {
// Given
final String uriOfResource = createAsUri();
final Response findOneResponse = RestAssured.given()
.header("Accept", "application/json")
.get(uriOfResource);
final String etagValue = findOneResponse.getHeader(HttpHeaders.ETAG);
// existingResource.setName(randomAlphabetic(6));
// getApi().update(existingResource.setName("randomString"));
// When
final Response secondFindOneResponse = RestAssured.given()
.header("Accept", "application/json")
.headers("If-None-Match", etagValue)
.get(uriOfResource);
// Then
assertTrue(secondFindOneResponse.getStatusCode() == 200);
}
@Test
@Ignore("Not Yet Implemented By Spring - https://jira.springsource.org/browse/SPR-10164")
public void givenResourceExists_whenRetrievedWithIfMatchIncorrectEtag_then412IsReceived() {
// Given
final String uriOfResource = createAsUri();
// When
final Response findOneResponse = RestAssured.given()
.header("Accept", "application/json")
.headers("If-Match", randomAlphabetic(8))
.get(uriOfResource);
// Then
assertTrue(findOneResponse.getStatusCode() == 412);
}
// find - one
// find - all
}

View File

@ -1,35 +0,0 @@
package org.baeldung.web;
import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
import org.baeldung.common.web.AbstractBasicLiveTest;
import org.baeldung.persistence.model.Foo;
import org.baeldung.spring.ConfigIntegrationTest;
import org.junit.runner.RunWith;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.support.AnnotationConfigContextLoader;
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { ConfigIntegrationTest.class }, loader = AnnotationConfigContextLoader.class)
@ActiveProfiles("test")
public class FooLiveTest extends AbstractBasicLiveTest<Foo> {
public FooLiveTest() {
super(Foo.class);
}
// API
@Override
public final void create() {
create(new Foo(randomAlphabetic(6)));
}
@Override
public final String createAsUri() {
return createAsUri(new Foo(randomAlphabetic(6)));
}
}

View File

@ -1,13 +0,0 @@
package org.baeldung.web;
import org.junit.runner.RunWith;
import org.junit.runners.Suite;
@RunWith(Suite.class)
@Suite.SuiteClasses({
// @formatter:off
FooLiveTest.class
}) //
public class LiveTestSuiteLiveTest {
}