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
This commit is contained in:
rstoyanchev
2022-02-23 13:00:01 +00:00
parent 65394b00ea
commit 714d451260
6 changed files with 425 additions and 20 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2022 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.
@@ -16,6 +16,7 @@
package org.springframework.web.reactive.result.method.annotation;
import java.net.URI;
import java.time.Instant;
import java.util.List;
import java.util.Set;
@@ -30,6 +31,7 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.HttpMessageWriter;
@@ -41,7 +43,8 @@ import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.server.ServerWebExchange;
/**
* Handles {@link HttpEntity} and {@link ResponseEntity} return values.
* Handles return values of type {@link HttpEntity}, {@link ResponseEntity},
* {@link HttpHeaders}, and {@link ProblemDetail}.
*
* <p>By default the order for this result handler is set to 0. It is generally
* safe to place it early in the order as it looks for a concrete return type.
@@ -100,10 +103,12 @@ public class ResponseEntityResultHandler extends AbstractMessageWriterResultHand
return valueType;
}
private boolean isSupportedType(@Nullable Class<?> clazz) {
return (clazz != null && ((HttpEntity.class.isAssignableFrom(clazz) &&
!RequestEntity.class.isAssignableFrom(clazz)) ||
HttpHeaders.class.isAssignableFrom(clazz)));
private boolean isSupportedType(@Nullable Class<?> type) {
if (type == null) {
return false;
}
return ((HttpEntity.class.isAssignableFrom(type) && !RequestEntity.class.isAssignableFrom(type)) ||
HttpHeaders.class.isAssignableFrom(type) || ProblemDetail.class.isAssignableFrom(type));
}
@@ -136,11 +141,21 @@ public class ResponseEntityResultHandler extends AbstractMessageWriterResultHand
else if (returnValue instanceof HttpHeaders) {
httpEntity = new ResponseEntity<>((HttpHeaders) returnValue, HttpStatus.OK);
}
else if (returnValue instanceof ProblemDetail detail) {
httpEntity = new ResponseEntity<>(returnValue, HttpHeaders.EMPTY, detail.getStatus());
}
else {
throw new IllegalArgumentException(
"HttpEntity or HttpHeaders expected but got: " + returnValue.getClass());
}
if (httpEntity.getBody() instanceof ProblemDetail detail) {
if (detail.getInstance() == null) {
URI path = URI.create(exchange.getRequest().getPath().value());
detail.setInstance(path);
}
}
if (httpEntity instanceof ResponseEntity) {
exchange.getResponse().setRawStatusCode(
((ResponseEntity<?>) httpEntity).getStatusCodeValue());

View File

@@ -46,6 +46,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.EncoderHttpMessageWriter;
import org.springframework.http.codec.HttpMessageWriter;
@@ -129,6 +130,9 @@ public class ResponseEntityResultHandlerTests {
returnType = on(TestController.class).resolveReturnType(HttpHeaders.class);
assertThat(this.resultHandler.supports(handlerResult(value, returnType))).isTrue();
returnType = on(TestController.class).resolveReturnType(ProblemDetail.class);
assertThat(this.resultHandler.supports(handlerResult(value, returnType))).isTrue();
// SPR-15785
value = ResponseEntity.ok("testing");
returnType = on(TestController.class).resolveReturnType(Object.class);
@@ -232,6 +236,26 @@ public class ResponseEntityResultHandlerTests {
testHandle(returnValue, returnType);
}
@Test
public void handleProblemDetail() {
ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
MethodParameter returnType = on(TestController.class).resolveReturnType(ProblemDetail.class);
HandlerResult result = handlerResult(problemDetail, returnType);
MockServerWebExchange exchange = MockServerWebExchange.from(get("/path"));
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_PROBLEM_JSON);
this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5));
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(exchange.getResponse().getHeaders().size()).isEqualTo(2);
assertThat(exchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PROBLEM_JSON);
assertResponseBody(exchange,
"{\"type\":\"about:blank\"," +
"\"title\":\"Bad Request\"," +
"\"status\":400," +
"\"detail\":null," +
"\"instance\":\"/path\"}");
}
@Test
public void handleReturnValueLastModified() throws Exception {
Instant currentTime = Instant.now().truncatedTo(ChronoUnit.SECONDS);
@@ -505,6 +529,8 @@ public class ResponseEntityResultHandlerTests {
ResponseEntity<Person> responseEntityPerson() { return null; }
ProblemDetail problemDetail() { return null; }
HttpHeaders httpHeaders() { return null; }
Mono<ResponseEntity<String>> mono() { return null; }