How to create an API in Spring with support for multiple languages

Sebastián Plawner
Ibisdev
Published in
4 min readJun 22, 2020

In this article I will present a short code that I implemented in a project where the user had to get the same message, but depending on his language, this message should be adaptable to it.

One way this problem could have been solved was to send error codes, either in String format or number, but let’s assume that delegate everything to the customer is not always acceptable, there may be inconsistency in how messages are written, for example, among an application mobile and the web. Or even it may raise doubts about the meaning of each error code, and keep control over a chart where each error has a reference to its meaning. There is a simpler way where the API is responsible for sending those messages to the clients in which they just have to display them on the screen to the user.

Before we start, I would like to mention that this code is made in Kotlin, using the Spring Boot framework. So you will see annotations or syntax suggar that are typical of these.

First of all, I needed to create a CustomLocaleResolver that reads the header Accept-Language to recognize the language accepted by the customer who sends the order. This was the final result:

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.support.ResourceBundleMessageSource
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver
import java.util.*
import javax.servlet.http.HttpServletRequest
@Configuration
class CustomLocaleResolver : AcceptHeaderLocaleResolver(), WebMvcConfigurer {
override fun resolveLocale(request: HttpServletRequest): Locale {
val headerLang = request.getHeader("Accept-Language")
return if (headerLang == null || headerLang.isEmpty())
Locale.getDefault()
else
Locale.lookup(Locale.LanguageRange.parse(headerLang), LOCALES)
}
@Bean
fun messageSource(): ResourceBundleMessageSource {
val rs = ResourceBundleMessageSource()
rs.setBasename("messages")
rs.setDefaultEncoding("UTF-8")
rs.setUseCodeAsDefaultMessage(true)
return rs
}
companion object {
val LOCALES = listOf(
Locale("en"),
Locale("es"),
Locale("fr"))
}
}

Notice that I used the ©Configuration annotation that automatically adds this class to the Spring Boot configuration when it starts working.

Another detail: by extending the AcceptHeaderLocaleResolver class I was able to overwrite the method resolveLocale that allows Spring to set the language of the response given the header or, in case there is none, move to the default values.

Then, in the following methods I defined the name of the configuration files where the languages are stored, they are called messages and the list of accepted languages are: english, spanish and french.

Afterwards, I created a class called Translator which is responsible for translating the messages:

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.i18n.LocaleContextHolder
import org.springframework.context.support.ResourceBundleMessageSource
import org.springframework.stereotype.Component
@Component
class Translator @Autowired
constructor(messageSource: ResourceBundleMessageSource) {
init {
Companion.messageSource = messageSource
}
companion object {
private var messageSource: ResourceBundleMessageSource? = null
fun toLocale(msgCode: String?): String {
val locale = LocaleContextHolder.getLocale()
return messageSource?.getMessage(msgCode!!, null, locale) ?: ""
}
}
}

Remember that in Kotlin, static methods and variables are stored within the companion object block. In this case, the toLocate method will be the one that given a String, that refers to the tag of the messages, will return its translated version.

Now we must create configuration files in the resources folder, and in each one we’ll save the messages for each language. In this case, and given the languages I chose, it would look like this:

resources/
-- messages_es.properties
-- messages_en.properties
-- messages_fr.properties

Within each configuration file, we’ll save the messages like this:

error_401=Unauthorized
error_400=Bad Request
error_404=Not Found
notfound_user=User not found

The next step is to configure Spring to intercept all exceptions that we send and translate into the corresponding language. For this task we will use a ResponseEntityExceptionHandler.

import com.vmc.api.configuration.translator.Translator
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.http.ResponseEntity
import org.springframework.validation.BindException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.context.request.WebRequest
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler
import java.util.stream.Collectors
@RestControllerAdvice
class CustomGlobalExceptionHandler : ResponseEntityExceptionHandler() {
@ExceptionHandler(ResourceNotFoundException::class)
fun handleResourceNotFoundException(ex: RuntimeException): ResponseEntity<Any> {
return ResponseEntity(ExceptionResponse(
NOT_FOUND.value(),
Translator.toLocale("error_404"),
Translator.toLocale(ex.message)
), NOT_FOUND)
}
@ExceptionHandler(BadRequestException::class)
fun handleBadRequestException(ex: RuntimeException): ResponseEntity<Any> {
return ResponseEntity(ExceptionResponse(
BAD_REQUEST.value(),
Translator.toLocale("error_400"),
ex.message?.let { Translator.toLocale(ex.message) } ?: Translator.toLocale("message_400")
), BAD_REQUEST)
}
override fun handleBindException(ex: BindException, headers: HttpHeaders, status: HttpStatus, request: WebRequest): ResponseEntity<Any> {
val fields = ex.bindingResult
.fieldErrors
.stream()
.map { x -> x.defaultMessage?.let { Translator.toLocale(it) } }
.collect(Collectors.toList())
return ResponseEntity(ExceptionResponse(
status.value(),
Translator.toLocale("error_400"),
fields
), headers, status)
}
}

Methods such as handleResourceNotFoundException, handleBadRequestException and handleBindException allow us to intercept all exceptions in that category and give them a custom message with the static method Translator.toLocale

If you would like to send a BadRequest error code with a custom message, use the following:

throw BadRequestException("user_not_found_in_database")

Being the native BadRequestException class, you can take a value as a parameter to the error message or leave it blank, then the class CustomGlobalExceptionHandler will give you a default value for that type of exception.

Now, have a look at the class ExceptionResponse, which matches with a class that will create a custom error message, leaving behind the typical Spring format.

import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL
import com.vmc.api.configuration.translator.Translator
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.*
@JsonInclude(NON_NULL)
class ExceptionResponse {
val timestamp: String
val status: Int
val error: String
val message: String?
val fields: List<String?>?
constructor(status: Int, error: String, fields: List<String?>) {
this.timestamp = dateTimeFormatter.format(Instant.now())
this.status = status
this.error = error
this.fields = fields
this.message = Translator.toLocale("message_400")
}
constructor(status: Int, error: String, message: String) {
this.timestamp = dateTimeFormatter.format(Instant.now())
this.status = status
this.error = error
this.fields = null
this.message = message
}
companion object {
private val dateTimeFormatter = DateTimeFormatter
.ofPattern("yyyy-MM-dd HH:mm:ss")
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
}
}

In this way, all error messages will have the following format:

{
"timestamp": "",
"status": "",
"error": "",
"fields": "",
"message": ""
}

Being the optional fields field, Spring sometimes decides to print the name of the fields that failed, for example when using the annotations @Valid, then this field will be useful for us. If it does not exist, the annotation @Jsonlnclude(NON_NULL) will remove it from the response as its content is null.

In case you want to use the Translator.toLocale method in other parts of the code, it is loaded within the Spring configuration and as is it a static method, you can name it anywhere with no inconvenience. While the error code exists in the configuration files we created at the beginning, Spring will be in charge of interpreting the correct one for each customer order.

--

--