From fd70fd90d2fa1977c87bd707457aa8859efe8274 Mon Sep 17 00:00:00 2001 From: Greg Turnquist Date: Wed, 4 Sep 2019 14:44:46 -0500 Subject: [PATCH] #1061 - Reenable default codecs for WebFlux configuration. With Spring WebFlux properly handling custom codecs, no need to disable the default handlers. This commit verifies WebFlux configuration in test cases, and also adds an equivalent set of test cases verifying Web MVC configuration as well. Supercedes: #1047 --- .../config/WebFluxHateoasConfiguration.java | 15 +- .../Jackson2CollectionJsonModule.java | 2 +- .../hal/forms/Jackson2HalFormsModule.java | 2 +- .../HypermediaWebFluxConfigurerTest.java | 13 +- .../HypermediaWebMvcConfigurerTest.java | 481 ++++++++++++++++++ 5 files changed, 497 insertions(+), 16 deletions(-) create mode 100644 src/test/java/org/springframework/hateoas/config/HypermediaWebMvcConfigurerTest.java diff --git a/src/main/java/org/springframework/hateoas/config/WebFluxHateoasConfiguration.java b/src/main/java/org/springframework/hateoas/config/WebFluxHateoasConfiguration.java index 75d18a7c..03315800 100644 --- a/src/main/java/org/springframework/hateoas/config/WebFluxHateoasConfiguration.java +++ b/src/main/java/org/springframework/hateoas/config/WebFluxHateoasConfiguration.java @@ -124,21 +124,14 @@ class WebFluxHateoasConfiguration { @Override public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) { - CodecConfigurer.CustomCodecs customCodecs = configurer.customCodecs(); - this.hypermediaTypes.forEach(hypermedia -> { - MimeType[] mimeTypes = hypermedia.getMediaTypes().toArray(new MimeType[0]); - ObjectMapper objectMapper = hypermedia.configureObjectMapper(this.mapper.copy()); - customCodecs.encoder(new Jackson2JsonEncoder(objectMapper, mimeTypes)); - customCodecs.decoder(new Jackson2JsonDecoder(objectMapper, mimeTypes)); + MimeType[] mimeTypes = hypermedia.getMediaTypes().toArray(new MimeType[0]); + + configurer.customCodecs().encoder(new Jackson2JsonEncoder(objectMapper, mimeTypes)); + configurer.customCodecs().decoder(new Jackson2JsonDecoder(objectMapper, mimeTypes)); }); - - customCodecs.encoder(CharSequenceEncoder.allMimeTypes()); - customCodecs.decoder(StringDecoder.allMimeTypes()); - - configurer.registerDefaults(false); } } } diff --git a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java index 3c975ae8..3566b153 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java +++ b/src/main/java/org/springframework/hateoas/mediatype/collectionjson/Jackson2CollectionJsonModule.java @@ -65,7 +65,7 @@ import com.fasterxml.jackson.databind.type.TypeFactory; * @author Greg Turnquist * @author Oliver Drotbohm */ -class Jackson2CollectionJsonModule extends SimpleModule { +public class Jackson2CollectionJsonModule extends SimpleModule { private static final long serialVersionUID = -6540574644565592709L; diff --git a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java index c9598a92..f2e35804 100644 --- a/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java +++ b/src/main/java/org/springframework/hateoas/mediatype/hal/forms/Jackson2HalFormsModule.java @@ -73,7 +73,7 @@ import com.fasterxml.jackson.databind.type.TypeFactory; * @author Greg Turnquist * @author Oliver Gierke */ -class Jackson2HalFormsModule extends SimpleModule { +public class Jackson2HalFormsModule extends SimpleModule { private static final long serialVersionUID = -4496351128468451196L; diff --git a/src/test/java/org/springframework/hateoas/config/HypermediaWebFluxConfigurerTest.java b/src/test/java/org/springframework/hateoas/config/HypermediaWebFluxConfigurerTest.java index f41efd7e..79eb48c1 100644 --- a/src/test/java/org/springframework/hateoas/config/HypermediaWebFluxConfigurerTest.java +++ b/src/test/java/org/springframework/hateoas/config/HypermediaWebFluxConfigurerTest.java @@ -255,15 +255,22 @@ class HypermediaWebFluxConfigurerTest { } /** + * When requesting an unregistered media type, fallback to Spring Framework's default JSON handler. + * * @see #728 */ @Test - void callingForUnregisteredMediaTypeShouldFail() { + void callingForUnregisteredMediaTypeShouldFallBackToDefaultHandler() { setUp(HalWebFluxConfig.class); - this.testClient.get().uri("/").accept(MediaTypes.UBER_JSON).exchange().expectStatus().is4xxClientError() - .returnResult(String.class).getResponseBody().as(StepVerifier::create).verifyComplete(); + this.testClient.get().uri("/").accept(MediaTypes.UBER_JSON) // + .exchange() // + .expectStatus().isOk() // + .returnResult(String.class).getResponseBody() // + .as(StepVerifier::create) // + .expectNext("{\"links\":[{\"rel\":\"self\",\"href\":\"/\"},{\"rel\":\"employees\",\"href\":\"/employees\"}]}") + .verifyComplete(); } /** diff --git a/src/test/java/org/springframework/hateoas/config/HypermediaWebMvcConfigurerTest.java b/src/test/java/org/springframework/hateoas/config/HypermediaWebMvcConfigurerTest.java new file mode 100644 index 00000000..65f824d1 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/config/HypermediaWebMvcConfigurerTest.java @@ -0,0 +1,481 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.config; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.hateoas.mediatype.collectionjson.Jackson2CollectionJsonModule; +import org.springframework.hateoas.mediatype.hal.Jackson2HalModule; +import org.springframework.hateoas.mediatype.hal.forms.Jackson2HalFormsModule; +import org.springframework.hateoas.mediatype.uber.Jackson2UberModule; +import org.springframework.hateoas.server.SimpleRepresentationModelAssembler; +import org.springframework.hateoas.support.Employee; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockServletContext; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * @author Greg Turnquist + */ +class HypermediaWebMvcConfigurerTest { + + MockMvc mockMvc; + + void setUp(Class context) { + + AnnotationConfigWebApplicationContext ctx = new AnnotationConfigWebApplicationContext(); + ctx.register(context); + ctx.setServletContext(new MockServletContext()); + ctx.refresh(); + + this.mockMvc = webAppContextSetup(ctx).build(); + } + + /** + * @see #728 + */ + @Test + void registeringHalShouldServeHal() throws Exception { + + setUp(HalWebMvcConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.HAL_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_JSON); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_JSON); + } + + /** + * @see #728 + */ + @Test + void registeringHalFormsShouldServeHalForms() throws Exception { + + setUp(HalFormsWebMvcConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_FORMS_JSON); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_FORMS_JSON); + } + + /** + * @see #728 + */ + @Test + void registeringCollectionJsonShouldServerCollectionJson() throws Exception { + + setUp(CollectionJsonWebMvcConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.COLLECTION_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.COLLECTION_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.COLLECTION_JSON); + + verifyCreatingNewEntityWorks(MediaTypes.COLLECTION_JSON); + } + + /** + * @see #728 + */ + @Test + void registeringUberShouldServerUber() throws Exception { + + setUp(UberWebMvcConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.UBER_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.UBER_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.UBER_JSON); + + verifyCreatingNewEntityWorks(MediaTypes.UBER_JSON); + } + + /** + * @see #728 + */ + @Test + void registeringHalAndHalFormsShouldServerHalAndHalForms() throws Exception { + + setUp(AllHalWebMvcConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.HAL_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_JSON); + + verifyRootUriServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_FORMS_JSON); + } + + /** + * @see #728 + */ + @Test + void registeringHalAndHalFormsShouldAllowCreatingViaHalAndHalForms() throws Exception { + + setUp(AllHalWebMvcConfig.class); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_JSON); + verifyCreatingNewEntityWorks(MediaTypes.HAL_FORMS_JSON); + } + + /** + * @see #728 + */ + @Test + void registeringHalAndCollectionJsonShouldServerHalAndCollectionJson() throws Exception { + + setUp(HalAndCollectionJsonWebMvcConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.HAL_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_JSON); + + verifyRootUriServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_FORMS_JSON); + + verifyRootUriServesHypermedia(MediaTypes.COLLECTION_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.COLLECTION_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.COLLECTION_JSON); + } + + /** + * @see #728 + */ + @Test + void registeringHalAndCollectionJsonShouldAllowCreatingViaHalAndCollectionJson() throws Exception { + + setUp(HalAndCollectionJsonWebMvcConfig.class); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_JSON); + verifyCreatingNewEntityWorks(MediaTypes.HAL_FORMS_JSON); + verifyCreatingNewEntityWorks(MediaTypes.COLLECTION_JSON); + } + + /** + * @see #728 + */ + @Test + void registeringAllHypermediaTypesShouldServerThemAll() throws Exception { + + setUp(AllHypermediaTypesWebMvcConfig.class); + + verifyRootUriServesHypermedia(MediaTypes.HAL_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_JSON); + + verifyRootUriServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.HAL_FORMS_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.HAL_FORMS_JSON); + + verifyRootUriServesHypermedia(MediaTypes.COLLECTION_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.COLLECTION_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.COLLECTION_JSON); + + verifyRootUriServesHypermedia(MediaTypes.UBER_JSON); + verifyAggregateRootServesHypermedia(MediaTypes.UBER_JSON); + verifySingleItemResourceServesHypermedia(MediaTypes.UBER_JSON); + } + + /** + * @see #728 + */ + @Test + void registeringAllHypermediaTypesShouldAllowCreatingThroughAllFormats() throws Exception { + + setUp(AllHypermediaTypesWebMvcConfig.class); + + verifyCreatingNewEntityWorks(MediaTypes.HAL_JSON); + verifyCreatingNewEntityWorks(MediaTypes.HAL_FORMS_JSON); + verifyCreatingNewEntityWorks(MediaTypes.COLLECTION_JSON); + verifyCreatingNewEntityWorks(MediaTypes.UBER_JSON); + } + + /** + * When requesting an unregistered media type, fallback to Spring Framework's default JSON handler. + */ + @Test + void callingForUnregisteredMediaTypeShouldFallBackToDefaultHandler() throws Exception { + + setUp(HalWebMvcConfig.class); + + String unformattedJson = this.mockMvc.perform(get("/").accept(MediaTypes.UBER_JSON)) // + .andExpect(status().isOk()) // + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.UBER_JSON.toString())) // + .andReturn() // + .getResponse().getContentAsString(); // + + assertThat(unformattedJson) + .isEqualTo("{\"links\":[{\"rel\":\"self\",\"href\":\"/\"},{\"rel\":\"employees\",\"href\":\"/employees\"}]}"); + } + + private void verifyRootUriServesHypermedia(MediaType mediaType) throws Exception { + verifyRootUriServesHypermedia(mediaType, mediaType); + } + + private void verifyRootUriServesHypermedia(MediaType requestType, MediaType responseType) throws Exception { + + String json = this.mockMvc.perform(get("/").accept(requestType)) // + .andExpect(status().isOk()) // + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, responseType.toString())) // + .andReturn() // + .getResponse().getContentAsString(); // + + ObjectMapper mapper = getMapper(responseType); + + RepresentationModel model = mapper.readValue(json, RepresentationModel.class); + + assertThat(model.getLinks()) // + .containsExactlyInAnyOrder(new Link("/", IanaLinkRelations.SELF), new Link("/employees", "employees")); + } + + private void verifyAggregateRootServesHypermedia(MediaType mediaType) throws Exception { + verifyAggregateRootServesHypermedia(mediaType, mediaType); + } + + private void verifyAggregateRootServesHypermedia(MediaType requestType, MediaType responseType) throws Exception { + + String json = this.mockMvc.perform(get("/employees").accept(requestType)) // + .andExpect(status().isOk()) // + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, responseType.toString())) // + .andReturn().getResponse().getContentAsString(); + + ObjectMapper mapper = getMapper(responseType); + + JavaType entityModelType = mapper.getTypeFactory().constructParametricType(EntityModel.class, Employee.class); + JavaType collectionModelType = mapper.getTypeFactory().constructParametricType(CollectionModel.class, + entityModelType); + + CollectionModel> resources = mapper.readValue(json, collectionModelType); + + assertThat(resources.getLinks()).containsExactlyInAnyOrder(new Link("/employees", IanaLinkRelations.SELF)); + + Collection> content = resources.getContent(); + assertThat(content).hasSize(1); + + EntityModel resource = content.iterator().next(); + + assertThat(resource.getContent()).isEqualTo(new Employee("Frodo Baggins", "ring bearer")); + assertThat(resource.getLinks()) // + .containsExactlyInAnyOrder(new Link("/employees/1", IanaLinkRelations.SELF), + new Link("/employees", "employees")); + } + + private void verifySingleItemResourceServesHypermedia(MediaType mediaType) throws Exception { + verifySingleItemResourceServesHypermedia(mediaType, mediaType); + } + + private void verifySingleItemResourceServesHypermedia(MediaType requestType, MediaType responseType) + throws Exception { + + String json = this.mockMvc.perform(get("/employees/1").accept(requestType)) // + .andExpect(status().isOk()) // + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, responseType.toString())) // + .andReturn().getResponse().getContentAsString(); + + ObjectMapper mapper = getMapper(responseType); + + JavaType entityModelType = mapper.getTypeFactory().constructParametricType(EntityModel.class, Employee.class); + + EntityModel employeeResource = mapper.readValue(json, entityModelType); + + assertThat(employeeResource.getContent()).isEqualTo(new Employee("Frodo Baggins", "ring bearer")); + assertThat(employeeResource.getLinks()).containsExactlyInAnyOrder(new Link("/employees/1", IanaLinkRelations.SELF), + new Link("/employees", "employees")); + } + + private void verifyCreatingNewEntityWorks(MediaType mediaType) throws Exception { + verifyCreatingNewEntityWorks(mediaType, mediaType); + } + + private void verifyCreatingNewEntityWorks(MediaType contentType, MediaType responseType) throws Exception { + verifyCreation("/employees", contentType, responseType); + } + + private void verifyCreation(String uri, MediaType contentType, MediaType responseType) throws Exception { + + ObjectMapper mapper = getMapper(responseType); + + String json = this.mockMvc.perform( // + post(uri) // + .accept(contentType) // + .contentType(contentType) // + .content(mapper.writeValueAsBytes(new Employee("Samwise Gamgee", "gardener")))) // + .andExpect(status().isOk()) // + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, responseType.toString())) // + .andReturn().getResponse().getContentAsString(); + + JavaType entityModelType = mapper.getTypeFactory().constructParametricType(EntityModel.class, Employee.class); + + EntityModel resource = mapper.readValue(json, entityModelType); + + assertThat(resource.getContent()).isEqualTo(new Employee("Samwise Gamgee", "gardener")); + assertThat(resource.getLinks()) // + .containsExactlyInAnyOrder(new Link("/employees/1", IanaLinkRelations.SELF), + new Link("/employees", "employees")); + } + + private static ObjectMapper getMapper(MediaType mediaType) { + + ObjectMapper mapper = new ObjectMapper(); + + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + if (mediaType == MediaTypes.HAL_JSON) { + mapper.registerModule(new Jackson2HalModule()); + } else if (mediaType == MediaTypes.UBER_JSON) { + mapper.registerModule(new Jackson2UberModule()); + } else if (mediaType == MediaTypes.HAL_FORMS_JSON) { + mapper.registerModule(new Jackson2HalFormsModule()); + } else if (mediaType == MediaTypes.COLLECTION_JSON) { + mapper.registerModule(new Jackson2CollectionJsonModule()); + } + + return mapper; + } + + @Configuration + @WebAppConfiguration + @EnableWebMvc + static abstract class BaseConfig { + + @Bean + TestController testController() { + return new TestController(); + } + } + + @EnableHypermediaSupport(type = HAL) + static class HalWebMvcConfig extends BaseConfig {} + + @EnableHypermediaSupport(type = HAL_FORMS) + static class HalFormsWebMvcConfig extends BaseConfig {} + + @EnableHypermediaSupport(type = COLLECTION_JSON) + static class CollectionJsonWebMvcConfig extends BaseConfig {} + + @EnableHypermediaSupport(type = UBER) + static class UberWebMvcConfig extends BaseConfig {} + + @EnableHypermediaSupport(type = { HAL, HAL_FORMS }) + static class AllHalWebMvcConfig extends BaseConfig {} + + @EnableHypermediaSupport(type = { HAL, HAL_FORMS, COLLECTION_JSON }) + static class HalAndCollectionJsonWebMvcConfig extends BaseConfig {} + + @EnableHypermediaSupport(type = { HAL, HAL_FORMS, COLLECTION_JSON, UBER }) + static class AllHypermediaTypesWebMvcConfig extends BaseConfig {} + + @RestController + static class TestController { + + private List employees; + private EmployeeResourceAssembler assembler = new EmployeeResourceAssembler(); + + TestController() { + + this.employees = new ArrayList<>(); + this.employees.add(new Employee("Frodo Baggins", "ring bearer")); + } + + @GetMapping + RepresentationModel root() { + + RepresentationModel root = new RepresentationModel<>(); + + root.add(new Link("/").withSelfRel()); + root.add(new Link("/employees").withRel("employees")); + + return root; + } + + @GetMapping("/employees") + CollectionModel> employees() { + return this.assembler.toCollectionModel(this.employees); + } + + @PostMapping("/employees") + EntityModel newEmployee(@RequestBody Employee newEmployee) { + + this.employees.add(newEmployee); + + return this.assembler.toModel(newEmployee); + } + + @GetMapping("/employees/{id}") + EntityModel employee(@PathVariable String id) { + return this.assembler.toModel(this.employees.get(0)); + } + + @PutMapping("/employees/{id}") + EntityModel updateEmployee(@RequestBody Employee newEmployee, @PathVariable String id) { + + this.employees.add(newEmployee); + + return this.assembler.toModel(newEmployee); + } + } + + static class EmployeeResourceAssembler implements SimpleRepresentationModelAssembler { + + @Override + public void addLinks(EntityModel resource) { + + resource.add(new Link("/employees/1").withSelfRel()); + resource.add(new Link("/employees").withRel("employees")); + } + + @Override + public void addLinks(CollectionModel> resources) { + resources.add(new Link("/employees").withSelfRel()); + } + } + +}