Add i18n module: MessageSource, LocaleResolver and localized ProblemDetail
English, Marathi and Hindi bundles behind one small web app, with tests that run it on a real port and write the transcripts (01-11) quoted in the article, plus a jar-metadata capture (12). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
@@ -0,0 +1,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 |
|
||||||
@@ -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!
|
||||||
@@ -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.
|
||||||
@@ -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 वस्तू आहेत.
|
||||||
@@ -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'.
|
||||||
@@ -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!
|
||||||
@@ -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.
|
||||||
@@ -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!
|
||||||
@@ -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"
|
||||||
@@ -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>
|
||||||
Executable
+24
@@ -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
|
||||||
Executable
+19
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user