Exception Handling and I18N on Spring Boot APIs, Part 2
In the second part our this article, we'll create a proper exception handler, fully integrated with bean validation that is easy to use.
Join the DZone community and get the full member experience.
Join For FreeGlobally Handling Exceptions on Spring Boot
Every message that our Spring Boot API is going to send to the user will be serialized as a JSON object. Therefore, we need to create a class to represent a structured message. Let's call this class RestMessage
and add to the com.questionmarks.util
package with the following code:
package com.questionmarks.util;
import lombok.Getter;
import java.util.List;
@Getter
public class RestMessage {
private String message;
private List<String> messages;
public RestMessage(List<String> messages) {
this.messages = messages;
}
public RestMessage(String message) {
this.message = message;
}
}
In contrast to RestException
, we haven't used any Lombok annotation to create the constructors of this class. As of the time of writing, no feature provided by Lombok creates separate constructors for each property. Therefore, we needed to add the code by ourselves, but at least we could take advantage of the @Getter
annotation again.
As the idea is to serialize instances of this class as JSON objects back to the user, we are going to tweak the serialization process a little. By default, Jackson (the JSON serializer used by Spring Boot) serializes all properties in an instance, whether it has these values or not. To avoid adding a bunch of null
in these JSON objects, let's edit the application.properties
file by adding the following line:
spring.jackson.default-property-inclusion=non_null
With this configuration in place, we can move ahead and implement the class that will handle all exceptions thrown throughout the execution of requests in our application. Let's call this class RestExceptionHandler
and create it in the main package (com.questionmark
):
package com.questionmarks;
import com.questionmarks.util.RestException;
import com.questionmarks.util.RestMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
@ControllerAdvice
public class RestExceptionHandler {
private static final String UNEXPECTED_ERROR = "Exception.unexpected";
private final MessageSource messageSource;
@Autowired
public RestExceptionHandler(MessageSource messageSource) {
this.messageSource = messageSource;
}
@ExceptionHandler(RestException.class)
public ResponseEntity<RestMessage> handleIllegalArgument(RestException ex, Locale locale) {
String errorMessage = messageSource.getMessage(ex.getMessage(), ex.getArgs(), locale);
return new ResponseEntity<>(new RestMessage(errorMessage), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<RestMessage> handleArgumentNotValidException(MethodArgumentNotValidException ex, Locale locale) {
BindingResult result = ex.getBindingResult();
List<String> errorMessages = result.getAllErrors()
.stream()
.map(objectError -> messageSource.getMessage(objectError, locale))
.collect(Collectors.toList());
return new ResponseEntity<>(new RestMessage(errorMessages), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<RestMessage> handleExceptions(Exception ex, Locale locale) {
String errorMessage = messageSource.getMessage(UNEXPECTED_ERROR, null, locale);
ex.printStackTrace();
return new ResponseEntity<>(new RestMessage(errorMessage), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
As this class' implementation is not that trivial, let's take a closer look at the details.
Making the Exception Handler Global
To make our exception handler implementation global, we have used the @ControllerAdvice
annotation. This annotation is a specialization of @Component
and enables developers to apply, among other things, @ExceptionHandler
methods globally to all controllers in an application.
This means that the methods defined in this class that handle exceptions will apply to all @Controllers
that we define in our application. This helps us to avoid having to define a base class that the controllers have to extend or having to define exception handlers on each controller.
Injecting an I18N Message Resource
Since we aim to support multiple languages, we have defined the constructor of this class to get an instance of MessageSource
injected. This instance enables us to search for (I18N) messages defined in the messages.properties
files, or on its variations for other languages, based on codes.
As an example, in this class, we've defined a private constant called UNEXPECTED_ERROR
. The value of this constant is Exception.unexpected
and will point to a message that tells the user that the error was not expected. We will define the messages and their localizations later on in the article.
Handling REST Exceptions
To handle exceptions derived from (or instance of) RestException
, we define a method called handleIllegalArgument
and annotate it with @ExceptionHandler(RestException.class)
. Whenever an exception of this class is caught by the method, the code message set in the exception is passed to the MessageSource
instance to get a localized message explaining the error. Besides that, the args
property and the current locale
are passed alongside with the message code so Spring can interpolate the localized final message replacing any placeholders.
String errorMessage = messageSource
.getMessage(ex.getMessage(), ex.getArgs(), locale);
Handling Bean Validation Exceptions
In the previous article, we developed a solution that transforms DTOs into entities and that triggers the bean validation for these DTOs automatically. This means that, for example, if we define a property as @NotNull
in a DTO and a user sends an instance that contains null
as the value property, a MethodArgumentNotValidException
is thrown saying that this situation is not valid.
To catch this exception and provide a better message, we have defined a method called handleArgumentNotValidException
and set it to handle MethodArgumentNotValidExceptions
. Since multiple validation errors might occur, we map the error codes to messages defined in messages.properties
files.
List<String> errorMessages = result.getAllErrors()
.stream()
.map(objectError -> messageSource.getMessage(objectError, locale))
.collect(Collectors.toList());
Handling Unexpected Exceptions
The last method defined in the RestExceptionHandler
class is responsible for handling exceptions that we have not foreseen. For example, let's say that for some reason Spring is unable to inject a Repository
instance in a controller, and we try to use this null reference to hit the database. In this situation, a NullPointerException
will be thrown by the application and this method will catch it. Since our application was not expecting this error to occur, and we don't have much to say to the user, we just use the UNEXPECTED_ERROR
constant to search for a localized message that tells the user that something went wrong.
String errorMessage = messageSource.getMessage(UNEXPECTED_ERROR, null, locale);
ex.printStackTrace();
We also call printStackTrace
method in the exception to log its details to be able to analyze it later.
Using the Global Exception Handler
Now that we have a global exception handler in place, let's change some classes to see it working. In the previous article, we created two DTOs to handle the insertion and update of exams, ExamCreationDTO
and ExamUpdateDTO
. Both of them used only the @NotNull
annotation to avoid null values on their properties. Let's start incrementing the ExamCreationDTO
class to add a new validation:
package com.questionmarks.model.dto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
@Getter
@Setter
public class ExamCreationDTO {
@NotNull
@Size(min = 1, max = 50)
private String title;
@NotNull
@Size(min = 1, max = 512)
private String description;
@JsonIgnore
private final LocalDateTime createdAt = LocalDateTime.now();
@JsonIgnore
private final LocalDateTime editedAt = LocalDateTime.now();
}
The difference between this version and the one created in the previous article is that now we use @Size
annotations to guarantee that title
and description
won't exceed the limits defined in the database. To keep everything consistent, let's add the same annotation to the same fields but in the ExamUpdateDTO
class:
package com.questionmarks.model.dto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Id;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
@Getter
@Setter
public class ExamUpdateDTO {
@Id
@NotNull
private Long id;
@NotNull
@Size(min = 1, max = 50)
private String title;
@NotNull
@Size(min = 1, max = 512)
private String description;
@JsonIgnore
private final LocalDateTime editedAt = LocalDateTime.now();
}
From now on, when the bean validation process gets triggered on instances of these classes, title
and description
on both classes are checked to guarantee that no null values are set on it and that the values don't exceed the limits defined. In case one or more of these validations fail, an instance of MethodArgumentNotValidException
is thrown indicating what properties failed. For example, if the user sends a title
with more than 50 characters, the bean validation process will produce an exception with the following code: Size.exam.title
. The exception handler will then get this code and search in the messages.properties
file for an associated message.
We will define these messages in the following sections, but first, let's make just one more change in our application. We will refactor the DTOModelMapper
class to validate if the application managed to find the object persisted with the id
provided on a DTO. For those who didn't read the previous article, this class is responsible for the automatic mapping of DTOs into entities and, for DTOs that include @Id
properties, it tries to fetch records from the database. Let's refactor the resolveArgument
method in this class to include a call to the Check.notNull()
method, as follows:
package com.questionmarks.util;
// ... imports
public class DTOModelMapper extends RequestResponseBodyMethodProcessor {
// ...
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
Object dto = super.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
Object id = getEntityId(dto);
if (id == null) {
return modelMapper.map(dto, parameter.getParameterType());
} else {
Object persistedObject = entityManager.find(parameter.getParameterType(), id);
Check.notNull(persistedObject, "Exception.notFound",
parameter.getParameterType().getSimpleName(), id);
modelMapper.map(dto, persistedObject);
return persistedObject;
}
}
// ...
}
By adding the Check.notNull
call to the else
block, we guarantee that the program is able to find an entity with the id
passed before proceeding with the execution. In case no entity is found, a RestException
is thrown with the Exception.notFound
code and with the simple name (getSimpleName
) of the class alongside with the id
provided.
Creating the I18N Messages
The last thing we need in order to have a Spring Boot API that supports multiple languages is to map the messages that we will send to our users and translate them to other languages. The messages in English will be stored in a new file called messages.properties
that we are going to create in the src/main/resources/
folder. Let's add the following messages:
NotNull.exam.id=Please, inform the exam''s id to be updated.
NotNull.exam.title=Please, provide a title to the exam.
NotNull.exam.description=Please, provide a description to the exam.
Size.exam.title=Exam title must contain between {2} and {1} characters.
Size.exam.description=Exam description must contain between {2} and {1} characters.
Exception.notFound=No record of {0} could be found with id {1}.
Exception.unexpected=An unexpected error occurred while processing your request.
Whenever a validation fails, Spring Boot generates a code that starts with the annotation name (e.g. Size
), then it adds the entity where the validation failed (e.g. exam
), and lastly, it adds the property (e.g. description
). Like that, it's easy to know what messages we need to map when dealing with bean validation.
For some messages, we have defined placeholders like {0}
and {1}
. These placeholders are replaced by Spring to provide a better explanation to users. For example, if the size of an exam's title
is too long, Spring gets the message referenced by Size.exam.title
and replaces {2}
and {1}
with the minimum and maximum length set in the @Size
annotation.
In the messages added above, only two didn't follow the pattern explained: Exception.notFound
and Exception.unexpected
. The former was created when refactoring the DTOModelMapper
class, and the latter was defined to tell users about errors that we didn't expect.
Supporting Other Languages
To give alternative languages for users, we need to create other files with translated versions of the messages above. The names of these files must start with messages
followed by an underscore and the ISO code of the language chosen (e.g. _pt
). Optionally, we can add another underscore followed by the ISO code of a specific region (e.g. _BR
). This pattern enables us to provide messages to multiple languages and its variations.
In this article, we are going to create a file called messages_pt_BR.properties
, in the src/main/resources/
folder, to support users from Brazil (BR
):
NotNull.exam.id=Por favor, informe o id do exame a ser editado.
NotNull.exam.title=Por favor, informe um título para o exame.
NotNull.exam.description=Por favor, informe uma descrição para o exame.
Size.exam.title=O título do exame deve conter entre {2} e {1} caracteres.
Size.exam.description=A descrição do exame deve conter entre {2} e {1} caracteres.
Exception.unexpected=Um erro inesperado ocorreu durante a execução da sua requisição.
The official language in Brazil is Portuguese (pt
), but as the language spoken there is quite different from Portugal, we opted to have a translation crafted specifically for Brazilians. This is everything we need to do to support the Portuguese variation spoken in Brazil. Now, whenever a user expresses that they want messages in Brazilian Portuguese, Spring Boot will search the messages_pt_BR.properties
file to get the appropriate message.
In the next section, we will see how to interact with the API to get user-friendly messages in both languages: English and Brazilian Portuguese.
Interacting With a Localized Spring Boot API
Before start testing our API, let's run the application. This can be accomplished through our IDE or through the gradle bootRun
command. When the API finishes bootstrapping, we can send the following request to add a new exam:
# adds a new exam
curl -X POST -H "Content-Type: application/json" -d '{
"title": "Another show exam",
"description": "Another show exam desc"
}' http://localhost:8080/exams
The command above must work without problems and no output message is expected from the API. Now, if we send the following request:
# tries to add a new exam without a title
curl -X POST -H "Content-Type: application/json" -d '{
"description": "Another show exam desc"
}' http://localhost:8080/exams
We can expect a message to be sent back from our API, since we didn't define a title, saying "Please, provide a title for the exam." As the message is structured as JSON, the output from Spring Boot is:
{"messages":["Please, provide a title to the exam."]}}
This proves that RestExceptionHandler
worked and crafted a better message for the user. But let's say that we prefer to get messages in Brazilian Portuguese, how do we do that? Easy! We just need to inform the API which language we want through the Accept-Language
header in the request:
curl -X POST -H "Content-Type: application/json" -H "Accept-Language: pt-BR" -d '{
"description": "Another show exam desc"
}' http://localhost:8080/exams
And the output provided by Spring Boot will be in Portuguese:
{"messages":["Por favor, informe um título para o exame."]}
For the sake of completeness, let's see placeholders getting replaced when we send a title that is too long:
curl -X POST -H "Content-Type: application/json" -d '{
"title": "This title is too long to be accepted and Spring Boot will complain about it",
"description": "Another show exam desc"
}' http://localhost:8080/exams
As we haven't defined the Accept-Language
header in the request above, and as the title exceeded the limits, Spring Boot will send us the following message:
{"messages":["Exam title must contain between 1 and 50 characters."]}
Both the {1}
and {2}
placeholders in the original, English message, got replaced by the min
and max
values set in the @Size
annotation configured in the ExamCreationDTO
.
Next Steps: Integration Testing on Spring Boot APIs
There we go, we now have a proper exception handler in place, fully integrated with bean validation and that is easy to use. We are now ready to add the missing endpoints that our to-be-developed frontend applications will need. As we want these new endpoints to function properly, in the next article we are going to create these endpoints alongside integration tests.
Throughout the article, we are going to use libraries such as JUnit and Hamcrest to simulate interactions with the RESTful API to guarantee that everything works as expected. Stay tuned!
Published at DZone with permission of Bruno Krebs, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments