From 2cbf31e7d5f89734ea12188e96140621de54562e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 09:54:55 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1 --- i18n/README.md | 85 ++++++++++++ i18n/output/01-accept-language.txt | 8 ++ i18n/output/02-fallback-to-system-locale.txt | 11 ++ i18n/output/03-cookie-locale-resolver.txt | 20 +++ i18n/output/04-messageformat-apostrophes.txt | 9 ++ i18n/output/05-devanagari-digits.txt | 11 ++ i18n/output/06-missing-keys.txt | 4 + i18n/output/07-properties-encoding.txt | 4 + i18n/output/08-validation-messages.txt | 39 ++++++ i18n/output/09-problemdetail-localized.txt | 44 +++++++ i18n/output/10-always-use-message-format.txt | 6 + i18n/output/11-spring-web-locale.txt | 12 ++ i18n/output/12-configuration-metadata.txt | 31 +++++ i18n/pom.xml | 57 ++++++++ i18n/scripts/capture-facts.sh | 24 ++++ i18n/scripts/run-all.sh | 19 +++ .../main/java/com/ankurm/i18n/I18nApp.java | 7 + .../com/ankurm/i18n/config/LocaleConfig.java | 74 +++++++++++ .../ankurm/i18n/web/ApiExceptionHandler.java | 47 +++++++ .../com/ankurm/i18n/web/CustomerRequest.java | 17 +++ .../ankurm/i18n/web/LegacyStateException.java | 9 ++ .../i18n/web/OrderNotFoundException.java | 16 +++ .../i18n/web/PaymentDeclinedException.java | 16 +++ .../com/ankurm/i18n/web/ShopController.java | 68 ++++++++++ .../src/main/resources/application.properties | 5 + i18n/src/main/resources/messages.properties | 20 +++ .../src/main/resources/messages_hi.properties | 17 +++ .../src/main/resources/messages_mr.properties | 17 +++ i18n/src/test/java/com/ankurm/i18n/Http.java | 48 +++++++ i18n/src/test/java/com/ankurm/i18n/Json.java | 21 +++ .../ankurm/i18n/LocaleResolutionTests.java | 123 ++++++++++++++++++ .../java/com/ankurm/i18n/MessagesTests.java | 100 ++++++++++++++ .../com/ankurm/i18n/ProblemDetailTests.java | 63 +++++++++ .../test/java/com/ankurm/i18n/RunningApp.java | 26 ++++ .../i18n/SystemLocaleFallbackTests.java | 50 +++++++ .../test/java/com/ankurm/i18n/Transcript.java | 54 ++++++++ 36 files changed, 1182 insertions(+) create mode 100644 i18n/README.md create mode 100644 i18n/output/01-accept-language.txt create mode 100644 i18n/output/02-fallback-to-system-locale.txt create mode 100644 i18n/output/03-cookie-locale-resolver.txt create mode 100644 i18n/output/04-messageformat-apostrophes.txt create mode 100644 i18n/output/05-devanagari-digits.txt create mode 100644 i18n/output/06-missing-keys.txt create mode 100644 i18n/output/07-properties-encoding.txt create mode 100644 i18n/output/08-validation-messages.txt create mode 100644 i18n/output/09-problemdetail-localized.txt create mode 100644 i18n/output/10-always-use-message-format.txt create mode 100644 i18n/output/11-spring-web-locale.txt create mode 100644 i18n/output/12-configuration-metadata.txt create mode 100644 i18n/pom.xml create mode 100755 i18n/scripts/capture-facts.sh create mode 100755 i18n/scripts/run-all.sh create mode 100644 i18n/src/main/java/com/ankurm/i18n/I18nApp.java create mode 100644 i18n/src/main/java/com/ankurm/i18n/config/LocaleConfig.java create mode 100644 i18n/src/main/java/com/ankurm/i18n/web/ApiExceptionHandler.java create mode 100644 i18n/src/main/java/com/ankurm/i18n/web/CustomerRequest.java create mode 100644 i18n/src/main/java/com/ankurm/i18n/web/LegacyStateException.java create mode 100644 i18n/src/main/java/com/ankurm/i18n/web/OrderNotFoundException.java create mode 100644 i18n/src/main/java/com/ankurm/i18n/web/PaymentDeclinedException.java create mode 100644 i18n/src/main/java/com/ankurm/i18n/web/ShopController.java create mode 100644 i18n/src/main/resources/application.properties create mode 100644 i18n/src/main/resources/messages.properties create mode 100644 i18n/src/main/resources/messages_hi.properties create mode 100644 i18n/src/main/resources/messages_mr.properties create mode 100644 i18n/src/test/java/com/ankurm/i18n/Http.java create mode 100644 i18n/src/test/java/com/ankurm/i18n/Json.java create mode 100644 i18n/src/test/java/com/ankurm/i18n/LocaleResolutionTests.java create mode 100644 i18n/src/test/java/com/ankurm/i18n/MessagesTests.java create mode 100644 i18n/src/test/java/com/ankurm/i18n/ProblemDetailTests.java create mode 100644 i18n/src/test/java/com/ankurm/i18n/RunningApp.java create mode 100644 i18n/src/test/java/com/ankurm/i18n/SystemLocaleFallbackTests.java create mode 100644 i18n/src/test/java/com/ankurm/i18n/Transcript.java diff --git a/i18n/README.md b/i18n/README.md new file mode 100644 index 0000000..faad234 --- /dev/null +++ b/i18n/README.md @@ -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 | diff --git a/i18n/output/01-accept-language.txt b/i18n/output/01-accept-language.txt new file mode 100644 index 0000000..0de8b8b --- /dev/null +++ b/i18n/output/01-accept-language.txt @@ -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! diff --git a/i18n/output/02-fallback-to-system-locale.txt b/i18n/output/02-fallback-to-system-locale.txt new file mode 100644 index 0000000..4af5d36 --- /dev/null +++ b/i18n/output/02-fallback-to-system-locale.txt @@ -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! diff --git a/i18n/output/03-cookie-locale-resolver.txt b/i18n/output/03-cookie-locale-resolver.txt new file mode 100644 index 0000000..0a6098e --- /dev/null +++ b/i18n/output/03-cookie-locale-resolver.txt @@ -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 diff --git a/i18n/output/04-messageformat-apostrophes.txt b/i18n/output/04-messageformat-apostrophes.txt new file mode 100644 index 0000000..808f867 --- /dev/null +++ b/i18n/output/04-messageformat-apostrophes.txt @@ -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. diff --git a/i18n/output/05-devanagari-digits.txt b/i18n/output/05-devanagari-digits.txt new file mode 100644 index 0000000..7416e14 --- /dev/null +++ b/i18n/output/05-devanagari-digits.txt @@ -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 वस्तू आहेत. diff --git a/i18n/output/06-missing-keys.txt b/i18n/output/06-missing-keys.txt new file mode 100644 index 0000000..0ef20f9 --- /dev/null +++ b/i18n/output/06-missing-keys.txt @@ -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'. diff --git a/i18n/output/07-properties-encoding.txt b/i18n/output/07-properties-encoding.txt new file mode 100644 index 0000000..14f97ec --- /dev/null +++ b/i18n/output/07-properties-encoding.txt @@ -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! diff --git a/i18n/output/08-validation-messages.txt b/i18n/output/08-validation-messages.txt new file mode 100644 index 0000000..dfa0714 --- /dev/null +++ b/i18n/output/08-validation-messages.txt @@ -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. diff --git a/i18n/output/09-problemdetail-localized.txt b/i18n/output/09-problemdetail-localized.txt new file mode 100644 index 0000000..6dd17c1 --- /dev/null +++ b/i18n/output/09-problemdetail-localized.txt @@ -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. diff --git a/i18n/output/10-always-use-message-format.txt b/i18n/output/10-always-use-message-format.txt new file mode 100644 index 0000000..6cedaa2 --- /dev/null +++ b/i18n/output/10-always-use-message-format.txt @@ -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. diff --git a/i18n/output/11-spring-web-locale.txt b/i18n/output/11-spring-web-locale.txt new file mode 100644 index 0000000..1028f7c --- /dev/null +++ b/i18n/output/11-spring-web-locale.txt @@ -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! diff --git a/i18n/output/12-configuration-metadata.txt b/i18n/output/12-configuration-metadata.txt new file mode 100644 index 0000000..83962da --- /dev/null +++ b/i18n/output/12-configuration-metadata.txt @@ -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 + default: ["messages"] +spring.messages.cache-duration + type: java.time.Duration + default: (none) +spring.messages.common-messages + type: java.util.List + 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" diff --git a/i18n/pom.xml b/i18n/pom.xml new file mode 100644 index 0000000..8cc853f --- /dev/null +++ b/i18n/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + i18n + 1.0.0 + i18n + Internationalization in Spring Boot 4: MessageSource, LocaleResolver, validation messages and localized ProblemDetail + + + 25 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + false + -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Duser.language=en -Duser.country=US + + + + + diff --git a/i18n/scripts/capture-facts.sh b/i18n/scripts/capture-facts.sh new file mode 100755 index 0000000..4f3ce37 --- /dev/null +++ b/i18n/scripts/capture-facts.sh @@ -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 diff --git a/i18n/scripts/run-all.sh b/i18n/scripts/run-all.sh new file mode 100755 index 0000000..1e5eebf --- /dev/null +++ b/i18n/scripts/run-all.sh @@ -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 diff --git a/i18n/src/main/java/com/ankurm/i18n/I18nApp.java b/i18n/src/main/java/com/ankurm/i18n/I18nApp.java new file mode 100644 index 0000000..9d3ecf5 --- /dev/null +++ b/i18n/src/main/java/com/ankurm/i18n/I18nApp.java @@ -0,0 +1,7 @@ +package com.ankurm.i18n; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class I18nApp { +} diff --git a/i18n/src/main/java/com/ankurm/i18n/config/LocaleConfig.java b/i18n/src/main/java/com/ankurm/i18n/config/LocaleConfig.java new file mode 100644 index 0000000..1c2347f --- /dev/null +++ b/i18n/src/main/java/com/ankurm/i18n/config/LocaleConfig.java @@ -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()); + } + } +} diff --git a/i18n/src/main/java/com/ankurm/i18n/web/ApiExceptionHandler.java b/i18n/src/main/java/com/ankurm/i18n/web/ApiExceptionHandler.java new file mode 100644 index 0000000..d545d82 --- /dev/null +++ b/i18n/src/main/java/com/ankurm/i18n/web/ApiExceptionHandler.java @@ -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 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; + } +} diff --git a/i18n/src/main/java/com/ankurm/i18n/web/CustomerRequest.java b/i18n/src/main/java/com/ankurm/i18n/web/CustomerRequest.java new file mode 100644 index 0000000..403dd55 --- /dev/null +++ b/i18n/src/main/java/com/ankurm/i18n/web/CustomerRequest.java @@ -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) { +} diff --git a/i18n/src/main/java/com/ankurm/i18n/web/LegacyStateException.java b/i18n/src/main/java/com/ankurm/i18n/web/LegacyStateException.java new file mode 100644 index 0000000..e72f741 --- /dev/null +++ b/i18n/src/main/java/com/ankurm/i18n/web/LegacyStateException.java @@ -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"); + } +} diff --git a/i18n/src/main/java/com/ankurm/i18n/web/OrderNotFoundException.java b/i18n/src/main/java/com/ankurm/i18n/web/OrderNotFoundException.java new file mode 100644 index 0000000..a65d1c4 --- /dev/null +++ b/i18n/src/main/java/com/ankurm/i18n/web/OrderNotFoundException.java @@ -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}); + } +} diff --git a/i18n/src/main/java/com/ankurm/i18n/web/PaymentDeclinedException.java b/i18n/src/main/java/com/ankurm/i18n/web/PaymentDeclinedException.java new file mode 100644 index 0000000..577f0d6 --- /dev/null +++ b/i18n/src/main/java/com/ankurm/i18n/web/PaymentDeclinedException.java @@ -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; + } +} diff --git a/i18n/src/main/java/com/ankurm/i18n/web/ShopController.java b/i18n/src/main/java/com/ankurm/i18n/web/ShopController.java new file mode 100644 index 0000000..e8fefb5 --- /dev/null +++ b/i18n/src/main/java/com/ankurm/i18n/web/ShopController.java @@ -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 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 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 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 order(@PathVariable long id) { + if (id != 1) { + throw new OrderNotFoundException(id); + } + return Map.of("id", id); + } + + @PostMapping("/api/customers") + @ResponseStatus(HttpStatus.CREATED) + Map 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(); + } +} diff --git a/i18n/src/main/resources/application.properties b/i18n/src/main/resources/application.properties new file mode 100644 index 0000000..1f9205d --- /dev/null +++ b/i18n/src/main/resources/application.properties @@ -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. diff --git a/i18n/src/main/resources/messages.properties b/i18n/src/main/resources/messages.properties new file mode 100644 index 0000000..c77822e --- /dev/null +++ b/i18n/src/main/resources/messages.properties @@ -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. diff --git a/i18n/src/main/resources/messages_hi.properties b/i18n/src/main/resources/messages_hi.properties new file mode 100644 index 0000000..c94db9d --- /dev/null +++ b/i18n/src/main/resources/messages_hi.properties @@ -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=खाली नहीं होना चाहिए। diff --git a/i18n/src/main/resources/messages_mr.properties b/i18n/src/main/resources/messages_mr.properties new file mode 100644 index 0000000..1687d32 --- /dev/null +++ b/i18n/src/main/resources/messages_mr.properties @@ -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=रिक्त नसावे. diff --git a/i18n/src/test/java/com/ankurm/i18n/Http.java b/i18n/src/test/java/com/ankurm/i18n/Http.java new file mode 100644 index 0000000..97d407d --- /dev/null +++ b/i18n/src/test/java/com/ankurm/i18n/Http.java @@ -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 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 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); + } + } +} diff --git a/i18n/src/test/java/com/ankurm/i18n/Json.java b/i18n/src/test/java/com/ankurm/i18n/Json.java new file mode 100644 index 0000000..59bd600 --- /dev/null +++ b/i18n/src/test/java/com/ankurm/i18n/Json.java @@ -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(); + } +} diff --git a/i18n/src/test/java/com/ankurm/i18n/LocaleResolutionTests.java b/i18n/src/test/java/com/ankurm/i18n/LocaleResolutionTests.java new file mode 100644 index 0000000..78e7a51 --- /dev/null +++ b/i18n/src/test/java/com/ankurm/i18n/LocaleResolutionTests.java @@ -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(); + } + } +} diff --git a/i18n/src/test/java/com/ankurm/i18n/MessagesTests.java b/i18n/src/test/java/com/ankurm/i18n/MessagesTests.java new file mode 100644 index 0000000..0f08cd2 --- /dev/null +++ b/i18n/src/test/java/com/ankurm/i18n/MessagesTests.java @@ -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!"); + } + } + } +} diff --git a/i18n/src/test/java/com/ankurm/i18n/ProblemDetailTests.java b/i18n/src/test/java/com/ankurm/i18n/ProblemDetailTests.java new file mode 100644 index 0000000..750d1bb --- /dev/null +++ b/i18n/src/test/java/com/ankurm/i18n/ProblemDetailTests.java @@ -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"); + } + } +} diff --git a/i18n/src/test/java/com/ankurm/i18n/RunningApp.java b/i18n/src/test/java/com/ankurm/i18n/RunningApp.java new file mode 100644 index 0000000..dfe4584 --- /dev/null +++ b/i18n/src/test/java/com/ankurm/i18n/RunningApp.java @@ -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(); + } +} diff --git a/i18n/src/test/java/com/ankurm/i18n/SystemLocaleFallbackTests.java b/i18n/src/test/java/com/ankurm/i18n/SystemLocaleFallbackTests.java new file mode 100644 index 0000000..4f475e9 --- /dev/null +++ b/i18n/src/test/java/com/ankurm/i18n/SystemLocaleFallbackTests.java @@ -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); + } + } +} diff --git a/i18n/src/test/java/com/ankurm/i18n/Transcript.java b/i18n/src/test/java/com/ankurm/i18n/Transcript.java new file mode 100644 index 0000000..ce0bf36 --- /dev/null +++ b/i18n/src/test/java/com/ankurm/i18n/Transcript.java @@ -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"), ""); + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, text); + } catch (IOException e) { + throw new IllegalStateException("could not write " + path, e); + } + System.out.print(text); + } +}