Spring Web: Problem Details for HTTP APIs
Spring Boot 3 introduces support for RFC 7807, Problem Details for HTTP APIs. This specification defines a standard way to return error details from HTTP APIs.
In this tutorial, we will explore how to return ProblemDetail in Spring Boot when an exception is thrown, and verify it using integration tests with Testcontainers and RestTestClient.
Problem Details
RFC 7807 defines a ProblemDetail object with the following fields:
* type: A URI reference that identifies the problem type.
* title: A short, human-readable summary of the problem type.
* status: The HTTP status code generated by the origin server for this occurrence of the problem.
* detail: A human-readable explanation specific to this occurrence of the problem.
* instance: A URI reference that identifies the specific occurrence of the problem.
Implementation
We will create a UserResource that retrieves a User by its id. If the User is not found, we will throw a UserNotFoundException, which will be handled by a @ControllerAdvice to return a ProblemDetail.
User Resource
Our UserResource retrieves a user by its ID:
package zin.rashidi.boot.web.problemdetails.user;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserResource {
private final UserRepository repository;
public UserResource(UserRepository repository) {
this.repository = repository;
}
@GetMapping("/users/{id}")
public User findById(@PathVariable Long id) {
return repository.findById(id).orElseThrow(() -> new UserNotFoundException(id));
}
}
If the user is not found, a UserNotFoundException is thrown:
package zin.rashidi.boot.web.problemdetails.user;
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(Long id) {
super("User with id " + id + " not found");
}
}
Exception Handler
We will use @ControllerAdvice to handle the UserNotFoundException and return a ProblemDetail:
package zin.rashidi.boot.web.problemdetails.user;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.net.URI;
@ControllerAdvice
public class UserExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handleUserNotFoundException(UserNotFoundException e) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
problemDetail.setTitle("User Not Found");
problemDetail.setType(URI.create("https://zin.rashidi.my/problems/user-not-found"));
return problemDetail;
}
}
We create a ProblemDetail using forStatusAndDetail, setting the HTTP status to 404 NOT_FOUND and providing the exception message as the detail. We also set a custom title and type URI.
Verification
To ensure our implementation works as expected, we will write an integration test using RestTestClient and Testcontainers.
Testcontainers Configuration
We define a PostgreSQL container for our integration tests:
package zin.rashidi.boot.web.problemdetails;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:latest");
}
}
Integration Test
We test the UserResource by requesting a non-existent user and asserting the response:
package zin.rashidi.boot.web.problemdetails.user;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.client.RestTestClient;
import zin.rashidi.boot.web.problemdetails.TestcontainersConfiguration;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@AutoConfigureRestTestClient
@Import(TestcontainersConfiguration.class)
@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop", webEnvironment = RANDOM_PORT)
class UserResourceTests {
@Autowired
private RestTestClient restClient;
@Test
@DisplayName("Should return Problem Detail when user is not found")
void userNotFound() {
restClient.get().uri("/users/{id}", 999L).exchange()
.expectStatus().isNotFound()
.expectHeader().contentType("application/problem+json")
.expectBody()
.jsonPath("$.type").isEqualTo("https://zin.rashidi.my/problems/user-not-found")
.jsonPath("$.title").isEqualTo("User Not Found")
.jsonPath("$.status").isEqualTo(404)
.jsonPath("$.detail").isEqualTo("User with id 999 not found")
.jsonPath("$.instance").isEqualTo("/users/999");
}
}
We verify that the response status is 404 Not Found, the content type is application/problem+json, and the body contains the correct ProblemDetail fields matching RFC 7807.