diff --git a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java index 90a3fa034..ac18ac83e 100755 --- a/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java +++ b/spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java @@ -20,6 +20,7 @@ import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; import static org.junit.Assert.assertThat; import static org.springframework.data.rest.webmvc.util.TestUtils.*; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; import static org.springframework.http.HttpHeaders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -39,6 +40,7 @@ import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.support.GenericApplicationContext; import org.springframework.data.rest.core.mapping.ResourceMappings; import org.springframework.data.rest.tests.CommonWebTests; +import org.springframework.data.rest.webmvc.RepositoryLinksResource; import org.springframework.data.rest.webmvc.jpa.JpaRepositoryConfig.BooksHtmlController; import org.springframework.data.rest.webmvc.jpa.JpaRepositoryConfig.OrdersJsonController; import org.springframework.hateoas.IanaLinkRelations; @@ -47,6 +49,7 @@ import org.springframework.hateoas.LinkRelation; import org.springframework.hateoas.Links; import org.springframework.hateoas.MediaTypes; import org.springframework.hateoas.server.LinkRelationProvider; +import org.springframework.hateoas.server.RepresentationModelProcessor; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.context.ContextConfiguration; @@ -91,6 +94,27 @@ public class JpaWebTests extends CommonWebTests { ctx.registerBean(AuthorsController.class); ctx.registerBean(BooksHtmlController.class); ctx.registerBean(OrdersJsonController.class); + ctx.registerBean(RepositoryLinkAffordanceAdder.class); + } + } + + /** + * Registered to add an affordance to the {@link RepositoryLinksResource} to make sure HAL FORMS can be requested in + * {@link JpaWebTests#answersToHalFormsRequests()}. + * + * @author Oliver Drotbohm + */ + static class RepositoryLinkAffordanceAdder implements RepresentationModelProcessor { + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.server.RepresentationModelProcessor#process(org.springframework.hateoas.RepresentationModel) + */ + @Override + public RepositoryLinksResource process(RepositoryLinksResource model) { + + return model.mapLink(LinkRelation.of("authors"), + link -> link.andAffordance(afford(methodOn(AuthorsController.class).deleteAuthor(null)))); } } diff --git a/spring-data-rest-webmvc/pom.xml b/spring-data-rest-webmvc/pom.xml index fb70214fb..ac772e0ea 100644 --- a/spring-data-rest-webmvc/pom.xml +++ b/spring-data-rest-webmvc/pom.xml @@ -50,6 +50,13 @@ provided + + org.projectlombok + lombok + ${lombok} + true + + diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/HalFormsAdaptingResponseBodyAdvice.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/HalFormsAdaptingResponseBodyAdvice.java new file mode 100644 index 000000000..d6a3b735b --- /dev/null +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/HalFormsAdaptingResponseBodyAdvice.java @@ -0,0 +1,102 @@ +/* + * Copyright 2021 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.data.rest.webmvc.config; + +import lombok.SneakyThrows; + +import java.util.Arrays; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.MethodParameter; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.hateoas.server.mvc.TypeConstrainedMappingJackson2HttpMessageConverter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +/** + * {@link ResponseBodyAdvice} that tweaks responses asking for HAL FORMS to potentially fall back to a non-forms + * {@link MediaType} in case no affordances are registered on the {@link RepresentationModel} to be rendered. + * + * @author Oliver Drotbohm + */ +class HalFormsAdaptingResponseBodyAdvice> + implements ResponseBodyAdvice> { + + private static final Logger logger = LoggerFactory.getLogger(RequestResponseBodyMethodProcessor.class); + private static final String MESSAGE = "HalFormsRejectingResponseBodyAdvice - Changing content type to '%s' as no affordances were registered on the representation model to be rendered!"; + private static final List SUPPORTED_MEDIA_TYPES = Arrays.asList(MediaTypes.HAL_JSON, + MediaType.APPLICATION_JSON); + + /* + * (non-Javadoc) + * @see org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice#supports(org.springframework.core.MethodParameter, java.lang.Class) + */ + @Override + public boolean supports(MethodParameter returnType, Class> converterType) { + return TypeConstrainedMappingJackson2HttpMessageConverter.class.isAssignableFrom(converterType); + } + + /* + * (non-Javadoc) + * @see org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice#beforeBodyWrite(java.lang.Object, org.springframework.core.MethodParameter, org.springframework.http.MediaType, java.lang.Class, org.springframework.http.server.ServerHttpRequest, org.springframework.http.server.ServerHttpResponse) + */ + @Override + @SneakyThrows + public RepresentationModel beforeBodyWrite(RepresentationModel body, MethodParameter returnType, + MediaType selectedContentType, Class> selectedConverterType, + ServerHttpRequest request, ServerHttpResponse response) { + + // Only step in if we are about to render HAL FORMS + if (!MediaTypes.HAL_FORMS_JSON.equals(selectedContentType)) { + return body; + } + + List accept = request.getHeaders().getAccept(); + + boolean hasAffordances = body.getLinks().stream() + .anyMatch(it -> !it.getAffordances().isEmpty()); + + // Affordances registered -> we're fine as we will render templates + if (hasAffordances) { + return body; + } + + // Check whether either HAL or general JSON are acceptable + for (MediaType candidate : accept) { + for (MediaType supported : SUPPORTED_MEDIA_TYPES) { + if (candidate.isCompatibleWith(supported)) { + + // Tweak response to expose that + logger.debug(String.format(MESSAGE, supported)); + response.getHeaders().setContentType(supported); + + return body; + } + } + } + + // Reject the request otherwise + throw new HttpMediaTypeNotAcceptableException(SUPPORTED_MEDIA_TYPES); + } +} diff --git a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java index 505e57c76..a0b30c080 100644 --- a/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java +++ b/spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java @@ -123,6 +123,7 @@ import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; import org.springframework.web.util.pattern.PathPatternParser; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -658,10 +659,15 @@ public class RepositoryRestMvcConfiguration extends HateoasAwareSpringDataWebCon handlerAdapter.setWebBindingInitializer(initializer); handlerAdapter.setMessageConverters(defaultMessageConverters); + List> advices = new ArrayList<>(); + advices.add(new HalFormsAdaptingResponseBodyAdvice<>()); + if (repositoryRestConfiguration.getMetadataConfiguration().alpsEnabled()) { - handlerAdapter.setResponseBodyAdvice(Arrays.asList(alpsJsonHttpMessageConverter)); + advices.addAll(Arrays.asList(alpsJsonHttpMessageConverter)); } + handlerAdapter.setResponseBodyAdvice(advices); + return handlerAdapter; } diff --git a/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/HalFormsAdaptingResponseBodyAdviceTests.java b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/HalFormsAdaptingResponseBodyAdviceTests.java new file mode 100644 index 000000000..04d507cd4 --- /dev/null +++ b/spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/config/HalFormsAdaptingResponseBodyAdviceTests.java @@ -0,0 +1,109 @@ +/* + * Copyright 2021 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.data.rest.webmvc.config; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MediaTypes; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.hateoas.mediatype.Affordances; +import org.springframework.hateoas.server.mvc.TypeConstrainedMappingJackson2HttpMessageConverter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.HttpMediaTypeNotAcceptableException; + +/** + * Unit tests for {@link HalFormsAdaptingResponseBodyAdvice}. + * + * @author Oliver Drotbohm + */ +@ExtendWith(MockitoExtension.class) +public class HalFormsAdaptingResponseBodyAdviceTests> { + + HalFormsAdaptingResponseBodyAdvice advice = new HalFormsAdaptingResponseBodyAdvice<>(); + + @Mock MethodParameter parameter; + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + @Test // #2060 + void supportsTypeConstraintedHttpMessageConverterOnly() { + + assertThat(advice.supports(parameter, TypeConstrainedMappingJackson2HttpMessageConverter.class)).isTrue(); + assertThat(advice.supports(parameter, MappingJackson2HttpMessageConverter.class)).isFalse(); + } + + @Test // #2060 + void usesHalJsonContentTypeIfNoAffordancesSet() { + + request.addHeader(HttpHeaders.ACCEPT, + MediaType.toString(Arrays.asList(MediaTypes.HAL_FORMS_JSON, MediaTypes.HAL_JSON))); + + RepresentationModel model = new RepresentationModel<>(); + + assertResponseContentType(model, MediaTypes.HAL_JSON); + } + + @Test // #2060 + void usesHalFormsContentTypeIfAffordancesPresent() { + + request.addHeader(HttpHeaders.ACCEPT, + MediaType.toString(Arrays.asList(MediaTypes.HAL_FORMS_JSON, MediaTypes.HAL_JSON))); + + RepresentationModel model = new RepresentationModel<>(); + model.add(Affordances.of(Link.of("localhost")).afford(HttpMethod.GET).build().toLink()); + + assertResponseContentType(model, MediaTypes.HAL_FORMS_JSON); + } + + @Test // #2060 + void issues415IfNoCompatibleMediaTypeWasRequested() { + + request.addHeader(HttpHeaders.ACCEPT, + MediaType.toString(Arrays.asList(MediaTypes.HAL_FORMS_JSON))); + + RepresentationModel model = new RepresentationModel<>(); + + assertThatExceptionOfType(HttpMediaTypeNotAcceptableException.class) + .isThrownBy(() -> assertResponseContentType(model, MediaTypes.HAL_JSON)); + } + + private void assertResponseContentType(RepresentationModel model, MediaType mediaType) { + + this.response.addHeader(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE); + ServletServerHttpResponse response = new ServletServerHttpResponse(this.response); + + advice.beforeBodyWrite(model, parameter, MediaTypes.HAL_FORMS_JSON, + TypeConstrainedMappingJackson2HttpMessageConverter.class, + new ServletServerHttpRequest(request), response); + + assertThat(response.getHeaders().getContentType()).isEqualTo(mediaType); + } +}