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()); + } + } + +}