This 10-minute post with code demonstrates a simple way to handle API validation and return a list of formatted errors from a Spring Boot Microservice.
Running The Example
You can download and run the source code as follows.
git clone https://github.com/johndobie/spring-boot-error-handling.git
mvn clean install spring-boot:run
$ curl -X POST http://localhost:8080/echo/model -H 'Content-Type: application/json' -d '{"name":null,"message":"This message is more than 30 characters long ..................................."}'
{"type":"VALIDATION","errors":[{"code":"Size","detail":"message size must be between 1 and 30","source":"echoModel/message"},{"code":"NotNull","detail":"name may not be null","source":"echoModel/name"}]}
Error Handling in Spring Boot Microservices
When building a service it is essential to be able to send back useful error information that can be used to diagnose any issues or be shown to clients.
There are 3 steps to the solution:
- Define a custom error response model.
- Define the API model you want to validate.
- Define a Global Exception Handler and customise for each exception.
Your microservice will then return errors of all types in the following structure.
{
"type": "VALIDATION",
"errors": [
{
"code": "NotNull",
"detail": "name may not be null",
"source": "echoModel/name"
},
{
"code": "Size",
"detail": "message size must be between 1 and 30",
"source": "echoModel/message"
}
]
}
Building The Solution
Step 1 – Define a custom error format
This could not be easier, requiring only that you define two simple POJOs.
The first is the structure you want to send back the main error details
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ErrorModel {
private String code;
private String detail;
private String source;
}
The second is a container for a list of those errors plus any additional fields.
In our case, we will add a type field.
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponseModel {
public String type;
public List<ErrorModel> errorModels;
}
The two of these will form the overall error structure.
Step 2 – Define the API Model.
We are going to create a very simple Microservice endpoint that will take the following model as input, and return the same thing.
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EchoModel {
@Size(min = 1, max = 15, message = "Name should be between 1 and 15")
@NotNull
private String name;
@Size(min = 1, max = 30)
@NotNull
private String message;
}
However, we want to make sure an error is raised if either is null, or the above size limits are exceeded.
To enable this we create a controller endpoint that takes that model as input and make sure we annotate that method with @Valid.
@RestController
public class EchoController {
public static final String ECHO_MODEL_ENDPOINT = "/echo/model";
@PostMapping(value = ECHO_MODEL_ENDPOINT)
public String Mapping(@RequestBody @Valid EchoModel model) {
return model.getMessage();
}
}
This annotation makes Spring check the model whenever it receives a request.
So let’s just hit that code with an empty body.
$ curl -X POST http://localhost:8080/echo/model -H 'Content-Type: application/json' -d '{"name":null,"message":"This message"}'
{"timestamp":"2023-07-19T15:33:47.602+00:00","status":400,"error":"Bad Request","message":"","path":"/echo/model"}
By default, spring throws the following MethodArgumentNotValidException.
Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public java.lang.String com.johndobie.springboot.
web.controller.EchoController.Mapping(com.johndobie.springboot.web.model.EchoModel): [Field error in object 'echoModel' on field 'name': rejected value [null]; codes [NotNull.echoModel.name,NotNull.name,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [echoModel.name,name]; arguments []; default message [name]]; default message [may not be null]] ]
As it stands it doesn’t look great in this format, and that’s where our global exception handler comes in.
Step 3 – Define a Global Exception Handler
We define a global exception handler class that will handle any exceptions.
The initial body should look as follows.
@ControllerAdvice
@RestController
@Configuration
public class GlobalExceptionHandler {
}
The important part is the @ControllerAdvice annotation.
It tells Spring that you are interested in any exceptions that might be thrown.
Now we know that we want to deal with a MethodArgumentNotValidException.
So what we need to do is to take the details, from that and put them into our model. This can be done with the following code which creates a specific ExceptionHandler for MethodArgumentNotValidException.
@ControllerAdvice
@RestController
@Configuration
public class GlobalExceptionHandler {
@ExceptionHandler(value = MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ErrorResponseModel handleException(MethodArgumentNotValidException e) {
List<ErrorModel> errorModels = processErrors(e);
return ErrorResponseModel
.builder()
.errors(errorModels)
.type(ErrorType.VALIDATION.toString())
.build();
}
private List<ErrorModel> processErrors(MethodArgumentNotValidException e) {
List<ErrorModel> validationErrorModels = new ArrayList<ErrorModel>();
for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
ErrorModel validationErrorModel = ErrorModel
.builder()
.code(fieldError.getCode())
.source(fieldError.getObjectName() + "/" + fieldError.getField())
.detail(fieldError.getField() + " " + fieldError.getDefaultMessage())
.build();
validationErrorModels.add(validationErrorModel);
}
return validationErrorModels;
}
}
The processErrors block simply takes the details from the MethodArgumentNotValidException and builds the ErrorModel details for us. We then use that to create our ErrorResponseModel.
Now if we run the same test again we get the following.
$ curl -X POST http://localhost:8080/echo/model -H 'Content-Type: application/json' -d '{"name":null,"message":"This message"}'
{
"type": "VALIDATION",
"errors": [
{
"code": "NotNull",
"detail": "name may not be null",
"source": "echoModel/name"
}
]
}
We now have the target structure we desire. It is much easier to see what went wrong with this message.
Testing Errors in Spring Boot Microservices.
We can add a unit test for this as follows.
@Test
public void postShouldReturnMessageTooLong() throws Exception {
String TEST_NAME = "John Dobie";
String LONG_TEST_MESSAGE = "This message is more than 30 characters long";
EchoModel echoModel = EchoModel
.builder()
.message(LONG_TEST_MESSAGE)
.name(TEST_NAME)
.build();
MvcResult mvcResult = mockMvc
.perform(post(ECHO_MODEL_ENDPOINT)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(getJsonObjectAsString(echoModel)))
.andDo(print())
.andExpect(status().isUnprocessableEntity())
.andReturn();
String responseBody = mvcResult.getResponse().getContentAsString();
ErrorResponseModel errorResponseModel = readJsonAsObject(responseBody, ErrorResponseModel.class);
assertThat(errorResponseModel.getErrors().size()).isOne();
assertThat(errorResponseModel.type).isEqualTo(ErrorType.VALIDATION.toString());
ErrorModel errorModel = errorResponseModel.getErrors().get(0);
assertThat(errorModel.getCode()).isEqualTo("Size");
assertThat(errorModel.getDetail())
.isEqualTo("message size must be between 1 and 30");
assertThat(errorModel.getSource()).isEqualTo("echoModel/message");
}
The test makes a call with a message that is longer than 30 characters.
It then checks that we get back a 422 status and a single error block.
There are other tests in that class to look at.
Adding Other Errors.
Handling any type of exception is now easy. We can just add an additional block to the global exception handler.
I’ve added a couple more in GlobalExceptionHandler.java which you can see being hit in the tests.
Next Steps.
I will shortly publish another blog on showing how you can extend this technique to deal with more complex validations including business validations that require conditional or dependent logic.
In the meantime, you can find more information here.
https://reflectoring.io/bean-validation-with-spring-boot/
https://spring.io/guides/gs/validating-form-input/
https://cheatsheetseries.owasp.org/cheatsheets/Bean_Validation_Cheat_Sheet.html
Leave a Reply
You must be logged in to post a comment.