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:
parent
65394b00ea
commit
714d451260
|
|
@ -0,0 +1,279 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
* 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.http;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Representation of an RFC 7807 problem detail, including all RFC-defined
|
||||||
|
* fields. For an extended response with more fields, create a subclass that
|
||||||
|
* exposes the additional fields.
|
||||||
|
*
|
||||||
|
* @author Rossen Stoyanchev
|
||||||
|
* @since 6.0
|
||||||
|
*
|
||||||
|
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7807">RFC 7807</a>
|
||||||
|
* @see org.springframework.web.ErrorResponse
|
||||||
|
* @see org.springframework.web.ErrorResponseException
|
||||||
|
*/
|
||||||
|
public class ProblemDetail {
|
||||||
|
|
||||||
|
private static final URI BLANK_TYPE = URI.create("about:blank");
|
||||||
|
|
||||||
|
|
||||||
|
private URI type = BLANK_TYPE;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private int status;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private String detail;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private URI instance;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protected constructor for subclasses.
|
||||||
|
* <p>To create a {@link ProblemDetail} instance, use static factory methods,
|
||||||
|
* {@link #forStatus(HttpStatus)} or {@link #forRawStatusCode(int)}.
|
||||||
|
* @param rawStatusCode the response status to use
|
||||||
|
*/
|
||||||
|
protected ProblemDetail(int rawStatusCode) {
|
||||||
|
this.status = rawStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy constructor that could be used from a subclass to re-create a
|
||||||
|
* {@code ProblemDetail} in order to extend it with more fields.
|
||||||
|
*/
|
||||||
|
protected ProblemDetail(ProblemDetail other) {
|
||||||
|
this.type = other.type;
|
||||||
|
this.title = other.title;
|
||||||
|
this.status = other.status;
|
||||||
|
this.detail = other.detail;
|
||||||
|
this.instance = other.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of {@link #setType(URI)} for chained initialization.
|
||||||
|
* @param type the problem type
|
||||||
|
* @return the same instance
|
||||||
|
*/
|
||||||
|
public ProblemDetail withType(URI type) {
|
||||||
|
setType(type);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of {@link #setTitle(String)} for chained initialization.
|
||||||
|
* @param title the problem title
|
||||||
|
* @return the same instance
|
||||||
|
*/
|
||||||
|
public ProblemDetail withTitle(@Nullable String title) {
|
||||||
|
setTitle(title);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of {@link #setStatus(int)} for chained initialization.
|
||||||
|
* @param status the response status for the problem
|
||||||
|
* @return the same instance
|
||||||
|
*/
|
||||||
|
public ProblemDetail withStatus(HttpStatus status) {
|
||||||
|
Assert.notNull(status, "HttpStatus is required");
|
||||||
|
setStatus(status.value());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of {@link #setStatus(int)} for chained initialization.
|
||||||
|
* @param status the response status value for the problem
|
||||||
|
* @return the same instance
|
||||||
|
*/
|
||||||
|
public ProblemDetail withRawStatusCode(int status) {
|
||||||
|
setStatus(status);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of {@link #setDetail(String)} for chained initialization.
|
||||||
|
* @param detail the problem detail
|
||||||
|
* @return the same instance
|
||||||
|
*/
|
||||||
|
public ProblemDetail withDetail(@Nullable String detail) {
|
||||||
|
setDetail(detail);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant of {@link #setInstance(URI)} for chained initialization.
|
||||||
|
* @param instance the problem instance URI
|
||||||
|
* @return the same instance
|
||||||
|
*/
|
||||||
|
public ProblemDetail withInstance(@Nullable URI instance) {
|
||||||
|
setInstance(instance);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Setters for deserialization
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for the {@link #getType() problem type}.
|
||||||
|
* <p>By default, this is {@link #BLANK_TYPE}.
|
||||||
|
* @param type the problem type
|
||||||
|
* @see #withType(URI)
|
||||||
|
*/
|
||||||
|
public void setType(URI type) {
|
||||||
|
Assert.notNull(type, "'type' is required");
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for the {@link #getTitle() problem title}.
|
||||||
|
* <p>By default, if not explicitly set and the status is well-known, this
|
||||||
|
* is sourced from the {@link HttpStatus#getReasonPhrase()}.
|
||||||
|
* @param title the problem title
|
||||||
|
* @see #withTitle(String)
|
||||||
|
*/
|
||||||
|
public void setTitle(@Nullable String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for the {@link #getStatus() problem status}.
|
||||||
|
* @param status the problem status
|
||||||
|
* @see #withStatus(HttpStatus)
|
||||||
|
* @see #withRawStatusCode(int)
|
||||||
|
*/
|
||||||
|
public void setStatus(int status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for the {@link #getDetail() problem detail}.
|
||||||
|
* <p>By default, this is not set.
|
||||||
|
* @param detail the problem detail
|
||||||
|
* @see #withDetail(String)
|
||||||
|
*/
|
||||||
|
public void setDetail(@Nullable String detail) {
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setter for the {@link #getInstance() problem instance}.
|
||||||
|
* <p>By default, when {@code ProblemDetail} is returned from an
|
||||||
|
* {@code @ExceptionHandler} method, this is initialized to the request path.
|
||||||
|
* @param instance the problem instance
|
||||||
|
* @see #withInstance(URI)
|
||||||
|
*/
|
||||||
|
public void setInstance(@Nullable URI instance) {
|
||||||
|
this.instance = instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the configured {@link #setType(URI) problem type}.
|
||||||
|
*/
|
||||||
|
public URI getType() {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the configured {@link #setTitle(String) problem title}.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public String getTitle() {
|
||||||
|
if (this.title == null) {
|
||||||
|
HttpStatus httpStatus = HttpStatus.resolve(this.status);
|
||||||
|
if (httpStatus != null) {
|
||||||
|
return httpStatus.getReasonPhrase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the status associated with the problem, provided either to the
|
||||||
|
* constructor or configured via {@link #setStatus(int)}.
|
||||||
|
*/
|
||||||
|
public int getStatus() {
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the configured {@link #setDetail(String) problem detail}.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public String getDetail() {
|
||||||
|
return this.detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the configured {@link #setInstance(URI) problem instance}.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public URI getInstance() {
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return getClass().getSimpleName() + "[" + initToStringContent() + "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a String representation of the {@code ProblemDetail} fields.
|
||||||
|
* Subclasses can override this to append additional fields.
|
||||||
|
*/
|
||||||
|
protected String initToStringContent() {
|
||||||
|
return "type='" + this.type + "'" +
|
||||||
|
", title='" + getTitle() + "'" +
|
||||||
|
", status=" + getStatus() +
|
||||||
|
", detail='" + getDetail() + "'" +
|
||||||
|
", instance='" + getInstance() + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Static factory methods
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a {@code ProblemDetail} instance with the given status code.
|
||||||
|
*/
|
||||||
|
public static ProblemDetail forStatus(HttpStatus status) {
|
||||||
|
Assert.notNull(status, "HttpStatus is required");
|
||||||
|
return forRawStatusCode(status.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a {@code ProblemDetail} instance with the given status value.
|
||||||
|
*/
|
||||||
|
public static ProblemDetail forRawStatusCode(int status) {
|
||||||
|
return new ProblemDetail(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -260,6 +260,27 @@ public class ResponseEntity<T> extends HttpEntity<T> {
|
||||||
return body.map(ResponseEntity::ok).orElseGet(() -> notFound().build());
|
return body.map(ResponseEntity::ok).orElseGet(() -> notFound().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a builder for a {@code ResponseEntity} with the given
|
||||||
|
* {@link ProblemDetail} as the body, also matching to its
|
||||||
|
* {@link ProblemDetail#getStatus() status}. An {@code @ExceptionHandler}
|
||||||
|
* method can use to add response headers, or otherwise it can return
|
||||||
|
* {@code ProblemDetail}.
|
||||||
|
* @param body the details for an HTTP error response
|
||||||
|
* @return the created builder
|
||||||
|
* @since 6.0
|
||||||
|
*/
|
||||||
|
public static HeadersBuilder<?> of(ProblemDetail body) {
|
||||||
|
return new DefaultBuilder(body.getStatus()) {
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Override
|
||||||
|
public <T> ResponseEntity<T> build() {
|
||||||
|
return (ResponseEntity<T>) body(body);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new builder with a {@linkplain HttpStatus#CREATED CREATED} status
|
* Create a new builder with a {@linkplain HttpStatus#CREATED CREATED} status
|
||||||
* and a location header set to the given URI.
|
* and a location header set to the given URI.
|
||||||
|
|
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with 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;
|
package org.springframework.web.reactive.result.method.annotation;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
@ -30,6 +31,7 @@ import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ProblemDetail;
|
||||||
import org.springframework.http.RequestEntity;
|
import org.springframework.http.RequestEntity;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.codec.HttpMessageWriter;
|
import org.springframework.http.codec.HttpMessageWriter;
|
||||||
|
|
@ -41,7 +43,8 @@ import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
|
||||||
import org.springframework.web.server.ServerWebExchange;
|
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
|
* <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.
|
* 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;
|
return valueType;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isSupportedType(@Nullable Class<?> clazz) {
|
private boolean isSupportedType(@Nullable Class<?> type) {
|
||||||
return (clazz != null && ((HttpEntity.class.isAssignableFrom(clazz) &&
|
if (type == null) {
|
||||||
!RequestEntity.class.isAssignableFrom(clazz)) ||
|
return false;
|
||||||
HttpHeaders.class.isAssignableFrom(clazz)));
|
}
|
||||||
|
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) {
|
else if (returnValue instanceof HttpHeaders) {
|
||||||
httpEntity = new ResponseEntity<>((HttpHeaders) returnValue, HttpStatus.OK);
|
httpEntity = new ResponseEntity<>((HttpHeaders) returnValue, HttpStatus.OK);
|
||||||
}
|
}
|
||||||
|
else if (returnValue instanceof ProblemDetail detail) {
|
||||||
|
httpEntity = new ResponseEntity<>(returnValue, HttpHeaders.EMPTY, detail.getStatus());
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"HttpEntity or HttpHeaders expected but got: " + returnValue.getClass());
|
"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) {
|
if (httpEntity instanceof ResponseEntity) {
|
||||||
exchange.getResponse().setRawStatusCode(
|
exchange.getResponse().setRawStatusCode(
|
||||||
((ResponseEntity<?>) httpEntity).getStatusCodeValue());
|
((ResponseEntity<?>) httpEntity).getStatusCodeValue());
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ProblemDetail;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.codec.EncoderHttpMessageWriter;
|
import org.springframework.http.codec.EncoderHttpMessageWriter;
|
||||||
import org.springframework.http.codec.HttpMessageWriter;
|
import org.springframework.http.codec.HttpMessageWriter;
|
||||||
|
|
@ -129,6 +130,9 @@ public class ResponseEntityResultHandlerTests {
|
||||||
returnType = on(TestController.class).resolveReturnType(HttpHeaders.class);
|
returnType = on(TestController.class).resolveReturnType(HttpHeaders.class);
|
||||||
assertThat(this.resultHandler.supports(handlerResult(value, returnType))).isTrue();
|
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
|
// SPR-15785
|
||||||
value = ResponseEntity.ok("testing");
|
value = ResponseEntity.ok("testing");
|
||||||
returnType = on(TestController.class).resolveReturnType(Object.class);
|
returnType = on(TestController.class).resolveReturnType(Object.class);
|
||||||
|
|
@ -232,6 +236,26 @@ public class ResponseEntityResultHandlerTests {
|
||||||
testHandle(returnValue, returnType);
|
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
|
@Test
|
||||||
public void handleReturnValueLastModified() throws Exception {
|
public void handleReturnValueLastModified() throws Exception {
|
||||||
Instant currentTime = Instant.now().truncatedTo(ChronoUnit.SECONDS);
|
Instant currentTime = Instant.now().truncatedTo(ChronoUnit.SECONDS);
|
||||||
|
|
@ -505,6 +529,8 @@ public class ResponseEntityResultHandlerTests {
|
||||||
|
|
||||||
ResponseEntity<Person> responseEntityPerson() { return null; }
|
ResponseEntity<Person> responseEntityPerson() { return null; }
|
||||||
|
|
||||||
|
ProblemDetail problemDetail() { return null; }
|
||||||
|
|
||||||
HttpHeaders httpHeaders() { return null; }
|
HttpHeaders httpHeaders() { return null; }
|
||||||
|
|
||||||
Mono<ResponseEntity<String>> mono() { return null; }
|
Mono<ResponseEntity<String>> mono() { return null; }
|
||||||
|
|
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -19,6 +19,7 @@ package org.springframework.web.servlet.mvc.method.annotation;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.lang.reflect.ParameterizedType;
|
import java.lang.reflect.ParameterizedType;
|
||||||
import java.lang.reflect.Type;
|
import java.lang.reflect.Type;
|
||||||
|
import java.net.URI;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -32,6 +33,7 @@ import org.springframework.core.ResolvableType;
|
||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.ProblemDetail;
|
||||||
import org.springframework.http.RequestEntity;
|
import org.springframework.http.RequestEntity;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.converter.HttpMessageConverter;
|
import org.springframework.http.converter.HttpMessageConverter;
|
||||||
|
|
@ -52,10 +54,11 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
import org.springframework.web.servlet.support.RequestContextUtils;
|
import org.springframework.web.servlet.support.RequestContextUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves {@link HttpEntity} and {@link RequestEntity} method argument values
|
* Resolves {@link HttpEntity} and {@link RequestEntity} method argument values,
|
||||||
* and also handles {@link HttpEntity} and {@link ResponseEntity} return values.
|
* as well as return values of type {@link HttpEntity}, {@link ResponseEntity},
|
||||||
|
* and {@link ProblemDetail}.
|
||||||
*
|
*
|
||||||
* <p>An {@link HttpEntity} return type has a specific purpose. Therefore this
|
* <p>An {@link HttpEntity} return type has a specific purpose. Therefore, this
|
||||||
* handler should be configured ahead of handlers that support any return
|
* handler should be configured ahead of handlers that support any return
|
||||||
* value type annotated with {@code @ModelAttribute} or {@code @ResponseBody}
|
* value type annotated with {@code @ModelAttribute} or {@code @ResponseBody}
|
||||||
* to ensure they don't take over.
|
* to ensure they don't take over.
|
||||||
|
|
@ -82,9 +85,7 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro
|
||||||
* Suitable for resolving {@code HttpEntity} and handling {@code ResponseEntity}
|
* Suitable for resolving {@code HttpEntity} and handling {@code ResponseEntity}
|
||||||
* without {@code Request~} or {@code ResponseBodyAdvice}.
|
* without {@code Request~} or {@code ResponseBodyAdvice}.
|
||||||
*/
|
*/
|
||||||
public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> converters,
|
public HttpEntityMethodProcessor(List<HttpMessageConverter<?>> converters, ContentNegotiationManager manager) {
|
||||||
ContentNegotiationManager manager) {
|
|
||||||
|
|
||||||
super(converters, manager);
|
super(converters, manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,8 +120,9 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsReturnType(MethodParameter returnType) {
|
public boolean supportsReturnType(MethodParameter returnType) {
|
||||||
return (HttpEntity.class.isAssignableFrom(returnType.getParameterType()) &&
|
Class<?> type = returnType.getParameterType();
|
||||||
!RequestEntity.class.isAssignableFrom(returnType.getParameterType()));
|
return ((HttpEntity.class.isAssignableFrom(type) && !RequestEntity.class.isAssignableFrom(type)) ||
|
||||||
|
ProblemDetail.class.isAssignableFrom(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -177,8 +179,21 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro
|
||||||
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
|
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
|
||||||
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
|
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
|
||||||
|
|
||||||
|
HttpEntity<?> httpEntity;
|
||||||
|
if (returnValue instanceof ProblemDetail detail) {
|
||||||
|
httpEntity = new ResponseEntity<>(returnValue, HttpHeaders.EMPTY, detail.getStatus());
|
||||||
|
}
|
||||||
|
else {
|
||||||
Assert.isInstanceOf(HttpEntity.class, returnValue);
|
Assert.isInstanceOf(HttpEntity.class, returnValue);
|
||||||
HttpEntity<?> httpEntity = (HttpEntity<?>) returnValue;
|
httpEntity = (HttpEntity<?>) returnValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (httpEntity.getBody() instanceof ProblemDetail detail) {
|
||||||
|
if (detail.getInstance() == null) {
|
||||||
|
URI path = URI.create(inputMessage.getServletRequest().getRequestURI());
|
||||||
|
detail.setInstance(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HttpHeaders outputHeaders = outputMessage.getHeaders();
|
HttpHeaders outputHeaders = outputMessage.getHeaders();
|
||||||
HttpHeaders entityHeaders = httpEntity.getHeaders();
|
HttpHeaders entityHeaders = httpEntity.getHeaders();
|
||||||
|
|
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -31,6 +31,7 @@ import java.util.Date;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
@ -46,6 +47,7 @@ import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.HttpOutputMessage;
|
import org.springframework.http.HttpOutputMessage;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ProblemDetail;
|
||||||
import org.springframework.http.RequestEntity;
|
import org.springframework.http.RequestEntity;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.converter.HttpMessageConverter;
|
import org.springframework.http.converter.HttpMessageConverter;
|
||||||
|
|
@ -77,6 +79,8 @@ import static org.mockito.Mockito.reset;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM;
|
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
import static org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON;
|
||||||
|
import static org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE;
|
||||||
import static org.springframework.http.MediaType.TEXT_PLAIN;
|
import static org.springframework.http.MediaType.TEXT_PLAIN;
|
||||||
import static org.springframework.web.servlet.HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE;
|
import static org.springframework.web.servlet.HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE;
|
||||||
|
|
||||||
|
|
@ -103,6 +107,8 @@ public class HttpEntityMethodProcessorMockTests {
|
||||||
|
|
||||||
private HttpMessageConverter<Object> resourceRegionMessageConverter;
|
private HttpMessageConverter<Object> resourceRegionMessageConverter;
|
||||||
|
|
||||||
|
private HttpMessageConverter<Object> jsonMessageConverter;
|
||||||
|
|
||||||
private MethodParameter paramHttpEntity;
|
private MethodParameter paramHttpEntity;
|
||||||
|
|
||||||
private MethodParameter paramRequestEntity;
|
private MethodParameter paramRequestEntity;
|
||||||
|
|
@ -123,6 +129,8 @@ public class HttpEntityMethodProcessorMockTests {
|
||||||
|
|
||||||
private MethodParameter returnTypeInt;
|
private MethodParameter returnTypeInt;
|
||||||
|
|
||||||
|
private MethodParameter returnTypeProblemDetail;
|
||||||
|
|
||||||
private ModelAndViewContainer mavContainer;
|
private ModelAndViewContainer mavContainer;
|
||||||
|
|
||||||
private MockHttpServletRequest servletRequest;
|
private MockHttpServletRequest servletRequest;
|
||||||
|
|
@ -147,8 +155,12 @@ public class HttpEntityMethodProcessorMockTests {
|
||||||
given(resourceRegionMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL));
|
given(resourceRegionMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL));
|
||||||
given(resourceRegionMessageConverter.getSupportedMediaTypes(any())).willReturn(Collections.singletonList(MediaType.ALL));
|
given(resourceRegionMessageConverter.getSupportedMediaTypes(any())).willReturn(Collections.singletonList(MediaType.ALL));
|
||||||
|
|
||||||
|
jsonMessageConverter = mock(HttpMessageConverter.class);
|
||||||
|
given(jsonMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON));
|
||||||
|
given(jsonMessageConverter.getSupportedMediaTypes(any())).willReturn(Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON));
|
||||||
|
|
||||||
processor = new HttpEntityMethodProcessor(Arrays.asList(
|
processor = new HttpEntityMethodProcessor(Arrays.asList(
|
||||||
stringHttpMessageConverter, resourceMessageConverter, resourceRegionMessageConverter));
|
stringHttpMessageConverter, resourceMessageConverter, resourceRegionMessageConverter, jsonMessageConverter));
|
||||||
|
|
||||||
Method handle1 = getClass().getMethod("handle1", HttpEntity.class, ResponseEntity.class,
|
Method handle1 = getClass().getMethod("handle1", HttpEntity.class, ResponseEntity.class,
|
||||||
Integer.TYPE, RequestEntity.class);
|
Integer.TYPE, RequestEntity.class);
|
||||||
|
|
@ -163,6 +175,7 @@ public class HttpEntityMethodProcessorMockTests {
|
||||||
returnTypeHttpEntitySubclass = new MethodParameter(getClass().getMethod("handle2x", HttpEntity.class), -1);
|
returnTypeHttpEntitySubclass = new MethodParameter(getClass().getMethod("handle2x", HttpEntity.class), -1);
|
||||||
returnTypeInt = new MethodParameter(getClass().getMethod("handle3"), -1);
|
returnTypeInt = new MethodParameter(getClass().getMethod("handle3"), -1);
|
||||||
returnTypeResponseEntityResource = new MethodParameter(getClass().getMethod("handle5"), -1);
|
returnTypeResponseEntityResource = new MethodParameter(getClass().getMethod("handle5"), -1);
|
||||||
|
returnTypeProblemDetail = new MethodParameter(getClass().getMethod("handle6"), -1);
|
||||||
|
|
||||||
mavContainer = new ModelAndViewContainer();
|
mavContainer = new ModelAndViewContainer();
|
||||||
servletRequest = new MockHttpServletRequest("GET", "/foo");
|
servletRequest = new MockHttpServletRequest("GET", "/foo");
|
||||||
|
|
@ -184,6 +197,7 @@ public class HttpEntityMethodProcessorMockTests {
|
||||||
assertThat(processor.supportsReturnType(returnTypeResponseEntity)).as("ResponseEntity return type not supported").isTrue();
|
assertThat(processor.supportsReturnType(returnTypeResponseEntity)).as("ResponseEntity return type not supported").isTrue();
|
||||||
assertThat(processor.supportsReturnType(returnTypeHttpEntity)).as("HttpEntity return type not supported").isTrue();
|
assertThat(processor.supportsReturnType(returnTypeHttpEntity)).as("HttpEntity return type not supported").isTrue();
|
||||||
assertThat(processor.supportsReturnType(returnTypeHttpEntitySubclass)).as("Custom HttpEntity subclass not supported").isTrue();
|
assertThat(processor.supportsReturnType(returnTypeHttpEntitySubclass)).as("Custom HttpEntity subclass not supported").isTrue();
|
||||||
|
assertThat(processor.supportsReturnType(returnTypeProblemDetail)).isTrue();
|
||||||
assertThat(processor.supportsReturnType(paramRequestEntity)).as("RequestEntity parameter supported").isFalse();
|
assertThat(processor.supportsReturnType(paramRequestEntity)).as("RequestEntity parameter supported").isFalse();
|
||||||
assertThat(processor.supportsReturnType(returnTypeInt)).as("non-ResponseBody return type supported").isFalse();
|
assertThat(processor.supportsReturnType(returnTypeInt)).as("non-ResponseBody return type supported").isFalse();
|
||||||
}
|
}
|
||||||
|
|
@ -268,6 +282,36 @@ public class HttpEntityMethodProcessorMockTests {
|
||||||
verify(stringHttpMessageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class));
|
verify(stringHttpMessageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldHandleProblemDetail() throws Exception {
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
|
||||||
|
servletRequest.addHeader("Accept", APPLICATION_PROBLEM_JSON_VALUE);
|
||||||
|
given(jsonMessageConverter.canWrite(ProblemDetail.class, APPLICATION_PROBLEM_JSON)).willReturn(true);
|
||||||
|
|
||||||
|
processor.handleReturnValue(problemDetail, returnTypeProblemDetail, mavContainer, webRequest);
|
||||||
|
|
||||||
|
assertThat(mavContainer.isRequestHandled()).isTrue();
|
||||||
|
assertThat(webRequest.getNativeResponse(HttpServletResponse.class).getStatus()).isEqualTo(400);
|
||||||
|
verify(jsonMessageConverter).write(eq(problemDetail), eq(APPLICATION_PROBLEM_JSON), isA(HttpOutputMessage.class));
|
||||||
|
|
||||||
|
assertThat(problemDetail).isNotNull()
|
||||||
|
.extracting(ProblemDetail::getInstance).isNotNull()
|
||||||
|
.extracting(URI::toString)
|
||||||
|
.as("Instance was not set to the request path")
|
||||||
|
.isEqualTo(servletRequest.getRequestURI());
|
||||||
|
|
||||||
|
|
||||||
|
// But if instance is set, it should be respected
|
||||||
|
problemDetail.setInstance(URI.create("/something/else"));
|
||||||
|
processor.handleReturnValue(problemDetail, returnTypeProblemDetail, mavContainer, webRequest);
|
||||||
|
|
||||||
|
assertThat(problemDetail).isNotNull()
|
||||||
|
.extracting(ProblemDetail::getInstance).isNotNull()
|
||||||
|
.extracting(URI::toString)
|
||||||
|
.as("Instance was not set to the request path")
|
||||||
|
.isEqualTo("/something/else");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shouldHandleReturnValueWithProducibleMediaType() throws Exception {
|
public void shouldHandleReturnValueWithProducibleMediaType() throws Exception {
|
||||||
String body = "Foo";
|
String body = "Foo";
|
||||||
|
|
@ -797,6 +841,11 @@ public class HttpEntityMethodProcessorMockTests {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
public ProblemDetail handle6() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
public static class CustomHttpEntity extends HttpEntity<Object> {
|
public static class CustomHttpEntity extends HttpEntity<Object> {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue