1. Overview
Frequently, we find ourselves tasked with designing applications that must deliver localized messages within a multilingual environment. In such scenarios, delivering messages in the user’s selected language is a common practice.
When we receive client requests to a REST web service, we must ensure the incoming client requests meet the predefined validation rules before processing them. Validations aim to maintain data integrity and enhance system security. The service is responsible for providing informative messages to indicate what’s wrong with the request whenever the validation fails.
In this tutorial, we’ll explore the implementation of delivering localized validation messages in a REST web service.
2. Essential Steps
Our journey begins with utilizing resource bundles as a repository for storing localized messages. We’ll then integrate resource bundles with Spring Boot which allows us to retrieve localized messages in our application.
After that, we’ll jump on to web service creation containing request validation. This showcases how localized messages are utilized in the event of a validation error during a request.
Finally, we’ll explore different kinds of localized message customization. These include overriding the default validation messages, defining our own resource bundle to provide custom validation messages, and creating a custom validation annotation for dynamic message generation.
Through these steps, we’ll refine our understanding of delivering precise and language-specific feedback in multilingual applications.
3. Maven Dependency
Before we get started, let’s add the Spring Boot Starter Web and Spring Boot Starter Validation dependencies for web development and Java Bean Validation to pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
The latest version of these can be found on Maven Central.
4. Localized Messages Storage
In Java application development, property files commonly function as repositories for localized messages in internationalized applications. It’s considered a conventional approach to localization. It’s often named a property resource bundle.
These files are plain text documents comprising key-value pairs. The key functions as an identifier for message retrieval, while the associated value holds the localized message in the corresponding language.
In this tutorial, we’ll create two property files.
CustomValidationMessages.properties is our default property file where the file name doesn’t contain any locale name. The application always falls back to its default language whenever the client specifies a locale that isn’t supported:
field.personalEmail=Personal Email
validation.notEmpty={field} cannot be empty
validation.email.notEmpty=Email cannot be empty
We’d like to create an additional Chinese language property file as well – CustomValidationMessages_zh.properties. The application language switches to Chinese whenever the client specifies either zh or variants such as zh-tw as the locale:
field.personalEmail=個人電郵
validation.notEmpty={field}不能是空白
validation.email.notEmpty=電郵不能留空
We must ensure that all property files are encoded in UTF-8. This becomes particularly crucial when handling messages that include non-Latin characters like Chinese, Japanese, and Korean. This assurance guarantees that we’ll display all messages accurately without the risk of corruption.
5. Localized Messages Retrieval
Spring Boot simplifies the localized message retrieval through the MessageSource interface. It resolves messages from resource bundles in the application and enables us to obtain messages for different locales without additional effort.
We must configure the provider of MessageSource in Spring Boot before we can make use of it. In this tutorial, we’ll use ReloadableResourceBundleMessageSource as the implementation.
It’s capable of reloading message property files without server restart. This is very useful when we’re in the initial stage of application development when we want to see the message changes without redeploying the whole application.
We have to align the default encoding with the UTF-8 encoding that we’re using for our property files:
@Configuration
public class MessageConfig {
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasename("classpath:CustomValidationMessages");
messageSource.setDefaultEncoding("UTF-8");
return messageSource;
}
}
6. Bean Validation
In the validation process, a Data Transfer Object (DTO) named User is used, which contains an email field. We’ll apply Java Bean Validation to validate this DTO class. The email field is annotated with @NotEmpty to make sure it isn’t an empty string. This annotation is a standard Java Bean Validation annotation:
public class User {
@NotEmpty
private String email;
// getters and setters
}
7. REST Service
In this section, we’ll create a REST service, UserService, which is responsible for updating specific user information via the PUT method based on the request body:
@RestController
public class UserService {
@PutMapping(value = "/user", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UpdateUserResponse> updateUser(
@RequestBody @Valid User user,
BindingResult bindingResult) {
if (bindingResult.hasFieldErrors()) {
List<InputFieldError> fieldErrorList = bindingResult.getFieldErrors().stream()
.map(error -> new InputFieldError(error.getField(), error.getDefaultMessage()))
.collect(Collectors.toList());
UpdateUserResponse updateResponse = new UpdateUserResponse(fieldErrorList);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(updateResponse);
}
else {
// Update logic...
return ResponseEntity.status(HttpStatus.OK).build();
}
}
}
7.1. Locale Selection
It’s a common practice to employ the Accept-Language HTTP header to define the client’s language preference.
We can obtain the locale from the Accept-Language header in the HTTP request by using the LocaleResolver interface in Spring Boot. In our case, we don’t have to explicitly define a LocaleResolver. Spring Boot provides a default one for us.
Our service then returns the appropriate localized messages in accordance with this header. In situations where the client designates a language that our service doesn’t support, our service simply adopts English as the default language.
7.2. Validation
We annotate User DTO with @Valid in the updateUser(…) method. This indicates that Java Bean Validation validates the object when the REST web service is called. Validation occurs behind the scenes. We’ll examine the validation outcomes via the BindingResult object.
Whenever there is any field error, which is determined by bindingResult.hasFieldErrors(), Spring Boot fetches the localized error message for us according to the current locale and encapsulates the message into a field error instance.
We’ll iterate each field error in BindingResult and collect them into a response object, and send the response back to the client.
7.3. Response Objects
If validation fails, the service returns an UpdateResponse object containing the validation error messages in the specified language:
public class UpdateResponse {
private List<InputFieldError> fieldErrors;
// getter and setter
}
InputFieldError is a placeholder class to store which field contains the error and what the error message is:
public class InputFieldError {
private String field;
private String message;
// getter and setter
}
8. Validation Message Types
Let’s initiate an update request to the REST service /user with the following request body:
{
"email": ""
}
As a reminder, the User object must contain a non-empty email. Therefore, we expect that this request triggers a validation error.
8.1. Standard Message
We’ll see the following typical response with an English message if we don’t provide any language information in the request:
{
"fieldErrors": [
{
"field": "email",
"message": "must not be empty"
}
]
}
Now, let’s initiate another request with the following accept-language HTTP header:
accept-lanaguage: zh-tw
The service interprets that we’d like to use Chinese. It retrieves the message from the corresponding resource bundle. We’ll see the following response that includes the Chinese validation message:
{
"fieldErrors": [
{
"field": "email",
"message": "不得是空的"
}
]
}
These are standard validation messages provided by the Java Bean Validation. We can find an exhaustive list of messages from the Hibernate validator, which serves as the default validation implementation.
However, the messages we saw don’t look nice. We probably want to change the validation message to provide more clarity. Let’s take a move to modify the standardized messages.
8.2. Overridden Message
We can override default messages defined in Java Bean Validation implementation. All we need to do is define a property file that has the basename ValidationMessages.properties:
jakarta.validation.constraints.NotEmpty.message=The field cannot be empty
With the same basename, we’ll create another property file ValidationMessages_zh.properties for Chinese as well:
jakarta.validation.constraints.NotEmpty.message=本欄不能留空
Upon calling the same service again, the response message is replaced by the one we defined:
{
"fieldErrors": [
{
"field": "email",
"message": "The field cannot be empty"
}
]
}
However, the validation message still looks generic despite overriding the message. The message itself doesn’t reveal which field goes wrong. Let’s proceed to include the field name in the error message.
8.3. Customized Message
In this scenario, we’ll dive into customizing validation messages. We defined all customized messages in the CustomValidationMessages resource bundle earlier.
Then, we’ll apply the new message {validation.email.notEmpty} to the validation annotation to the User DTO. The curly bracket indicates the message is a property key linking it to the corresponding message within the resource bundle:
public class User {
@NotEmpty(message = "{validation.email.notEmpty}")
private String email;
// getter and setter
}
We’ll see the following message when we initiate a request to the service:
{
"fieldErrors": [
{
"field": "email",
"message": "Email cannot be empty"
}
]
}
8.4. Interpolated Message
We’ve improved the message significantly by including the field name in the message. However, a potential challenge arises when dealing with many fields. Imagine a scenario where we have 30 fields, and each field requires three different types of validations. This would result in 90 validation messages within each localized resource bundle.
We could utilize message interpolation to address this issue. Interpolated messages operate on placeholders that are replaced dynamically with actual values before presenting them to users. In the scenario that we mentioned before, this approach reduces the number of validation messages to 33, containing 30 field names and three unique validation messages.
Java Bean Validation doesn’t support a validation message with a self-defined placeholder. However, we can define a custom validation that includes additional attributes.
This time, we annotate the User with a new customized annotation @FieldNotEmpty. Based on the existing message attribute, we’ll introduce a new attribute field to indicate the field name:
public class User {
@FieldNotEmpty(message = "{validation.notEmpty}", field = "{field.personalEmail}")
private String email;
// getter and setter
}
Now, let us define @FieldNotEmpty with two attributes:
@Documented
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Constraint(validatedBy = {FieldNotEmptyValidator.class})
public @interface FieldNotEmpty {
String message() default "{validation.notEmpty}";
String field() default "Field";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@FieldNotEmpty operates as a constraint and uses FieldNotEmptyValidator as the validator implementation:
public class FieldNotEmptyValidator implements ConstraintValidator<FieldNotEmpty, Object> {
private String message;
private String field;
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
return (value != null && !value.toString().trim().isEmpty());
}
}
The isValid(…) method performs the validation logic and simply determines whether the value is not empty. If the value is empty, it retrieves localized messages for the attribute field and message corresponding to the current locale from the request context. The attribute message is interpolated to form a complete message.
Upon execution, we observe the following result:
{
"fieldErrors": [
{
"field": "email",
"message": "{field.personalEmail} cannot be empty"
}
]
}
The message attribute and its corresponding placeholder are successfully retrieved. However, we’re expecting {field.personalEmail} to be replaced by the actual value.
8.5. Custom MessageInterpolator
The problem lies in the default MessageInterpolator. It translates the placeholder for one time only. We need to apply the interpolation to the message again to replace the subsequent placeholder with the localized message. In this case, we have to define a custom message interpolator to replace the default one:
public class RecursiveLocaleContextMessageInterpolator extends AbstractMessageInterpolator {
private static final Pattern PATTERN_PLACEHOLDER = Pattern.compile("\\{([^}]+)\\}");
private final MessageInterpolator interpolator;
public RecursiveLocaleContextMessageInterpolator(ResourceBundleMessageInterpolator interpolator) {
this.interpolator = interpolator;
}
@Override
public String interpolate(MessageInterpolator.Context context, Locale locale, String message) {
int level = 0;
while (containsPlaceholder(message) && (level++ < 2)) {
message = this.interpolator.interpolate(message, context, locale);
}
return message;
}
private boolean containsPlaceholder(String code) {
Matcher matcher = PATTERN_PLACEHOLDER.matcher(code);
return matcher.find();
}
}
RecursiveLocaleContextMessageInterpolator is simply a decorator. It reapplies interpolation with the wrapped MessageInterpolator when it detects the message contains any curly bracket placeholder.
We’ve completed the implementation, and it’s time for us to configure Spring Boot to incorporate it. We’ll add two provider methods to MessageConfig:
@Bean
public MessageInterpolator getMessageInterpolator(MessageSource messageSource) {
MessageSourceResourceBundleLocator resourceBundleLocator = new MessageSourceResourceBundleLocator(messageSource);
ResourceBundleMessageInterpolator messageInterpolator = new ResourceBundleMessageInterpolator(resourceBundleLocator);
return new RecursiveLocaleContextMessageInterpolator(messageInterpolator);
}
@Bean
public LocalValidatorFactoryBean getValidator(MessageInterpolator messageInterpolator) {
LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
bean.setMessageInterpolator(messageInterpolator);
return bean;
}
The getMessageInterpolator(…) method returns our own implementation. This implementation wraps ResourceBundleMessageInterpolator, which is the default MessageInterpolator in Spring Boot. The getValidator() is for registering the validator to use our customized MessageInterpolator within our web service.
Now, we’re all set, and let’s test it once more. We’ll have the following complete interpolated message with the placeholder replaced by the localized message as well:
{
"fieldErrors": [
{
"field": "email",
"message": "Personal Email cannot be empty"
}
]
}
9. Conclusion
In this article, we dived into the process of delivering localized messages within multilingual applications.
We began with an outline for all the key steps for a complete implementation, starting from using property files as message repositories and encoding them in UTF-8. Spring Boot integration simplifies message retrieval based on client locale preferences. Java Bean Validation, along with custom annotations and message interpolation, allows for tailored, language-specific error responses.
By incorporating these techniques together, we’re able to provide localized validation responses in REST web services.
As always, the sample code is available over on GitHub.