Add i18n module: MessageSource, LocaleResolver and localized ProblemDetail

English, Marathi and Hindi bundles behind one small web app, with tests that run it on a real
port and write the transcripts (01-11) quoted in the article, plus a jar-metadata capture (12).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
Claude
2026-09-24 09:54:55 +00:00
parent e59ff029a1
commit 2cbf31e7d5
36 changed files with 1182 additions and 0 deletions
@@ -0,0 +1,7 @@
package com.ankurm.i18n;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class I18nApp {
}
@@ -0,0 +1,74 @@
package com.ankurm.i18n.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import java.time.Duration;
import java.util.Locale;
/**
* Three ways of arranging "let the user pick a language with ?lang=mr". Each is a profile so that
* the tests can start the application in each arrangement and compare.
*/
public class LocaleConfig {
private static LocaleChangeInterceptor changeInterceptor() {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName("lang");
return interceptor;
}
/** Correct: a cookie resolver that is called "localeResolver", plus the interceptor that reads ?lang. */
@Configuration(proxyBeanMethods = false)
@Profile("cookie")
static class Cookie implements WebMvcConfigurer {
@Bean
LocaleResolver localeResolver() {
CookieLocaleResolver resolver = new CookieLocaleResolver("lang");
resolver.setDefaultLocale(Locale.ENGLISH);
resolver.setCookieMaxAge(Duration.ofDays(30));
return resolver;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(changeInterceptor());
}
}
/** Same resolver, wrong bean name. */
@Configuration(proxyBeanMethods = false)
@Profile("cookiewrongname")
static class WrongName implements WebMvcConfigurer {
@Bean
LocaleResolver cookieLocaleResolver() {
CookieLocaleResolver resolver = new CookieLocaleResolver("lang");
resolver.setDefaultLocale(Locale.ENGLISH);
return resolver;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(changeInterceptor());
}
}
/** The interceptor with Spring Boot's default resolver, which reads the Accept-Language header. */
@Configuration(proxyBeanMethods = false)
@Profile("interceptoronly")
static class InterceptorOnly implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(changeInterceptor());
}
}
}
@@ -0,0 +1,47 @@
package com.ankurm.i18n.web;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
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;
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
/** Spring's own exception, already an ErrorResponse: translate its title and detail, then add the field messages. */
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail problem = ex.updateAndGetBody(getMessageSource(), LocaleContextHolder.getLocale());
problem.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.sorted()
.toList());
return handleExceptionInternal(ex, problem, headers, status, request);
}
/** An ordinary exception, translated by hand through the MessageSource. */
@ExceptionHandler(PaymentDeclinedException.class)
ProblemDetail paymentDeclined(PaymentDeclinedException ex) {
var locale = LocaleContextHolder.getLocale();
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.PAYMENT_REQUIRED,
getMessageSource().getMessage("payment.declined", new Object[]{ex.amount()}, locale));
problem.setTitle(getMessageSource().getMessage("payment.declined.title", null, locale));
return problem;
}
/** The same kind of handler, written the way most are: English text in the source. */
@ExceptionHandler(LegacyStateException.class)
ProblemDetail legacyState() {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "The order is in a state that does not allow this.");
problem.setTitle("Conflict");
return problem;
}
}
@@ -0,0 +1,17 @@
package com.ankurm.i18n.web;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* The messages in braces are message KEYS, looked up in messages.properties for the caller's locale.
* "city" has no message on purpose: it shows what the validator says on its own.
*/
public record CustomerRequest(
@NotBlank(message = "{customer.name.required}") String name,
@Email(message = "{customer.email.invalid}") @NotBlank(message = "{customer.email.required}") String email,
@Min(value = 18, message = "{customer.age.min}") int age,
@NotNull String city) {
}
@@ -0,0 +1,9 @@
package com.ankurm.i18n.web;
/** Handled with a hard-coded English sentence, to show what is left behind when a handler forgets the MessageSource. */
public class LegacyStateException extends RuntimeException {
public LegacyStateException() {
super("bad state");
}
}
@@ -0,0 +1,16 @@
package com.ankurm.i18n.web;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;
/**
* An ErrorResponse: Spring looks up its title and detail in the MessageSource on its own, under
* problemDetail.title.com.ankurm.i18n.web.OrderNotFoundException and problemDetail.com.ankurm.i18n.web.OrderNotFoundException.
*/
public class OrderNotFoundException extends ErrorResponseException {
public OrderNotFoundException(long id) {
super(HttpStatus.NOT_FOUND, ProblemDetail.forStatus(HttpStatus.NOT_FOUND), null, null, new Object[]{id});
}
}
@@ -0,0 +1,16 @@
package com.ankurm.i18n.web;
/** An ordinary exception: Spring does NOT translate anything for it. The handler must do it. */
public class PaymentDeclinedException extends RuntimeException {
private final String amount;
public PaymentDeclinedException(String amount) {
super("declined " + amount);
this.amount = amount;
}
public String amount() {
return amount;
}
}
@@ -0,0 +1,68 @@
package com.ankurm.i18n.web;
import jakarta.validation.Valid;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.text.NumberFormat;
import java.util.Locale;
import java.util.Map;
@RestController
public class ShopController {
private final MessageSource messages;
public ShopController(MessageSource messages) {
this.messages = messages;
}
/** The locale parameter is filled in by whichever LocaleResolver the application has. */
@GetMapping("/api/greeting")
Map<String, String> greeting(@RequestParam(defaultValue = "Asha") String name, Locale locale) {
return Map.of("locale", locale.toString(), "message", messages.getMessage("greeting", new Object[]{name}, locale));
}
@GetMapping("/api/cart")
Map<String, String> cart(@RequestParam long items, Locale locale) {
return Map.of("locale", locale.toString(), "message", messages.getMessage("cart.items", new Object[]{items}, locale));
}
/** The same sentence, but the number is formatted by us and handed over as text, so MessageFormat cannot localize its digits. */
@GetMapping("/api/cart-latin")
Map<String, String> cartLatin(@RequestParam long items, Locale locale) {
String formatted = NumberFormat.getIntegerInstance(Locale.ENGLISH).format(items);
return Map.of("locale", locale.toString(), "message", messages.getMessage("cart.items", new Object[]{formatted}, locale));
}
@GetMapping("/api/orders/{id}")
Map<String, Object> order(@PathVariable long id) {
if (id != 1) {
throw new OrderNotFoundException(id);
}
return Map.of("id", id);
}
@PostMapping("/api/customers")
@ResponseStatus(HttpStatus.CREATED)
Map<String, String> register(@Valid @RequestBody CustomerRequest request) {
return Map.of("name", request.name());
}
@GetMapping("/api/pay")
void pay() {
throw new PaymentDeclinedException("499.00");
}
@GetMapping("/api/legacy")
void legacy() {
throw new LegacyStateException();
}
}
@@ -0,0 +1,5 @@
spring.application.name=i18n
spring.main.banner-mode=off
server.port=0
# Spring Boot's own defaults for messages are used on purpose: basename "messages", UTF-8, no other setting.
@@ -0,0 +1,20 @@
greeting=Hello, {0}!
cart.items=You have {0} items in your cart.
only.english.key=This sentence has not been translated yet.
# The first has a doubled apostrophe, the second a single one. See the MessageFormat run.
apos.doubled=Order {0} can''t be shipped yet.
apos.single=Order {0} can't be shipped yet.
customer.name.required=Name is required.
customer.email.required=Email is required.
customer.email.invalid=Enter a valid email address.
customer.age.min=You must be at least {value} years old.
payment.declined.title=Payment declined
payment.declined=Payment of {0} was declined.
problemDetail.title.com.ankurm.i18n.web.OrderNotFoundException=Order not found
problemDetail.com.ankurm.i18n.web.OrderNotFoundException=No order with id {0} exists.
problemDetail.title.org.springframework.web.bind.MethodArgumentNotValidException=Validation failed
problemDetail.org.springframework.web.bind.MethodArgumentNotValidException=The request has invalid fields.
@@ -0,0 +1,17 @@
greeting=नमस्ते, {0}!
cart.items=आपकी कार्ट में {0} वस्तुएँ हैं।
customer.name.required=नाम आवश्यक है।
customer.email.required=ईमेल आवश्यक है।
customer.email.invalid=कृपया सही ईमेल पता दर्ज करें।
customer.age.min=आपकी आयु कम से कम {value} वर्ष होनी चाहिए।
payment.declined.title=भुगतान अस्वीकृत
payment.declined={0} का भुगतान अस्वीकार कर दिया गया।
problemDetail.title.com.ankurm.i18n.web.OrderNotFoundException=ऑर्डर नहीं मिला
problemDetail.com.ankurm.i18n.web.OrderNotFoundException=क्रमांक {0} वाला ऑर्डर मौजूद नहीं है।
problemDetail.title.org.springframework.web.bind.MethodArgumentNotValidException=सत्यापन विफल
problemDetail.org.springframework.web.bind.MethodArgumentNotValidException=अनुरोध के कुछ फ़ील्ड अमान्य हैं।
jakarta.validation.constraints.NotNull.message=खाली नहीं होना चाहिए।
@@ -0,0 +1,17 @@
greeting=नमस्कार, {0}!
cart.items=तुमच्या कार्टमध्ये {0} वस्तू आहेत.
customer.name.required=नाव आवश्यक आहे.
customer.email.required=ईमेल आवश्यक आहे.
customer.email.invalid=कृपया वैध ईमेल पत्ता द्या.
customer.age.min=तुमचे वय किमान {value} वर्षे असणे आवश्यक आहे.
payment.declined.title=पेमेंट नाकारले
payment.declined={0} चे पेमेंट नाकारले गेले.
problemDetail.title.com.ankurm.i18n.web.OrderNotFoundException=ऑर्डर सापडली नाही
problemDetail.com.ankurm.i18n.web.OrderNotFoundException={0} क्रमांकाची ऑर्डर अस्तित्वात नाही.
problemDetail.title.org.springframework.web.bind.MethodArgumentNotValidException=पडताळणी अयशस्वी
problemDetail.org.springframework.web.bind.MethodArgumentNotValidException=विनंतीतील काही फील्ड चुकीची आहेत.
jakarta.validation.constraints.NotNull.message=रिक्त नसावे.