Skip to content

Commit 1dae476

Browse files
committed
Adapt Content-Type header for HAL FORMS requests based on affordances.
Whenever HAL FORMS is supposed to be rendered, we now adapt the `Content-Type` header depending on whether affordances (and thus `_templates`) are present. If none can be found, we either adapt the header to either `application/hal+json` or even `application/json` depending on the `Accept` header arrangement given. If none match, we reject the request with `406 Not Acceptable`. Had to reintroduce a dependency on Lombok to use @SneakyThrows as otherwise we cannot throw HttpMediaTypeNotAcceptableException from a ResponseBodyAdvice. A manual implementation of the pattern does not compile on Java 8 (at least on MacOS). Fixes #2060.
1 parent 291390a commit 1dae476

File tree

5 files changed

+249
-1
lines changed

5 files changed

+249
-1
lines changed

spring-data-rest-tests/spring-data-rest-tests-jpa/src/test/java/org/springframework/data/rest/webmvc/jpa/JpaWebTests.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import static org.junit.Assert.*;
2121
import static org.junit.Assert.assertThat;
2222
import static org.springframework.data.rest.webmvc.util.TestUtils.*;
23+
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
2324
import static org.springframework.http.HttpHeaders.*;
2425
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
2526
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -40,6 +41,7 @@
4041
import org.springframework.context.support.GenericApplicationContext;
4142
import org.springframework.data.rest.core.mapping.ResourceMappings;
4243
import org.springframework.data.rest.tests.CommonWebTests;
44+
import org.springframework.data.rest.webmvc.RepositoryLinksResource;
4345
import org.springframework.data.rest.webmvc.jpa.JpaRepositoryConfig.BooksHtmlController;
4446
import org.springframework.data.rest.webmvc.jpa.JpaRepositoryConfig.OrdersJsonController;
4547
import org.springframework.hateoas.IanaLinkRelations;
@@ -48,6 +50,7 @@
4850
import org.springframework.hateoas.Links;
4951
import org.springframework.hateoas.MediaTypes;
5052
import org.springframework.hateoas.server.LinkRelationProvider;
53+
import org.springframework.hateoas.server.RepresentationModelProcessor;
5154
import org.springframework.http.MediaType;
5255
import org.springframework.mock.web.MockHttpServletResponse;
5356
import org.springframework.test.context.ContextConfiguration;
@@ -93,6 +96,27 @@ public void initialize(GenericApplicationContext ctx) {
9396
ctx.registerBean(AuthorsController.class);
9497
ctx.registerBean(BooksHtmlController.class);
9598
ctx.registerBean(OrdersJsonController.class);
99+
ctx.registerBean(RepositoryLinkAffordanceAdder.class);
100+
}
101+
}
102+
103+
/**
104+
* Registered to add an affordance to the {@link RepositoryLinksResource} to make sure HAL FORMS can be requested in
105+
* {@link JpaWebTests#answersToHalFormsRequests()}.
106+
*
107+
* @author Oliver Drotbohm
108+
*/
109+
static class RepositoryLinkAffordanceAdder implements RepresentationModelProcessor<RepositoryLinksResource> {
110+
111+
/*
112+
* (non-Javadoc)
113+
* @see org.springframework.hateoas.server.RepresentationModelProcessor#process(org.springframework.hateoas.RepresentationModel)
114+
*/
115+
@Override
116+
public RepositoryLinksResource process(RepositoryLinksResource model) {
117+
118+
return model.mapLink(LinkRelation.of("authors"),
119+
link -> link.andAffordance(afford(methodOn(AuthorsController.class).deleteAuthor(null))));
96120
}
97121
}
98122

spring-data-rest-webmvc/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@
5050
<scope>provided</scope>
5151
</dependency>
5252

53+
<dependency>
54+
<groupId>org.projectlombok</groupId>
55+
<artifactId>lombok</artifactId>
56+
<version>${lombok}</version>
57+
<optional>true</optional>
58+
</dependency>
59+
5360
<!-- Jackson -->
5461

5562
<dependency>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.rest.webmvc.config;
17+
18+
import lombok.SneakyThrows;
19+
20+
import java.util.Arrays;
21+
import java.util.List;
22+
23+
import org.slf4j.Logger;
24+
import org.slf4j.LoggerFactory;
25+
import org.springframework.core.MethodParameter;
26+
import org.springframework.hateoas.MediaTypes;
27+
import org.springframework.hateoas.RepresentationModel;
28+
import org.springframework.hateoas.server.mvc.TypeConstrainedMappingJackson2HttpMessageConverter;
29+
import org.springframework.http.MediaType;
30+
import org.springframework.http.converter.HttpMessageConverter;
31+
import org.springframework.http.server.ServerHttpRequest;
32+
import org.springframework.http.server.ServerHttpResponse;
33+
import org.springframework.web.HttpMediaTypeNotAcceptableException;
34+
import org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor;
35+
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
36+
37+
/**
38+
* {@link ResponseBodyAdvice} that tweaks responses asking for HAL FORMS to potentially fall back to a non-forms
39+
* {@link MediaType} in case no affordances are registered on the {@link RepresentationModel} to be rendered.
40+
*
41+
* @author Oliver Drotbohm
42+
*/
43+
class HalFormsAdaptingResponseBodyAdvice<T extends RepresentationModel<T>>
44+
implements ResponseBodyAdvice<RepresentationModel<T>> {
45+
46+
private static final Logger logger = LoggerFactory.getLogger(RequestResponseBodyMethodProcessor.class);
47+
private static final String MESSAGE = "HalFormsRejectingResponseBodyAdvice - Changing content type to '%s' as no affordances were registered on the representation model to be rendered!";
48+
private static final List<MediaType> SUPPORTED_MEDIA_TYPES = Arrays.asList(MediaTypes.HAL_JSON,
49+
MediaType.APPLICATION_JSON);
50+
51+
/*
52+
* (non-Javadoc)
53+
* @see org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice#supports(org.springframework.core.MethodParameter, java.lang.Class)
54+
*/
55+
@Override
56+
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
57+
return TypeConstrainedMappingJackson2HttpMessageConverter.class.isAssignableFrom(converterType);
58+
}
59+
60+
/*
61+
* (non-Javadoc)
62+
* @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)
63+
*/
64+
@Override
65+
@SneakyThrows
66+
public RepresentationModel<T> beforeBodyWrite(RepresentationModel<T> body, MethodParameter returnType,
67+
MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType,
68+
ServerHttpRequest request, ServerHttpResponse response) {
69+
70+
// Only step in if we are about to render HAL FORMS
71+
if (!MediaTypes.HAL_FORMS_JSON.equals(selectedContentType)) {
72+
return body;
73+
}
74+
75+
List<MediaType> accept = request.getHeaders().getAccept();
76+
77+
boolean hasAffordances = body.getLinks().stream()
78+
.anyMatch(it -> !it.getAffordances().isEmpty());
79+
80+
// Affordances registered -> we're fine as we will render templates
81+
if (hasAffordances) {
82+
return body;
83+
}
84+
85+
// Check whether either HAL or general JSON are acceptable
86+
for (MediaType candidate : accept) {
87+
for (MediaType supported : SUPPORTED_MEDIA_TYPES) {
88+
if (candidate.isCompatibleWith(supported)) {
89+
90+
// Tweak response to expose that
91+
logger.debug(String.format(MESSAGE, supported));
92+
response.getHeaders().setContentType(supported);
93+
94+
return body;
95+
}
96+
}
97+
}
98+
99+
// Reject the request otherwise
100+
throw new HttpMediaTypeNotAcceptableException(SUPPORTED_MEDIA_TYPES);
101+
}
102+
}

spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/RepositoryRestMvcConfiguration.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
125125
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;
126126
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
127+
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
127128
import org.springframework.web.util.pattern.PathPatternParser;
128129

129130
import com.fasterxml.jackson.databind.DeserializationFeature;
@@ -659,10 +660,15 @@ public RequestMappingHandlerAdapter repositoryExporterHandlerAdapter(
659660
handlerAdapter.setWebBindingInitializer(initializer);
660661
handlerAdapter.setMessageConverters(defaultMessageConverters);
661662

663+
List<ResponseBodyAdvice<?>> advices = new ArrayList<>();
664+
advices.add(new HalFormsAdaptingResponseBodyAdvice<>());
665+
662666
if (repositoryRestConfiguration.getMetadataConfiguration().alpsEnabled()) {
663-
handlerAdapter.setResponseBodyAdvice(Arrays.asList(alpsJsonHttpMessageConverter));
667+
advices.addAll(Arrays.asList(alpsJsonHttpMessageConverter));
664668
}
665669

670+
handlerAdapter.setResponseBodyAdvice(advices);
671+
666672
return handlerAdapter;
667673
}
668674

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright 2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.rest.webmvc.config;
17+
18+
import static org.assertj.core.api.Assertions.*;
19+
20+
import java.util.Arrays;
21+
22+
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.api.extension.ExtendWith;
24+
import org.mockito.Mock;
25+
import org.mockito.junit.jupiter.MockitoExtension;
26+
import org.springframework.core.MethodParameter;
27+
import org.springframework.hateoas.Link;
28+
import org.springframework.hateoas.MediaTypes;
29+
import org.springframework.hateoas.RepresentationModel;
30+
import org.springframework.hateoas.mediatype.Affordances;
31+
import org.springframework.hateoas.server.mvc.TypeConstrainedMappingJackson2HttpMessageConverter;
32+
import org.springframework.http.HttpHeaders;
33+
import org.springframework.http.HttpMethod;
34+
import org.springframework.http.MediaType;
35+
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
36+
import org.springframework.http.server.ServletServerHttpRequest;
37+
import org.springframework.http.server.ServletServerHttpResponse;
38+
import org.springframework.mock.web.MockHttpServletRequest;
39+
import org.springframework.mock.web.MockHttpServletResponse;
40+
import org.springframework.web.HttpMediaTypeNotAcceptableException;
41+
42+
/**
43+
* Unit tests for {@link HalFormsAdaptingResponseBodyAdvice}.
44+
*
45+
* @author Oliver Drotbohm
46+
*/
47+
@ExtendWith(MockitoExtension.class)
48+
public class HalFormsAdaptingResponseBodyAdviceTests<T extends RepresentationModel<T>> {
49+
50+
HalFormsAdaptingResponseBodyAdvice<T> advice = new HalFormsAdaptingResponseBodyAdvice<>();
51+
52+
@Mock MethodParameter parameter;
53+
MockHttpServletRequest request = new MockHttpServletRequest();
54+
MockHttpServletResponse response = new MockHttpServletResponse();
55+
56+
@Test // #2060
57+
void supportsTypeConstraintedHttpMessageConverterOnly() {
58+
59+
assertThat(advice.supports(parameter, TypeConstrainedMappingJackson2HttpMessageConverter.class)).isTrue();
60+
assertThat(advice.supports(parameter, MappingJackson2HttpMessageConverter.class)).isFalse();
61+
}
62+
63+
@Test // #2060
64+
void usesHalJsonContentTypeIfNoAffordancesSet() {
65+
66+
request.addHeader(HttpHeaders.ACCEPT,
67+
MediaType.toString(Arrays.asList(MediaTypes.HAL_FORMS_JSON, MediaTypes.HAL_JSON)));
68+
69+
RepresentationModel<T> model = new RepresentationModel<>();
70+
71+
assertResponseContentType(model, MediaTypes.HAL_JSON);
72+
}
73+
74+
@Test // #2060
75+
void usesHalFormsContentTypeIfAffordancesPresent() {
76+
77+
request.addHeader(HttpHeaders.ACCEPT,
78+
MediaType.toString(Arrays.asList(MediaTypes.HAL_FORMS_JSON, MediaTypes.HAL_JSON)));
79+
80+
RepresentationModel<T> model = new RepresentationModel<>();
81+
model.add(Affordances.of(Link.of("localhost")).afford(HttpMethod.GET).build().toLink());
82+
83+
assertResponseContentType(model, MediaTypes.HAL_FORMS_JSON);
84+
}
85+
86+
@Test // #2060
87+
void issues415IfNoCompatibleMediaTypeWasRequested() {
88+
89+
request.addHeader(HttpHeaders.ACCEPT,
90+
MediaType.toString(Arrays.asList(MediaTypes.HAL_FORMS_JSON)));
91+
92+
RepresentationModel<T> model = new RepresentationModel<>();
93+
94+
assertThatExceptionOfType(HttpMediaTypeNotAcceptableException.class)
95+
.isThrownBy(() -> assertResponseContentType(model, MediaTypes.HAL_JSON));
96+
}
97+
98+
private void assertResponseContentType(RepresentationModel<T> model, MediaType mediaType) {
99+
100+
this.response.addHeader(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_FORMS_JSON_VALUE);
101+
ServletServerHttpResponse response = new ServletServerHttpResponse(this.response);
102+
103+
advice.beforeBodyWrite(model, parameter, MediaTypes.HAL_FORMS_JSON,
104+
TypeConstrainedMappingJackson2HttpMessageConverter.class,
105+
new ServletServerHttpRequest(request), response);
106+
107+
assertThat(response.getHeaders().getContentType()).isEqualTo(mediaType);
108+
}
109+
}

0 commit comments

Comments
 (0)