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
+85
View File
@@ -0,0 +1,85 @@
# i18n
Companion project for the article **[Internationalization (i18n) in Spring Boot 4: MessageSource, LocaleResolver and Localized ProblemDetail](https://ankurm.com/spring-boot-4-internationalization-messagesource-localeresolver-problemdetail/)** on **[ankurm.com](https://ankurm.com)**.
One small web application that answers in English, Marathi and Hindi, plus the test suite that starts it on a real
port and records what it says. Every console block quoted in the article came out of `output/`. Transcripts 01-11 are
written by the test suite (so a claim that stops being true turns the build red); 12 is read out of the Spring Boot jar
by `scripts/capture-facts.sh`.
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible "going deeper"
sections inside the article itself, next to the paragraph each one extends.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| JDK | 25 (Temurin 25.0.4.1+1) |
| Maven | 3.9 |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn test # runs every scenario and rewrites output/01-11
./scripts/run-all.sh # everything, including 12 (needs python3)
```
The tests pin the JVM to `-Duser.language=en -Duser.country=US -Dfile.encoding=UTF-8` (see `pom.xml`) so the
transcripts do not depend on the machine they were generated on, and run each test class in its own JVM
(`reuseForks=false`) because Tomcat reads the JVM's default locale once.
## Profiles
| Profile | What it turns on |
|---|---|
| *(none)* | Spring Boot's defaults: `AcceptHeaderLocaleResolver`, basename `messages`, UTF-8 |
| `cookie` | `CookieLocaleResolver("lang")` named `localeResolver`, plus a `LocaleChangeInterceptor` for `?lang=` |
| `cookiewrongname` | the same resolver under the bean name `cookieLocaleResolver`, which Spring MVC ignores |
| `interceptoronly` | the `LocaleChangeInterceptor` with Spring Boot's default resolver |
## Endpoints
| Endpoint | What it shows |
|---|---|
| `GET /api/greeting?name=` | `MessageSource` with a `Locale` controller argument |
| `GET /api/cart?items=` | a number formatted by `MessageFormat` in the request locale |
| `GET /api/cart-latin?items=` | the same sentence with the number pre-formatted as text |
| `GET /api/orders/{id}` | `ErrorResponseException`: `problemDetail.*` keys translate it automatically |
| `POST /api/customers` | Bean Validation messages and a localized `MethodArgumentNotValidException` |
| `GET /api/pay` | an ordinary exception translated in the `@ExceptionHandler` |
| `GET /api/legacy` | an ordinary exception whose handler hard-codes English |
## Source layout
| Path | What it holds |
|---|---|
| `src/main/resources/messages*.properties` | English, Marathi (`_mr`) and Hindi (`_hi`), UTF-8 |
| `web/ShopController` | the endpoints above |
| `web/CustomerRequest` | Bean Validation with `{message.key}` placeholders |
| `web/OrderNotFoundException` | an `ErrorResponseException` with a message-code argument |
| `web/ApiExceptionHandler` | `ResponseEntityExceptionHandler` subclass |
| `config/LocaleConfig` | one nested configuration per profile |
| `src/test/.../LocaleResolutionTests` | transcripts 01, 03, 11 |
| `src/test/.../SystemLocaleFallbackTests` | transcript 02 (own JVM) |
| `src/test/.../MessagesTests` | transcripts 04-07 and 10 |
| `src/test/.../ProblemDetailTests` | transcripts 08, 09 |
## Index of captured output
| File | Written by | What it shows |
|---|---|---|
| `01-accept-language.txt` | `LocaleResolutionTests` | which locale each `Accept-Language` header resolves to, and what a missing header gives |
| `02-fallback-to-system-locale.txt` | `SystemLocaleFallbackTests` | a French or English request served in Hindi because the JVM default is Hindi |
| `03-cookie-locale-resolver.txt` | `LocaleResolutionTests` | `?lang=mr`, the `Set-Cookie`, the cookie honoured later; and the two wiring mistakes |
| `04-messageformat-apostrophes.txt` | `MessagesTests` | `''` vs `'`, with and without arguments |
| `05-devanagari-digits.txt` | `MessagesTests` | Marathi renders `1234567` in Devanagari digits, Hindi does not; two ways to keep Latin digits |
| `06-missing-keys.txt` | `MessagesTests` | a key present only in English, and a key present nowhere |
| `07-properties-encoding.txt` | `MessagesTests` | the same file read as UTF-8 and as ISO-8859-1 |
| `08-validation-messages.txt` | `ProblemDetailTests` | a 400 in four languages, validation messages and all |
| `09-problemdetail-localized.txt` | `ProblemDetailTests` | three kinds of exception, translated or not |
| `10-always-use-message-format.txt` | `MessagesTests` | the apostrophe lookups with `spring.messages.always-use-message-format=true` |
| `11-spring-web-locale.txt` | `LocaleResolutionTests` | `spring.web.locale` and `spring.web.locale-resolver=fixed` |
| `12-configuration-metadata.txt` | `capture-facts.sh` | every `spring.messages.*` and `spring.web.locale*` property, type and default |
+8
View File
@@ -0,0 +1,8 @@
# GET /api/greeting under different Accept-Language headers (Spring Boot's default LocaleResolver)
Accept-Language: mr-IN -> status=200 locale=mr_IN message=नमस्कार, Asha!
Accept-Language: hi -> status=200 locale=hi message=नमस्ते, Asha!
Accept-Language: mr;q=0.4, hi;q=0.9 -> status=200 locale=hi message=नमस्ते, Asha!
Accept-Language: en-GB -> status=200 locale=en_GB message=Hello, Asha!
Accept-Language: fr -> status=200 locale=fr message=Hello, Asha!
Accept-Language: (no header) -> status=200 locale=en_US message=Hello, Asha!
@@ -0,0 +1,11 @@
# The JVM's default locale is Hindi: what does a French or an English request get?
Locale.getDefault() while these ran: hi
--- spring.messages.fallback-to-system-locale left at its default ---
Accept-Language: fr -> status=200 locale=fr message=नमस्ते, Asha!
Accept-Language: en -> status=200 locale=en message=नमस्ते, Asha!
--- spring.messages.fallback-to-system-locale=false ---
Accept-Language: fr -> status=200 locale=fr message=Hello, Asha!
Accept-Language: en -> status=200 locale=en message=Hello, Asha!
+20
View File
@@ -0,0 +1,20 @@
# Letting the user pick a language: ?lang=mr with a cookie resolver, and two ways to get it wrong
--- A. CookieLocaleResolver named localeResolver + LocaleChangeInterceptor (profile 'cookie') ---
no cookie yet, Accept-Language: hi -> status=200 locale=en message=Hello, Asha!
?lang=mr, Accept-Language: hi -> status=200 locale=mr message=नमस्कार, Asha!
Set-Cookie: lang=mr; Path=/; Max-Age=2592000; SameSite=Lax
Cookie: lang=mr, Accept-Language: hi -> status=200 locale=mr message=नमस्कार, Asha!
--- B. the same resolver under the bean name cookieLocaleResolver (profile 'cookiewrongname') ---
Accept-Language: hi -> status=200 locale=hi message=नमस्ते, Asha!
?lang=mr -> status=500 (Tomcat's own HTML error page)
bean 'cookieLocaleResolver' exists: true
bean 'localeResolver' is a: AcceptHeaderLocaleResolver
what the interceptor triggers: UnsupportedOperationException: Cannot change HTTP Accept-Language header - use a different locale resolution strategy
--- C. only the interceptor, Spring Boot's default resolver (profile 'interceptoronly') ---
?lang=mr -> status=500 (Tomcat's own HTML error page)
bean 'localeResolver' is a: AcceptHeaderLocaleResolver
what the interceptor triggers: UnsupportedOperationException: Cannot change HTTP Accept-Language header - use a different locale resolution strategy
@@ -0,0 +1,9 @@
# The same sentence with a doubled and a single apostrophe, with and without arguments
apos.doubled = Order {0} can''t be shipped yet.
apos.single = Order {0} can't be shipped yet.
with an argument: doubled -> Order A-1 can't be shipped yet.
with an argument: single -> Order A-1 cant be shipped yet.
without arguments: doubled -> Order {0} can''t be shipped yet.
without arguments: single -> Order {0} can't be shipped yet.
+11
View File
@@ -0,0 +1,11 @@
# GET /api/cart?items=1234567 in three languages, and two ways to keep Latin digits
Accept-Language: en -> You have 1,234,567 items in your cart.
Accept-Language: hi -> आपकी कार्ट में 1,234,567 वस्तुएँ हैं।
Accept-Language: mr -> तुमच्या कार्टमध्ये १,२३४,५६७ वस्तू आहेत.
--- fix 1: the client asks for Latin digits (Unicode extension -u-nu-latn) ---
Accept-Language: mr-IN-u-nu-latn -> locale=mr_IN_#u-nu-latn तुमच्या कार्टमध्ये 1,234,567 वस्तू आहेत.
--- fix 2: the server formats the number itself and passes text (GET /api/cart-latin) ---
Accept-Language: mr -> तुमच्या कार्टमध्ये 1,234,567 वस्तू आहेत.
+4
View File
@@ -0,0 +1,4 @@
# A key that exists only in English, and a key that exists nowhere
only.english.key, locale mr -> This sentence has not been translated yet.
no.such.key, locale mr -> org.springframework.context.NoSuchMessageException: No message found under code 'no.such.key' for locale 'mr'.
+4
View File
@@ -0,0 +1,4 @@
# The same Marathi greeting, with Spring Boot's default encoding and with spring.messages.encoding=ISO-8859-1
default (UTF-8): नमस्कार, Asha!
ISO-8859-1: नमस्कार, Asha!
+39
View File
@@ -0,0 +1,39 @@
# POST /api/customers with an invalid body, four Accept-Language values
body: {"name":"","email":"x","age":10}
--- Accept-Language: en ---
status=400 Content-Type=application/problem+json
title: Validation failed
detail: The request has invalid fields.
error: age: You must be at least 18 years old.
error: city: must not be null
error: email: Enter a valid email address.
error: name: Name is required.
--- Accept-Language: mr ---
status=400 Content-Type=application/problem+json
title: पडताळणी अयशस्वी
detail: विनंतीतील काही फील्ड चुकीची आहेत.
error: age: तुमचे वय किमान 18 वर्षे असणे आवश्यक आहे.
error: city: रिक्त नसावे.
error: email: कृपया वैध ईमेल पत्ता द्या.
error: name: नाव आवश्यक आहे.
--- Accept-Language: hi ---
status=400 Content-Type=application/problem+json
title: सत्यापन विफल
detail: अनुरोध के कुछ फ़ील्ड अमान्य हैं।
error: age: आपकी आयु कम से कम 18 वर्ष होनी चाहिए।
error: city: खाली नहीं होना चाहिए।
error: email: कृपया सही ईमेल पता दर्ज करें।
error: name: नाम आवश्यक है।
--- Accept-Language: fr ---
status=400 Content-Type=application/problem+json
title: Validation failed
detail: The request has invalid fields.
error: age: You must be at least 18 years old.
error: city: ne doit pas être nul
error: email: Enter a valid email address.
error: name: Name is required.
@@ -0,0 +1,44 @@
# Three kinds of exception: an ErrorResponse, an ordinary exception handled through the MessageSource, and one handled with English text in the source
--- an ErrorResponse (OrderNotFoundException) ---
Accept-Language: en
status=404 Content-Type=application/problem+json
title: Order not found
detail: No order with id 7 exists.
Accept-Language: mr
status=404 Content-Type=application/problem+json
title: ऑर्डर सापडली नाही
detail: ७ क्रमांकाची ऑर्डर अस्तित्वात नाही.
Accept-Language: hi
status=404 Content-Type=application/problem+json
title: ऑर्डर नहीं मिला
detail: क्रमांक 7 वाला ऑर्डर मौजूद नहीं है।
--- an ordinary exception, translated in the handler (PaymentDeclinedException) ---
Accept-Language: en
status=402 Content-Type=application/problem+json
title: Payment declined
detail: Payment of 499.00 was declined.
Accept-Language: mr
status=402 Content-Type=application/problem+json
title: पेमेंट नाकारले
detail: 499.00 चे पेमेंट नाकारले गेले.
Accept-Language: hi
status=402 Content-Type=application/problem+json
title: भुगतान अस्वीकृत
detail: 499.00 का भुगतान अस्वीकार कर दिया गया।
--- an ordinary exception, English in the handler (LegacyStateException) ---
Accept-Language: en
status=409 Content-Type=application/problem+json
title: Conflict
detail: The order is in a state that does not allow this.
Accept-Language: mr
status=409 Content-Type=application/problem+json
title: Conflict
detail: The order is in a state that does not allow this.
Accept-Language: hi
status=409 Content-Type=application/problem+json
title: Conflict
detail: The order is in a state that does not allow this.
@@ -0,0 +1,6 @@
# The same four lookups with spring.messages.always-use-message-format=true
with an argument: doubled -> Order A-1 can't be shipped yet.
with an argument: single -> Order A-1 cant be shipped yet.
without arguments: doubled -> Order {0} can't be shipped yet.
without arguments: single -> Order {0} cant be shipped yet.
+12
View File
@@ -0,0 +1,12 @@
# spring.web.locale on its own, and with spring.web.locale-resolver=fixed
--- spring.web.locale=hi (spring.web.locale-resolver left at accept-header) ---
Accept-Language: (no header) -> status=200 locale=hi message=नमस्ते, Asha!
Accept-Language: mr -> status=200 locale=mr message=नमस्कार, Asha!
Accept-Language: fr -> status=200 locale=fr message=Hello, Asha!
--- spring.web.locale=hi and spring.web.locale-resolver=fixed ---
Accept-Language: (no header) -> status=200 locale=hi message=नमस्ते, Asha!
Accept-Language: mr -> status=200 locale=hi message=नमस्ते, Asha!
Accept-Language: fr -> status=200 locale=hi message=नमस्ते, Asha!
+31
View File
@@ -0,0 +1,31 @@
# spring.messages.* and spring.web.locale* in the configuration metadata shipped with Spring Boot 4.1.1
jar: spring-boot-autoconfigure-4.1.1.jar
spring.messages.always-use-message-format
type: java.lang.Boolean
default: false
spring.messages.basename
type: java.util.List<java.lang.String>
default: ["messages"]
spring.messages.cache-duration
type: java.time.Duration
default: (none)
spring.messages.common-messages
type: java.util.List<org.springframework.core.io.Resource>
default: (none)
spring.messages.encoding
type: java.nio.charset.Charset
default: "UTF-8"
spring.messages.fallback-to-system-locale
type: java.lang.Boolean
default: true
spring.messages.use-code-as-default-message
type: java.lang.Boolean
default: false
spring.web.locale
type: java.util.Locale
default: (none)
spring.web.locale-resolver
type: org.springframework.boot.autoconfigure.web.WebProperties$LocaleResolver
default: "accept-header"
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>i18n</artifactId>
<version>1.0.0</version>
<name>i18n</name>
<description>Internationalization in Spring Boot 4: MessageSource, LocaleResolver, validation messages and localized ProblemDetail</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- UTF-8 so that Marathi and Hindi survive being echoed; a fixed default locale so that the transcripts do not depend on the machine -->
<reuseForks>false</reuseForks>
<argLine>-Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Duser.language=en -Duser.country=US</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Facts that a test cannot observe, read straight out of the jars on the classpath.
# 12-configuration-metadata.txt every spring.messages.* and spring.web.locale* property, with type and default
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output target
mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/classpath.txt >/dev/null
{
echo "# spring.messages.* and spring.web.locale* in the configuration metadata shipped with Spring Boot 4.1.1"
echo
for jar in $(tr ':' '\n' < target/classpath.txt | grep -E 'spring-boot-autoconfigure-'); do
echo "jar: $(basename "$jar")"
echo
unzip -p "$jar" META-INF/spring-configuration-metadata.json | python3 -c '
import json, sys
for p in sorted(json.load(sys.stdin)["properties"], key=lambda p: p["name"]):
if p["name"].startswith(("spring.messages.", "spring.web.locale")):
print("%s\n type: %s\n default: %s" % (p["name"], p.get("type"), json.dumps(p["defaultValue"]) if "defaultValue" in p else "(none)"))
'
done
} > output/12-configuration-metadata.txt
cat output/12-configuration-metadata.txt
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Regenerates every file under output/.
#
# ./scripts/run-all.sh
#
# Needs a JDK 25, Maven 3.9 and python3. Transcripts 01-11 are written by the test suite, so each figure in the
# article is an assertion that fails the build if it stops being true. 12 is read out of the Spring Boot jar.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== test suite (transcripts 01-11)"
mvn -B test
echo "== facts read from jars (12)"
./scripts/capture-facts.sh
echo
echo "output:"
ls -1 output
@@ -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=रिक्त नसावे.
@@ -0,0 +1,48 @@
package com.ankurm.i18n;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
/** Real HTTP against the embedded Tomcat: the Accept-Language header is parsed by the container, so MockMvc would skip that. */
public final class Http {
private static final HttpClient CLIENT = HttpClient.newHttpClient();
public record Response(int status, String contentType, String body, java.net.http.HttpHeaders headers) {
public List<String> setCookies() {
return headers.allValues("Set-Cookie");
}
}
private Http() {
}
public static Response get(int port, String path, String... headers) {
return send(port, "GET", path, null, headers);
}
public static Response post(int port, String path, String json, String... headers) {
return send(port, "POST", path, json, headers);
}
private static Response send(int port, String method, String path, String body, String... headers) {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path))
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
if (body != null) {
b.header("Content-Type", "application/json");
}
for (int i = 0; i < headers.length; i += 2) {
b.header(headers[i], headers[i + 1]);
}
try {
HttpResponse<String> r = CLIENT.send(b.build(), HttpResponse.BodyHandlers.ofString());
return new Response(r.statusCode(), r.headers().firstValue("Content-Type").orElse(""), r.body(), r.headers());
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,21 @@
package com.ankurm.i18n;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/** Reads a response body so transcripts print named fields in a fixed order (Map.of iterates in a different order on every run). */
public final class Json {
private static final JsonMapper MAPPER = new JsonMapper();
private Json() {
}
public static JsonNode read(Http.Response response) {
return MAPPER.readTree(response.body());
}
public static String text(Http.Response response, String field) {
return read(response).path(field).asString();
}
}
@@ -0,0 +1,123 @@
package com.ankurm.i18n;
import org.junit.jupiter.api.Test;
import java.util.Locale;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import static org.assertj.core.api.Assertions.assertThat;
/** Transcripts 01, 03 and 11: how the locale is chosen, and what the MessageSource does with it. */
class LocaleResolutionTests {
private static String describe(Http.Response r) {
return "status=" + r.status() + " locale=" + Json.text(r, "locale") + " message=" + Json.text(r, "message");
}
@Test
void acceptLanguageHeader() {
try (var app = RunningApp.start(""); var t = new Transcript("01-accept-language.txt",
"GET /api/greeting under different Accept-Language headers (Spring Boot's default LocaleResolver)")) {
for (String header : new String[]{"mr-IN", "hi", "mr;q=0.4, hi;q=0.9", "en-GB", "fr"}) {
var r = Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", header);
t.line("Accept-Language: %-20s -> %s", header, describe(r));
}
var none = Http.get(app.port(), "/api/greeting?name=Asha");
t.line("Accept-Language: %-20s -> %s", "(no header)", describe(none));
assertThat(Json.text(Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", "mr-IN"), "message"))
.isEqualTo("नमस्कार, Asha!");
assertThat(Json.text(Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", "mr;q=0.4, hi;q=0.9"), "locale"))
.isEqualTo("hi");
assertThat(Json.text(Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", "fr"), "message"))
.isEqualTo("Hello, Asha!");
}
}
@Test
void springWebLocaleProperty() {
try (var t = new Transcript("11-spring-web-locale.txt",
"spring.web.locale on its own, and with spring.web.locale-resolver=fixed")) {
String[] headers = {null, "mr", "fr"};
t.section("spring.web.locale=hi (spring.web.locale-resolver left at accept-header)");
try (var app = RunningApp.start("", "spring.web.locale=hi")) {
for (String h : headers) {
var r = h == null ? Http.get(app.port(), "/api/greeting?name=Asha") : Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", h);
t.line("Accept-Language: %-11s -> %s", h == null ? "(no header)" : h, describe(r));
}
assertThat(Json.text(Http.get(app.port(), "/api/greeting?name=Asha"), "locale")).isEqualTo("hi");
assertThat(Json.text(Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", "mr"), "locale")).isEqualTo("mr");
}
t.section("spring.web.locale=hi and spring.web.locale-resolver=fixed");
try (var app = RunningApp.start("", "spring.web.locale=hi", "spring.web.locale-resolver=fixed")) {
for (String h : headers) {
var r = h == null ? Http.get(app.port(), "/api/greeting?name=Asha") : Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", h);
t.line("Accept-Language: %-11s -> %s", h == null ? "(no header)" : h, describe(r));
}
assertThat(Json.text(Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", "mr"), "locale")).isEqualTo("hi");
}
}
}
private static String withoutExpiry(String setCookie) {
return setCookie.replaceAll("Expires=[^;]*; ", "");
}
@Test
void cookieLocaleResolver() {
try (var t = new Transcript("03-cookie-locale-resolver.txt",
"Letting the user pick a language: ?lang=mr with a cookie resolver, and two ways to get it wrong")) {
t.section("A. CookieLocaleResolver named localeResolver + LocaleChangeInterceptor (profile 'cookie')");
try (var app = RunningApp.start("cookie")) {
var first = Http.get(app.port(), "/api/greeting", "Accept-Language", "hi");
t.line("no cookie yet, Accept-Language: hi -> %s", describe(first));
var change = Http.get(app.port(), "/api/greeting?lang=mr", "Accept-Language", "hi");
t.line("?lang=mr, Accept-Language: hi -> %s", describe(change));
t.line("Set-Cookie: %s", withoutExpiry(change.setCookies().get(0)));
var later = Http.get(app.port(), "/api/greeting", "Accept-Language", "hi", "Cookie", "lang=mr");
t.line("Cookie: lang=mr, Accept-Language: hi -> %s", describe(later));
assertThat(Json.text(first, "locale")).isEqualTo("en");
assertThat(Json.text(later, "locale")).isEqualTo("mr");
}
t.section("B. the same resolver under the bean name cookieLocaleResolver (profile 'cookiewrongname')");
try (var app = RunningApp.start("cookiewrongname")) {
var plain = Http.get(app.port(), "/api/greeting", "Accept-Language", "hi");
t.line("Accept-Language: hi -> %s", describe(plain));
var change = Http.get(app.port(), "/api/greeting?lang=mr", "Accept-Language", "hi");
t.line("?lang=mr -> status=%d (Tomcat's own HTML error page)", change.status());
t.line("bean 'cookieLocaleResolver' exists: %s", app.context().containsBean("cookieLocaleResolver"));
var resolver = app.context().getBean("localeResolver", LocaleResolver.class);
t.line("bean 'localeResolver' is a: %s", resolver.getClass().getSimpleName());
assertThat(Json.text(plain, "locale")).isEqualTo("hi");
assertThat(change.status()).isEqualTo(500);
assertThat(resolver).isInstanceOf(AcceptHeaderLocaleResolver.class);
t.line("what the interceptor triggers: %s", failureOf(resolver));
}
t.section("C. only the interceptor, Spring Boot's default resolver (profile 'interceptoronly')");
try (var app = RunningApp.start("interceptoronly")) {
var change = Http.get(app.port(), "/api/greeting?lang=mr", "Accept-Language", "hi");
t.line("?lang=mr -> status=%d (Tomcat's own HTML error page)", change.status());
var resolver = app.context().getBean("localeResolver", LocaleResolver.class);
t.line("bean 'localeResolver' is a: %s", resolver.getClass().getSimpleName());
assertThat(change.status()).isEqualTo(500);
t.line("what the interceptor triggers: %s", failureOf(resolver));
}
}
}
/** What LocaleChangeInterceptor does when it calls setLocale on the resolver; the HTTP body is Tomcat's page, so ask the resolver directly. */
private static String failureOf(LocaleResolver resolver) {
try {
resolver.setLocale(new MockHttpServletRequest(), new MockHttpServletResponse(), Locale.forLanguageTag("mr"));
return "no exception";
} catch (RuntimeException e) {
return e.getClass().getSimpleName() + ": " + e.getMessage();
}
}
}
@@ -0,0 +1,100 @@
package com.ankurm.i18n;
import org.junit.jupiter.api.Test;
import org.springframework.context.MessageSource;
import org.springframework.context.NoSuchMessageException;
import java.util.Locale;
import java.util.ResourceBundle;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Transcripts 04-07 and 10: what the MessageSource does with the text itself. */
class MessagesTests {
private static final Locale EN = Locale.ENGLISH;
private static final Locale MR = Locale.forLanguageTag("mr");
@Test
void apostrophes() {
try (var app = RunningApp.start(""); var t = new Transcript("04-messageformat-apostrophes.txt",
"The same sentence with a doubled and a single apostrophe, with and without arguments")) {
MessageSource ms = app.context().getBean(MessageSource.class);
Object[] args = {"A-1"};
t.line("apos.doubled = Order {0} can''t be shipped yet.");
t.line("apos.single = Order {0} can't be shipped yet.");
t.blank();
t.line("with an argument: doubled -> %s", ms.getMessage("apos.doubled", args, EN));
t.line("with an argument: single -> %s", ms.getMessage("apos.single", args, EN));
t.line("without arguments: doubled -> %s", ms.getMessage("apos.doubled", null, EN));
t.line("without arguments: single -> %s", ms.getMessage("apos.single", null, EN));
assertThat(ms.getMessage("apos.doubled", args, EN)).isEqualTo("Order A-1 can't be shipped yet.");
assertThat(ms.getMessage("apos.single", args, EN)).isEqualTo("Order A-1 cant be shipped yet.");
assertThat(ms.getMessage("apos.doubled", null, EN)).contains("''");
}
try (var app = RunningApp.start("", "spring.messages.always-use-message-format=true");
var t = new Transcript("10-always-use-message-format.txt",
"The same four lookups with spring.messages.always-use-message-format=true")) {
MessageSource ms = app.context().getBean(MessageSource.class);
Object[] args = {"A-1"};
t.line("with an argument: doubled -> %s", ms.getMessage("apos.doubled", args, EN));
t.line("with an argument: single -> %s", ms.getMessage("apos.single", args, EN));
t.line("without arguments: doubled -> %s", ms.getMessage("apos.doubled", null, EN));
t.line("without arguments: single -> %s", ms.getMessage("apos.single", null, EN));
assertThat(ms.getMessage("apos.doubled", null, EN)).doesNotContain("''");
}
}
@Test
void devanagariDigits() {
try (var app = RunningApp.start(""); var t = new Transcript("05-devanagari-digits.txt",
"GET /api/cart?items=1234567 in three languages, and two ways to keep Latin digits")) {
for (String header : new String[]{"en", "hi", "mr"}) {
var r = Http.get(app.port(), "/api/cart?items=1234567", "Accept-Language", header);
t.line("Accept-Language: %-3s -> %s", header, Json.text(r, "message"));
}
t.section("fix 1: the client asks for Latin digits (Unicode extension -u-nu-latn)");
var ext = Http.get(app.port(), "/api/cart?items=1234567", "Accept-Language", "mr-IN-u-nu-latn");
t.line("Accept-Language: mr-IN-u-nu-latn -> locale=%s %s", Json.text(ext, "locale"), Json.text(ext, "message"));
t.section("fix 2: the server formats the number itself and passes text (GET /api/cart-latin)");
var latin = Http.get(app.port(), "/api/cart-latin?items=1234567", "Accept-Language", "mr");
t.line("Accept-Language: mr -> %s", Json.text(latin, "message"));
assertThat(Json.text(Http.get(app.port(), "/api/cart?items=1234567", "Accept-Language", "hi"), "message")).contains("1,234,567");
assertThat(Json.text(Http.get(app.port(), "/api/cart?items=1234567", "Accept-Language", "mr"), "message")).doesNotContain("1,234,567");
assertThat(Json.text(latin, "message")).contains("1,234,567").doesNotContain("१");
}
}
@Test
void missingAndPartialTranslations() {
try (var app = RunningApp.start(""); var t = new Transcript("06-missing-keys.txt",
"A key that exists only in English, and a key that exists nowhere")) {
MessageSource ms = app.context().getBean(MessageSource.class);
t.line("only.english.key, locale mr -> %s", ms.getMessage("only.english.key", null, MR));
assertThat(ms.getMessage("only.english.key", null, MR)).isEqualTo("This sentence has not been translated yet.");
assertThatThrownBy(() -> ms.getMessage("no.such.key", null, MR)).isInstanceOf(NoSuchMessageException.class)
.satisfies(e -> t.line("no.such.key, locale mr -> %s: %s", e.getClass().getName(), e.getMessage()));
}
}
@Test
void wrongEncoding() {
try (var t = new Transcript("07-properties-encoding.txt",
"The same Marathi greeting, with Spring Boot's default encoding and with spring.messages.encoding=ISO-8859-1")) {
try (var app = RunningApp.start("")) {
var r = Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", "mr");
t.line("default (UTF-8): %s", Json.text(r, "message"));
assertThat(Json.text(r, "message")).isEqualTo("नमस्कार, Asha!");
}
// ResourceBundle caches loaded bundles JVM-wide, so a second application in this JVM would reuse the UTF-8 read above.
ResourceBundle.clearCache();
try (var app = RunningApp.start("", "spring.messages.encoding=ISO-8859-1")) {
var r = Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", "mr");
t.line("ISO-8859-1: %s", Json.text(r, "message"));
assertThat(Json.text(r, "message")).isNotEqualTo("नमस्कार, Asha!");
}
}
}
}
@@ -0,0 +1,63 @@
package com.ankurm.i18n;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.JsonNode;
import static org.assertj.core.api.Assertions.assertThat;
/** Transcripts 08 and 09: validation messages and ProblemDetail, in four languages. */
class ProblemDetailTests {
private static final String INVALID = "{\"name\":\"\",\"email\":\"x\",\"age\":10}";
private static void problem(Transcript t, Http.Response r) {
JsonNode body = Json.read(r);
t.line("status=%d Content-Type=%s", r.status(), r.contentType());
t.line("title: %s", body.path("title").asString());
t.line("detail: %s", body.path("detail").asString());
if (body.has("errors")) {
for (JsonNode error : body.path("errors")) {
t.line(" error: %s", error.asString());
}
}
}
@Test
void validationMessages() {
try (var app = RunningApp.start(""); var t = new Transcript("08-validation-messages.txt",
"POST /api/customers with an invalid body, four Accept-Language values")) {
t.line("body: %s", INVALID);
for (String header : new String[]{"en", "mr", "hi", "fr"}) {
t.section("Accept-Language: " + header);
problem(t, Http.post(app.port(), "/api/customers", INVALID, "Accept-Language", header));
}
var mr = Json.read(Http.post(app.port(), "/api/customers", INVALID, "Accept-Language", "mr"));
assertThat(mr.path("title").asString()).isEqualTo("पडताळणी अयशस्वी");
var fr = Json.read(Http.post(app.port(), "/api/customers", INVALID, "Accept-Language", "fr"));
assertThat(fr.path("errors").toString()).contains("ne doit pas être nul").contains("Name is required.");
}
}
@Test
void problemDetailTitlesAndDetails() {
try (var app = RunningApp.start(""); var t = new Transcript("09-problemdetail-localized.txt",
"Three kinds of exception: an ErrorResponse, an ordinary exception handled through the MessageSource, and one handled with English text in the source")) {
String[][] cases = {
{"an ErrorResponse (OrderNotFoundException)", "/api/orders/7"},
{"an ordinary exception, translated in the handler (PaymentDeclinedException)", "/api/pay"},
{"an ordinary exception, English in the handler (LegacyStateException)", "/api/legacy"}};
for (String[] c : cases) {
t.section(c[0]);
for (String header : new String[]{"en", "mr", "hi"}) {
var r = Http.get(app.port(), c[1], "Accept-Language", header);
t.line("Accept-Language: %s", header);
problem(t, r);
}
}
var notFound = Json.read(Http.get(app.port(), "/api/orders/7", "Accept-Language", "mr"));
assertThat(notFound.path("detail").asString()).isEqualTo("७ क्रमांकाची ऑर्डर अस्तित्वात नाही.");
var legacy = Json.read(Http.get(app.port(), "/api/legacy", "Accept-Language", "mr"));
assertThat(legacy.path("title").asString()).isEqualTo("Conflict");
}
}
}
@@ -0,0 +1,26 @@
package com.ankurm.i18n;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.server.context.WebServerApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
/** The whole application on a random port, started with the profiles and properties one scenario needs. */
public record RunningApp(ConfigurableApplicationContext context, int port) implements AutoCloseable {
public static RunningApp start(String profiles, String... properties) {
var builder = new SpringApplicationBuilder(I18nApp.class).web(WebApplicationType.SERVLET)
.properties("logging.level.root=OFF");
if (!profiles.isEmpty()) {
builder.profiles(profiles.split(","));
}
builder.properties(properties);
ConfigurableApplicationContext ctx = builder.run();
return new RunningApp(ctx, ((WebServerApplicationContext) ctx).getWebServer().getPort());
}
@Override
public void close() {
context.close();
}
}
@@ -0,0 +1,50 @@
package com.ankurm.i18n;
import org.junit.jupiter.api.Test;
import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Transcript 02. Changes the JVM's default locale, and Tomcat reads that default once per JVM, so this class runs in its own
* JVM (surefire reuseForks=false) and cannot leak a Hindi default into the "no Accept-Language header" row of transcript 01.
*/
class SystemLocaleFallbackTests {
private static String describe(Http.Response r) {
return "status=" + r.status() + " locale=" + Json.text(r, "locale") + " message=" + Json.text(r, "message");
}
@Test
void fallbackToTheSystemLocale() {
Locale original = Locale.getDefault();
try (var t = new Transcript("02-fallback-to-system-locale.txt",
"The JVM's default locale is Hindi: what does a French or an English request get?")) {
Locale.setDefault(Locale.forLanguageTag("hi"));
t.line("Locale.getDefault() while these ran: %s", Locale.getDefault());
t.section("spring.messages.fallback-to-system-locale left at its default");
try (var app = RunningApp.start("")) {
for (String header : new String[]{"fr", "en"}) {
var r = Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", header);
t.line("Accept-Language: %-3s -> %s", header, describe(r));
}
assertThat(Json.text(Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", "fr"), "message"))
.isEqualTo("नमस्ते, Asha!");
}
t.section("spring.messages.fallback-to-system-locale=false");
try (var app = RunningApp.start("", "spring.messages.fallback-to-system-locale=false")) {
for (String header : new String[]{"fr", "en"}) {
var r = Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", header);
t.line("Accept-Language: %-3s -> %s", header, describe(r));
}
assertThat(Json.text(Http.get(app.port(), "/api/greeting?name=Asha", "Accept-Language", "fr"), "message"))
.isEqualTo("Hello, Asha!");
}
} finally {
Locale.setDefault(original);
}
}
}
@@ -0,0 +1,54 @@
package com.ankurm.i18n;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} and echoes it to the console.
* Every console block quoted in the article comes out of one of these files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
public Transcript section(String heading) {
out.println();
out.println("--- " + heading + " ---");
return this;
}
@Override
public void close() {
out.flush();
// Absolute paths of whoever ran the build are environment noise, not a finding.
String text = buffer.toString().replace(System.getProperty("user.dir"), "<i18n>");
try {
Files.createDirectories(path.getParent());
Files.writeString(path, text);
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(text);
}
}