Starting since Spring 6 and Spring Boot 3, the Spring Framework supports the “Problem Details for HTTP APIs” specification, RFC 7807. This Spring tutorial will guide you through this new enhancement in detail.
1. Problem Details Specification [RFC 7807]
This RFC defines simple JSON and XML document formats that can be used to communicate the problem details to the API consumers. This is useful in cases where HTTP status codes are not enough to describe the problem with an HTTP API.
The following is an example of a problem that occurred while transferring from one bank account to another, and we do not have sufficient balance in our account.
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en
{
"status": 403,
"type": "https://bankname.com/common-problems/low-balance",
"title": "You not have enough balance",
"detail": "Your current balance is 30 and you are transterring 50",
"instance": "/account-transfer-service"
}
Here the key phrases are:
- status: HTTP status code generated by the server.
- type: A URL that identifies the problem type and how to mitigate it. The default value is – about:blank.
- title: A short summary of the problem.
- detail: Problem explanation specific to this occurrence.
- instance: URL of the service where this problem occurred. The default value is the current request URL.
2. Support in Spring Framework
The following are the main abstractions in the Spring framework for supporting the problem details specification:
2.1. ProblemDetail
It is the primary object for representing the problem detail model. As discussed in the previous section, it contains standard fields, and non-standard fields can be added as properties.
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "message");
pd.setType(URI.create("http://my-app-host.com/errors/not-found"));
pd.setTitle("Record Not Found");
To add a non-standard field, use the setProperty() method.
pd.setProperty("property-key", "property-value");
2.2. ErrorResponse
This interface exposes HTTP error response details, including HTTP status, response headers, and a body of type ProblemDetail. It can be used to supply even more information to the client in comparison to only sending the ProblemDetail object.
All Spring MVC exceptions implement ErrorResponse interface. So all MVC exceptions are already compliant with the specification.
2.3. ErrorResponseException
It is a very basic ErrorResponse implementation that we can use as a convenient base class for creating more specific exception classes.
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Null Pointer Exception");
pd.setType(URI.create("http://my-app-host.com/errors/npe"));
pd.setTitle("Null Pointer Exception");
throw new ErrorResponseException(HttpStatus.INTERNAL_SERVER_ERROR, pd, npe);
2.4. ResponseEntityExceptionHandler
It is a convenient base class for an @ControllerAdvice that handles all Spring MVC exceptions according to RFC specification and any ErrorResponseException, and renders an error response with a body.
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(CustomException.class)
public ProblemDetail handleCustomException(CustomException ex, WebRequest request) {
ProblemDetail pd = //build object..
return pd;
}
}
We can return
ProblemDetail
orErrorResponse
from any@ExceptionHandler
or from any@RequestMapping
method to render an RFC 7807 response.
3. Sending ProblemDetail using ResponseEntity
In failure cases, create a new instance of ProblemDetail class, populate relevant information and set it into the ResponseEntity object.
The following API fails when id is greater than 100.
@Value("${hostname}")
private String hostname;
@GetMapping(path = "/ employees/v2/{id}")
public ResponseEntity getEmployeeById_V2(@PathVariable("id") Long id) {
if (id < 100) {
return ResponseEntity.ok(new Employee(id, "lokesh", "gupta", "admin@howtodoinjava.com"));
} else {
ProblemDetail pd = ProblemDetail
.forStatusAndDetail(HttpStatus.NOT_FOUND, "Employee id '" + id + "' does no exist");
pd.setType(URI.create("http://my-app-host.com/errors/not-found"));
pd.setTitle("Record Not Found");
pd.setProperty("hostname", hostname);
return ResponseEntity.status(404).body(pd);
}
}
Let us test with the id = 101. It will return the response in RFC specification.
{
"detail": "Employee id '101' does no exist",
"hostname": "localhost",
"instance": "/employees/v2/101",
"status": 404,
"title": "Record Not Found",
"type": "http://my-app-host.com/errors/not-found"
}
4. Throwing ErrorResponseException from REST Controller
Another way to send the problem detail information is by throwing the ErrorResponseException instance from the @RequestMapping handler methods.
This is especially useful in cases where we already have an exception that we cannot send to the client (e.g. NullPointerException). In such cases, we populate the essential information in ErrorResponseException and throw it. Spring MVC handlers internally process this exception and parse it into RFC-specified response format.
@GetMapping(path = "/v3/{id}")
public ResponseEntity getEmployeeById_V3(@PathVariable("id") Long id) {
try {
//somthing threw this exception
throw new NullPointerException("Something was expected but it was null");
}
catch (NullPointerException npe) {
ProblemDetail pd = ProblemDetail
.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"Null Pointer Exception");
pd.setType(URI.create("http://my-app-host.com/errors/npe"));
pd.setTitle("Null Pointer Exception");
pd.setProperty("hostname", hostname);
throw new ErrorResponseException(HttpStatus.NOT_FOUND, pd, npe);
}
}
5. Adding ProblemDetail to Custom Exceptions
Most applications create exception classes that are more close to their business domain/model. A few such exceptions could be RecordNotFoundException, TransactionLimitException etc. They are more readable and concisely represent an error scenario in the code.
Most of the time, these exceptions are subclasses of RuntimeException.
public class RecordNotFoundException extends RuntimeException {
private final String message;
public RecordNotFoundException(String message) {
this.message = message;
}
}
And we throw these exceptions from several places in the code.
@GetMapping(path = "/v1/{id}")
public ResponseEntity getEmployeeById_V1(@PathVariable("id") Long id) {
if (id < 100) {
return ResponseEntity.ok(...);
} else {
throw new RecordNotFoundException("Employee id '" + id + "' does no exist");
}
}
The best way to add the problem detail information in such exceptions is in the @ControllerAdvice
class. We must handle the exception in @ExceptionHandler(RecordNotFoundException.class) method and add the required information.
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@Value("${hostname}")
private String hostname;
@ExceptionHandler(RecordNotFoundException.class)
public ProblemDetail handleRecordNotFoundException(RecordNotFoundException ex, WebRequest request) {
ProblemDetail body = ProblemDetail
.forStatusAndDetail(HttpStatusCode.valueOf(404),ex.getLocalizedMessage());
body.setType(URI.create("http://my-app-host.com/errors/not-found"));
body.setTitle("Record Not Found");
body.setProperty("hostname", hostname);
return body;
}
}
6. Verifying ProblemDetail Response in Unit Tests
We can also test verify the problem detail responses of the above sections using RestTemplate in a unit test.
@Test
public void testAddEmployee_V2_FailsWhen_IncorrectId() {
try {
this.restTemplate.getForObject("/employees/v2/101", Employee.class);
} catch (RestClientResponseException ex) {
ProblemDetail pd = ex.getResponseBodyAs(ProblemDetail.class);
assertEquals("Employee id '101' does no exist", pd.getDetail());
assertEquals(404, pd.getStatus());
assertEquals("Record Not Found", pd.getTitle());
assertEquals(URI.create("http://my-app-host.com/errors/not-found"), pd.getType());
assertEquals("localhost", pd.getProperties().get("hostname"));
}
}
Note that if we are using Spring Webflux, we can use the WebClient API to validate the returned problem details.
@Test
void testAddEmployeeUsingWebFlux_V2_FailsWhen_IncorrectId() {
this.webClient.get().uri("/employees/v2/101")
.retrieve()
.bodyToMono(String.class)
.doOnNext(System.out::println)
.onErrorResume(WebClientResponseException.class, ex -> {
ProblemDetail pd = ex.getResponseBodyAs(ProblemDetail.class);
//assertions...
return Mono.empty();
})
.block();
}
7. Conclusion
In this Spring 6 tutorial, we learned about the new feature supporting the problem details specification in the Spring framework. After this feature, we can return the instances of ProblemDetail
or ErrorResponse
from any @ExceptionHandler
or from any @RequestMapping
methods and Spring framework add the necessary schema into the API response.
The code discussed in this tutorial is applied to Spring applications as well as Spring boot applications as well.
Happy Learning !!
Leave a Reply