Skip to content

Commit 714d451

Browse files
committed
Add ProblemDetail and @ExceptionHandler support
ProblemDetail is a representation of an RFC 7807 "problem", and this commits adds support for it in Spring MVC and WebFlux as a return value from `@ExceptionHandler` methods, optionally wrapped with ResponseEntity for headers. See gh-27052
1 parent 65394b0 commit 714d451

File tree

6 files changed

+425
-20
lines changed

6 files changed

+425
-20
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
/*
2+
* Copyright 2002-2022 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+
17+
package org.springframework.http;
18+
19+
import java.net.URI;
20+
21+
import org.springframework.lang.Nullable;
22+
import org.springframework.util.Assert;
23+
24+
/**
25+
* Representation of an RFC 7807 problem detail, including all RFC-defined
26+
* fields. For an extended response with more fields, create a subclass that
27+
* exposes the additional fields.
28+
*
29+
* @author Rossen Stoyanchev
30+
* @since 6.0
31+
*
32+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7807">RFC 7807</a>
33+
* @see org.springframework.web.ErrorResponse
34+
* @see org.springframework.web.ErrorResponseException
35+
*/
36+
public class ProblemDetail {
37+
38+
private static final URI BLANK_TYPE = URI.create("about:blank");
39+
40+
41+
private URI type = BLANK_TYPE;
42+
43+
@Nullable
44+
private String title;
45+
46+
private int status;
47+
48+
@Nullable
49+
private String detail;
50+
51+
@Nullable
52+
private URI instance;
53+
54+
55+
/**
56+
* Protected constructor for subclasses.
57+
* <p>To create a {@link ProblemDetail} instance, use static factory methods,
58+
* {@link #forStatus(HttpStatus)} or {@link #forRawStatusCode(int)}.
59+
* @param rawStatusCode the response status to use
60+
*/
61+
protected ProblemDetail(int rawStatusCode) {
62+
this.status = rawStatusCode;
63+
}
64+
65+
/**
66+
* Copy constructor that could be used from a subclass to re-create a
67+
* {@code ProblemDetail} in order to extend it with more fields.
68+
*/
69+
protected ProblemDetail(ProblemDetail other) {
70+
this.type = other.type;
71+
this.title = other.title;
72+
this.status = other.status;
73+
this.detail = other.detail;
74+
this.instance = other.instance;
75+
}
76+
77+
78+
/**
79+
* Variant of {@link #setType(URI)} for chained initialization.
80+
* @param type the problem type
81+
* @return the same instance
82+
*/
83+
public ProblemDetail withType(URI type) {
84+
setType(type);
85+
return this;
86+
}
87+
88+
/**
89+
* Variant of {@link #setTitle(String)} for chained initialization.
90+
* @param title the problem title
91+
* @return the same instance
92+
*/
93+
public ProblemDetail withTitle(@Nullable String title) {
94+
setTitle(title);
95+
return this;
96+
}
97+
98+
/**
99+
* Variant of {@link #setStatus(int)} for chained initialization.
100+
* @param status the response status for the problem
101+
* @return the same instance
102+
*/
103+
public ProblemDetail withStatus(HttpStatus status) {
104+
Assert.notNull(status, "HttpStatus is required");
105+
setStatus(status.value());
106+
return this;
107+
}
108+
109+
/**
110+
* Variant of {@link #setStatus(int)} for chained initialization.
111+
* @param status the response status value for the problem
112+
* @return the same instance
113+
*/
114+
public ProblemDetail withRawStatusCode(int status) {
115+
setStatus(status);
116+
return this;
117+
}
118+
119+
/**
120+
* Variant of {@link #setDetail(String)} for chained initialization.
121+
* @param detail the problem detail
122+
* @return the same instance
123+
*/
124+
public ProblemDetail withDetail(@Nullable String detail) {
125+
setDetail(detail);
126+
return this;
127+
}
128+
129+
/**
130+
* Variant of {@link #setInstance(URI)} for chained initialization.
131+
* @param instance the problem instance URI
132+
* @return the same instance
133+
*/
134+
public ProblemDetail withInstance(@Nullable URI instance) {
135+
setInstance(instance);
136+
return this;
137+
}
138+
139+
140+
// Setters for deserialization
141+
142+
/**
143+
* Setter for the {@link #getType() problem type}.
144+
* <p>By default, this is {@link #BLANK_TYPE}.
145+
* @param type the problem type
146+
* @see #withType(URI)
147+
*/
148+
public void setType(URI type) {
149+
Assert.notNull(type, "'type' is required");
150+
this.type = type;
151+
}
152+
153+
/**
154+
* Setter for the {@link #getTitle() problem title}.
155+
* <p>By default, if not explicitly set and the status is well-known, this
156+
* is sourced from the {@link HttpStatus#getReasonPhrase()}.
157+
* @param title the problem title
158+
* @see #withTitle(String)
159+
*/
160+
public void setTitle(@Nullable String title) {
161+
this.title = title;
162+
}
163+
164+
/**
165+
* Setter for the {@link #getStatus() problem status}.
166+
* @param status the problem status
167+
* @see #withStatus(HttpStatus)
168+
* @see #withRawStatusCode(int)
169+
*/
170+
public void setStatus(int status) {
171+
this.status = status;
172+
}
173+
174+
/**
175+
* Setter for the {@link #getDetail() problem detail}.
176+
* <p>By default, this is not set.
177+
* @param detail the problem detail
178+
* @see #withDetail(String)
179+
*/
180+
public void setDetail(@Nullable String detail) {
181+
this.detail = detail;
182+
}
183+
184+
/**
185+
* Setter for the {@link #getInstance() problem instance}.
186+
* <p>By default, when {@code ProblemDetail} is returned from an
187+
* {@code @ExceptionHandler} method, this is initialized to the request path.
188+
* @param instance the problem instance
189+
* @see #withInstance(URI)
190+
*/
191+
public void setInstance(@Nullable URI instance) {
192+
this.instance = instance;
193+
}
194+
195+
196+
// Getters
197+
198+
/**
199+
* Return the configured {@link #setType(URI) problem type}.
200+
*/
201+
public URI getType() {
202+
return this.type;
203+
}
204+
205+
/**
206+
* Return the configured {@link #setTitle(String) problem title}.
207+
*/
208+
@Nullable
209+
public String getTitle() {
210+
if (this.title == null) {
211+
HttpStatus httpStatus = HttpStatus.resolve(this.status);
212+
if (httpStatus != null) {
213+
return httpStatus.getReasonPhrase();
214+
}
215+
}
216+
return this.title;
217+
}
218+
219+
/**
220+
* Return the status associated with the problem, provided either to the
221+
* constructor or configured via {@link #setStatus(int)}.
222+
*/
223+
public int getStatus() {
224+
return this.status;
225+
}
226+
227+
/**
228+
* Return the configured {@link #setDetail(String) problem detail}.
229+
*/
230+
@Nullable
231+
public String getDetail() {
232+
return this.detail;
233+
}
234+
235+
/**
236+
* Return the configured {@link #setInstance(URI) problem instance}.
237+
*/
238+
@Nullable
239+
public URI getInstance() {
240+
return this.instance;
241+
}
242+
243+
244+
@Override
245+
public String toString() {
246+
return getClass().getSimpleName() + "[" + initToStringContent() + "]";
247+
}
248+
249+
/**
250+
* Return a String representation of the {@code ProblemDetail} fields.
251+
* Subclasses can override this to append additional fields.
252+
*/
253+
protected String initToStringContent() {
254+
return "type='" + this.type + "'" +
255+
", title='" + getTitle() + "'" +
256+
", status=" + getStatus() +
257+
", detail='" + getDetail() + "'" +
258+
", instance='" + getInstance() + "'";
259+
}
260+
261+
262+
// Static factory methods
263+
264+
/**
265+
* Create a {@code ProblemDetail} instance with the given status code.
266+
*/
267+
public static ProblemDetail forStatus(HttpStatus status) {
268+
Assert.notNull(status, "HttpStatus is required");
269+
return forRawStatusCode(status.value());
270+
}
271+
272+
/**
273+
* Create a {@code ProblemDetail} instance with the given status value.
274+
*/
275+
public static ProblemDetail forRawStatusCode(int status) {
276+
return new ProblemDetail(status);
277+
}
278+
279+
}

spring-web/src/main/java/org/springframework/http/ResponseEntity.java

+22-1
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.
@@ -260,6 +260,27 @@ public static <T> ResponseEntity<T> of(Optional<T> body) {
260260
return body.map(ResponseEntity::ok).orElseGet(() -> notFound().build());
261261
}
262262

263+
/**
264+
* Create a builder for a {@code ResponseEntity} with the given
265+
* {@link ProblemDetail} as the body, also matching to its
266+
* {@link ProblemDetail#getStatus() status}. An {@code @ExceptionHandler}
267+
* method can use to add response headers, or otherwise it can return
268+
* {@code ProblemDetail}.
269+
* @param body the details for an HTTP error response
270+
* @return the created builder
271+
* @since 6.0
272+
*/
273+
public static HeadersBuilder<?> of(ProblemDetail body) {
274+
return new DefaultBuilder(body.getStatus()) {
275+
276+
@SuppressWarnings("unchecked")
277+
@Override
278+
public <T> ResponseEntity<T> build() {
279+
return (ResponseEntity<T>) body(body);
280+
}
281+
};
282+
}
283+
263284
/**
264285
* Create a new builder with a {@linkplain HttpStatus#CREATED CREATED} status
265286
* and a location header set to the given URI.

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

+21-6
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.
@@ -16,6 +16,7 @@
1616

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

19+
import java.net.URI;
1920
import java.time.Instant;
2021
import java.util.List;
2122
import java.util.Set;
@@ -30,6 +31,7 @@
3031
import org.springframework.http.HttpHeaders;
3132
import org.springframework.http.HttpMethod;
3233
import org.springframework.http.HttpStatus;
34+
import org.springframework.http.ProblemDetail;
3335
import org.springframework.http.RequestEntity;
3436
import org.springframework.http.ResponseEntity;
3537
import org.springframework.http.codec.HttpMessageWriter;
@@ -41,7 +43,8 @@
4143
import org.springframework.web.server.ServerWebExchange;
4244

4345
/**
44-
* Handles {@link HttpEntity} and {@link ResponseEntity} return values.
46+
* Handles return values of type {@link HttpEntity}, {@link ResponseEntity},
47+
* {@link HttpHeaders}, and {@link ProblemDetail}.
4548
*
4649
* <p>By default the order for this result handler is set to 0. It is generally
4750
* safe to place it early in the order as it looks for a concrete return type.
@@ -100,10 +103,12 @@ private static Class<?> resolveReturnValueType(HandlerResult result) {
100103
return valueType;
101104
}
102105

103-
private boolean isSupportedType(@Nullable Class<?> clazz) {
104-
return (clazz != null && ((HttpEntity.class.isAssignableFrom(clazz) &&
105-
!RequestEntity.class.isAssignableFrom(clazz)) ||
106-
HttpHeaders.class.isAssignableFrom(clazz)));
106+
private boolean isSupportedType(@Nullable Class<?> type) {
107+
if (type == null) {
108+
return false;
109+
}
110+
return ((HttpEntity.class.isAssignableFrom(type) && !RequestEntity.class.isAssignableFrom(type)) ||
111+
HttpHeaders.class.isAssignableFrom(type) || ProblemDetail.class.isAssignableFrom(type));
107112
}
108113

109114

@@ -136,11 +141,21 @@ public Mono<Void> handleResult(ServerWebExchange exchange, HandlerResult result)
136141
else if (returnValue instanceof HttpHeaders) {
137142
httpEntity = new ResponseEntity<>((HttpHeaders) returnValue, HttpStatus.OK);
138143
}
144+
else if (returnValue instanceof ProblemDetail detail) {
145+
httpEntity = new ResponseEntity<>(returnValue, HttpHeaders.EMPTY, detail.getStatus());
146+
}
139147
else {
140148
throw new IllegalArgumentException(
141149
"HttpEntity or HttpHeaders expected but got: " + returnValue.getClass());
142150
}
143151

152+
if (httpEntity.getBody() instanceof ProblemDetail detail) {
153+
if (detail.getInstance() == null) {
154+
URI path = URI.create(exchange.getRequest().getPath().value());
155+
detail.setInstance(path);
156+
}
157+
}
158+
144159
if (httpEntity instanceof ResponseEntity) {
145160
exchange.getResponse().setRawStatusCode(
146161
((ResponseEntity<?>) httpEntity).getStatusCodeValue());

0 commit comments

Comments
 (0)