Skip to content

Commit 78ab4d7

Browse files
committed
Support content negotiation for RFC 7807
Closes gh-28189
1 parent f3fd8f9 commit 78ab4d7

File tree

10 files changed

+228
-33
lines changed

10 files changed

+228
-33
lines changed

spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -38,6 +38,7 @@
3838
import org.springframework.core.codec.Hints;
3939
import org.springframework.http.HttpLogging;
4040
import org.springframework.http.MediaType;
41+
import org.springframework.http.ProblemDetail;
4142
import org.springframework.http.server.reactive.ServerHttpRequest;
4243
import org.springframework.http.server.reactive.ServerHttpResponse;
4344
import org.springframework.lang.Nullable;
@@ -89,15 +90,24 @@ public abstract class Jackson2CodecSupport {
8990

9091
private final List<MimeType> mimeTypes;
9192

93+
private final List<MimeType> problemDetailMimeTypes;
94+
9295

9396
/**
9497
* Constructor with a Jackson {@link ObjectMapper} to use.
9598
*/
9699
protected Jackson2CodecSupport(ObjectMapper objectMapper, MimeType... mimeTypes) {
97100
Assert.notNull(objectMapper, "ObjectMapper must not be null");
98101
this.defaultObjectMapper = objectMapper;
99-
this.mimeTypes = !ObjectUtils.isEmpty(mimeTypes) ?
100-
List.of(mimeTypes) : DEFAULT_MIME_TYPES;
102+
this.mimeTypes = (!ObjectUtils.isEmpty(mimeTypes) ? List.of(mimeTypes) : DEFAULT_MIME_TYPES);
103+
this.problemDetailMimeTypes = initProblemDetailMediaTypes(this.mimeTypes);
104+
}
105+
106+
private static List<MimeType> initProblemDetailMediaTypes(List<MimeType> supportedMimeTypes) {
107+
List<MimeType> mimeTypes = new ArrayList<>();
108+
mimeTypes.add(MediaType.APPLICATION_PROBLEM_JSON);
109+
mimeTypes.addAll(supportedMimeTypes);
110+
return Collections.unmodifiableList(mimeTypes);
101111
}
102112

103113

@@ -180,7 +190,10 @@ protected List<MimeType> getMimeTypes(ResolvableType elementType) {
180190
result.addAll(entry.getValue().keySet());
181191
}
182192
}
183-
return (CollectionUtils.isEmpty(result) ? getMimeTypes() : result);
193+
if (!CollectionUtils.isEmpty(result)) {
194+
return result;
195+
}
196+
return (ProblemDetail.class.isAssignableFrom(elementClass) ? this.problemDetailMimeTypes : getMimeTypes());
184197
}
185198

186199
protected boolean supportsMimeType(@Nullable MimeType mimeType) {

spring-web/src/main/java/org/springframework/http/converter/AbstractHttpMessageConverter.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -100,12 +100,12 @@ protected AbstractHttpMessageConverter(Charset defaultCharset, MediaType... supp
100100
*/
101101
public void setSupportedMediaTypes(List<MediaType> supportedMediaTypes) {
102102
Assert.notEmpty(supportedMediaTypes, "MediaType List must not be empty");
103-
this.supportedMediaTypes = new ArrayList<>(supportedMediaTypes);
103+
this.supportedMediaTypes = Collections.unmodifiableList(new ArrayList<>(supportedMediaTypes));
104104
}
105105

106106
@Override
107107
public List<MediaType> getSupportedMediaTypes() {
108-
return Collections.unmodifiableList(this.supportedMediaTypes);
108+
return this.supportedMediaTypes;
109109
}
110110

111111
/**

spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
import org.springframework.http.HttpInputMessage;
5454
import org.springframework.http.HttpOutputMessage;
5555
import org.springframework.http.MediaType;
56+
import org.springframework.http.ProblemDetail;
5657
import org.springframework.http.converter.AbstractGenericHttpMessageConverter;
5758
import org.springframework.http.converter.HttpMessageConversionException;
5859
import org.springframework.http.converter.HttpMessageConverter;
@@ -92,6 +93,8 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener
9293
}
9394

9495

96+
private List<MediaType> problemDetailMediaTypes = Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON);
97+
9598
protected ObjectMapper defaultObjectMapper;
9699

97100
@Nullable
@@ -122,6 +125,19 @@ protected AbstractJackson2HttpMessageConverter(ObjectMapper objectMapper, MediaT
122125
}
123126

124127

128+
@Override
129+
public void setSupportedMediaTypes(List<MediaType> supportedMediaTypes) {
130+
this.problemDetailMediaTypes = initProblemDetailMediaTypes(supportedMediaTypes);
131+
super.setSupportedMediaTypes(supportedMediaTypes);
132+
}
133+
134+
private List<MediaType> initProblemDetailMediaTypes(List<MediaType> supportedMediaTypes) {
135+
List<MediaType> mediaTypes = new ArrayList<>();
136+
mediaTypes.add(MediaType.APPLICATION_PROBLEM_JSON);
137+
mediaTypes.addAll(supportedMediaTypes);
138+
return Collections.unmodifiableList(mediaTypes);
139+
}
140+
125141
/**
126142
* Configure the main {@code ObjectMapper} to use for Object conversion.
127143
* If not set, a default {@link ObjectMapper} instance is created.
@@ -198,7 +214,11 @@ public List<MediaType> getSupportedMediaTypes(Class<?> clazz) {
198214
result.addAll(entry.getValue().keySet());
199215
}
200216
}
201-
return (CollectionUtils.isEmpty(result) ? getSupportedMediaTypes() : result);
217+
if (!CollectionUtils.isEmpty(result)) {
218+
return result;
219+
}
220+
return (ProblemDetail.class.isAssignableFrom(clazz) ?
221+
this.problemDetailMediaTypes : getSupportedMediaTypes());
202222
}
203223

204224
private Map<Class<?>, Map<MediaType, ObjectMapper>> getObjectMapperRegistrations() {

spring-webflux/src/main/java/org/springframework/web/reactive/result/HandlerResultHandlerSupport.java

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,25 @@ protected ReactiveAdapter getAdapter(HandlerResult result) {
111111
}
112112

113113
/**
114-
* Select the best media type for the current request through a content negotiation algorithm.
114+
* Select the best media type for the current request through a content
115+
* negotiation algorithm.
115116
* @param exchange the current request
116-
* @param producibleTypesSupplier the media types that can be produced for the current request
117+
* @param producibleTypesSupplier the media types producible for the request
117118
* @return the selected media type, or {@code null} if none
118119
*/
119120
@Nullable
121+
protected MediaType selectMediaType(ServerWebExchange exchange, Supplier<List<MediaType>> producibleTypesSupplier) {
122+
return selectMediaType(exchange, producibleTypesSupplier, getAcceptableTypes(exchange));
123+
}
124+
125+
/**
126+
* Variant of {@link #selectMediaType(ServerWebExchange, Supplier)} with a
127+
* given list of requested (acceptable) media types.
128+
*/
129+
@Nullable
120130
protected MediaType selectMediaType(
121-
ServerWebExchange exchange, Supplier<List<MediaType>> producibleTypesSupplier) {
131+
ServerWebExchange exchange, Supplier<List<MediaType>> producibleTypesSupplier,
132+
List<MediaType> acceptableTypes) {
122133

123134
MediaType contentType = exchange.getResponse().getHeaders().getContentType();
124135
if (contentType != null && contentType.isConcrete()) {
@@ -128,7 +139,6 @@ protected MediaType selectMediaType(
128139
return contentType;
129140
}
130141

131-
List<MediaType> acceptableTypes = getAcceptableTypes(exchange);
132142
List<MediaType> producibleTypes = getProducibleTypes(exchange, producibleTypesSupplier);
133143

134144
Set<MediaType> compatibleMediaTypes = new LinkedHashSet<>();

spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageWriterResultHandler.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
1717
package org.springframework.web.reactive.result.method.annotation;
1818

1919
import java.util.ArrayList;
20+
import java.util.Arrays;
2021
import java.util.List;
2122
import java.util.Set;
2223

@@ -32,6 +33,7 @@
3233
import org.springframework.core.codec.Hints;
3334
import org.springframework.http.HttpStatusCode;
3435
import org.springframework.http.MediaType;
36+
import org.springframework.http.ProblemDetail;
3537
import org.springframework.http.codec.HttpMessageWriter;
3638
import org.springframework.http.converter.HttpMessageNotWritableException;
3739
import org.springframework.lang.Nullable;
@@ -57,6 +59,9 @@ public abstract class AbstractMessageWriterResultHandler extends HandlerResultHa
5759

5860
private final List<HttpMessageWriter<?>> messageWriters;
5961

62+
private final List<MediaType> problemMediaTypes =
63+
Arrays.asList(MediaType.APPLICATION_PROBLEM_JSON, MediaType.APPLICATION_PROBLEM_XML);
64+
6065

6166
/**
6267
* Constructor with {@link HttpMessageWriter HttpMessageWriters} and a
@@ -161,6 +166,12 @@ protected Mono<Void> writeBody(@Nullable Object body, MethodParameter bodyParame
161166
}
162167
throw ex;
163168
}
169+
170+
// Fall back on RFC 7807 format for ProblemDetail
171+
if (bestMediaType == null && elementType.toClass().equals(ProblemDetail.class)) {
172+
bestMediaType = selectMediaType(exchange, () -> getMediaTypesFor(elementType), this.problemMediaTypes);
173+
}
174+
164175
if (bestMediaType != null) {
165176
String logPrefix = exchange.getLogPrefix();
166177
if (logger.isDebugEnabled()) {

spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandler.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2018 the original author or authors.
2+
* Copyright 2002-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,13 +16,16 @@
1616

1717
package org.springframework.web.reactive.result.method.annotation;
1818

19+
import java.net.URI;
1920
import java.util.List;
2021

2122
import reactor.core.publisher.Mono;
2223

2324
import org.springframework.core.MethodParameter;
2425
import org.springframework.core.ReactiveAdapterRegistry;
2526
import org.springframework.core.annotation.AnnotatedElementUtils;
27+
import org.springframework.http.HttpStatusCode;
28+
import org.springframework.http.ProblemDetail;
2629
import org.springframework.http.codec.HttpMessageWriter;
2730
import org.springframework.web.bind.annotation.ResponseBody;
2831
import org.springframework.web.reactive.HandlerResult;
@@ -83,6 +86,13 @@ public boolean supports(HandlerResult result) {
8386
public Mono<Void> handleResult(ServerWebExchange exchange, HandlerResult result) {
8487
Object body = result.getReturnValue();
8588
MethodParameter bodyTypeParameter = result.getReturnTypeSource();
89+
if (body instanceof ProblemDetail detail) {
90+
exchange.getResponse().setStatusCode(HttpStatusCode.valueOf(detail.getStatus()));
91+
if (detail.getInstance() == null) {
92+
URI path = URI.create(exchange.getRequest().getPath().value());
93+
detail.setInstance(path);
94+
}
95+
}
8696
return writeBody(body, bodyTypeParameter, exchange);
8797
}
8898

spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.web.reactive.result.method.annotation;
1818

1919
import java.lang.reflect.Method;
20+
import java.time.Duration;
2021
import java.util.ArrayList;
2122
import java.util.List;
2223

@@ -25,23 +26,31 @@
2526
import org.junit.jupiter.api.BeforeEach;
2627
import org.junit.jupiter.api.Test;
2728
import reactor.core.publisher.Mono;
29+
import reactor.test.StepVerifier;
2830

2931
import org.springframework.core.codec.ByteBufferEncoder;
3032
import org.springframework.core.codec.CharSequenceEncoder;
33+
import org.springframework.http.HttpStatus;
34+
import org.springframework.http.MediaType;
35+
import org.springframework.http.ProblemDetail;
3136
import org.springframework.http.codec.EncoderHttpMessageWriter;
3237
import org.springframework.http.codec.HttpMessageWriter;
3338
import org.springframework.http.codec.ResourceHttpMessageWriter;
3439
import org.springframework.http.codec.json.Jackson2JsonEncoder;
3540
import org.springframework.http.codec.xml.Jaxb2XmlEncoder;
41+
import org.springframework.lang.Nullable;
3642
import org.springframework.stereotype.Controller;
3743
import org.springframework.web.bind.annotation.ResponseBody;
3844
import org.springframework.web.bind.annotation.RestController;
3945
import org.springframework.web.method.HandlerMethod;
4046
import org.springframework.web.reactive.HandlerResult;
4147
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
4248
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
49+
import org.springframework.web.testfixture.server.MockServerWebExchange;
4350

51+
import static java.nio.charset.StandardCharsets.UTF_8;
4452
import static org.assertj.core.api.Assertions.assertThat;
53+
import static org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest.get;
4554
import static org.springframework.web.testfixture.method.ResolvableMethod.on;
4655

4756
/**
@@ -82,7 +91,7 @@ public void supports() {
8291
testSupports(controller, method);
8392

8493
method = on(TestController.class).annotNotPresent(ResponseBody.class).resolveMethod("doWork");
85-
HandlerResult handlerResult = getHandlerResult(controller, method);
94+
HandlerResult handlerResult = getHandlerResult(controller, null, method);
8695
assertThat(this.resultHandler.supports(handlerResult)).isFalse();
8796
}
8897

@@ -105,20 +114,60 @@ public void supportsRestController() {
105114
}
106115

107116
private void testSupports(Object controller, Method method) {
108-
HandlerResult handlerResult = getHandlerResult(controller, method);
117+
HandlerResult handlerResult = getHandlerResult(controller, null, method);
109118
assertThat(this.resultHandler.supports(handlerResult)).isTrue();
110119
}
111120

112-
private HandlerResult getHandlerResult(Object controller, Method method) {
113-
HandlerMethod handlerMethod = new HandlerMethod(controller, method);
114-
return new HandlerResult(handlerMethod, null, handlerMethod.getReturnType());
121+
@Test
122+
void problemDetailContentNegotiation() {
123+
124+
// Default
125+
MockServerWebExchange exchange = MockServerWebExchange.from(get("/path"));
126+
testProblemDetailMediaType(exchange, MediaType.APPLICATION_PROBLEM_JSON);
127+
128+
// JSON requested
129+
exchange = MockServerWebExchange.from(get("/path").accept(MediaType.APPLICATION_JSON));
130+
testProblemDetailMediaType(exchange, MediaType.APPLICATION_JSON);
131+
132+
// No match fallback
133+
exchange = MockServerWebExchange.from(get("/path").accept(MediaType.APPLICATION_PDF));
134+
testProblemDetailMediaType(exchange, MediaType.APPLICATION_PROBLEM_JSON);
135+
}
136+
137+
private void testProblemDetailMediaType(MockServerWebExchange exchange, MediaType expectedMediaType) {
138+
ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
139+
140+
Method method = on(TestRestController.class).returning(ProblemDetail.class).resolveMethod();
141+
HandlerResult result = getHandlerResult(new TestRestController(), problemDetail, method);
142+
143+
this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5));
144+
145+
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
146+
assertThat(exchange.getResponse().getHeaders().getContentType()).isEqualTo(expectedMediaType);
147+
assertResponseBody(exchange,
148+
"{\"type\":\"about:blank\"," +
149+
"\"title\":\"Bad Request\"," +
150+
"\"status\":400," +
151+
"\"detail\":null," +
152+
"\"instance\":\"/path\"}");
115153
}
116154

117155
@Test
118156
public void defaultOrder() {
119157
assertThat(this.resultHandler.getOrder()).isEqualTo(100);
120158
}
121159

160+
private HandlerResult getHandlerResult(Object controller, @Nullable Object returnValue, Method method) {
161+
HandlerMethod handlerMethod = new HandlerMethod(controller, method);
162+
return new HandlerResult(handlerMethod, returnValue, handlerMethod.getReturnType());
163+
}
164+
165+
private void assertResponseBody(MockServerWebExchange exchange, @Nullable String responseBody) {
166+
StepVerifier.create(exchange.getResponse().getBody())
167+
.consumeNextWith(buf -> assertThat(buf.toString(UTF_8)).isEqualTo(responseBody))
168+
.expectComplete()
169+
.verify();
170+
}
122171

123172

124173
@RestController
@@ -142,6 +191,11 @@ public Single<String> handleToSingleString() {
142191
public Completable handleToCompletable() {
143192
return null;
144193
}
194+
195+
public ProblemDetail handleToProblemDetail() {
196+
return null;
197+
}
198+
145199
}
146200

147201

0 commit comments

Comments
 (0)