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:
@@ -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=रिक्त नसावे.
|
||||
Reference in New Issue
Block a user