Skip to content

Commit

Permalink
Add ProblemDetail and @ExceptionHandler support
Browse files Browse the repository at this point in the history
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
  • Loading branch information
rstoyanchev committed Feb 28, 2022
1 parent 65394b0 commit 714d451
Show file tree
Hide file tree
Showing 6 changed files with 425 additions and 20 deletions.
279 changes: 279 additions & 0 deletions spring-web/src/main/java/org/springframework/http/ProblemDetail.java
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -260,6 +260,27 @@ public static <T> ResponseEntity<T> of(Optional<T> body) {
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
* and a location header set to the given URI.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -30,6 +31,7 @@
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;
Expand All @@ -41,7 +43,8 @@
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.
Expand Down Expand Up @@ -100,10 +103,12 @@ private static Class<?> resolveReturnValueType(HandlerResult result) {
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));
}


Expand Down Expand Up @@ -136,11 +141,21 @@ public Mono<Void> handleResult(ServerWebExchange exchange, HandlerResult result)
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());
Expand Down
Loading

0 comments on commit 714d451

Please sign in to comment.