Skip to content

Latest commit

 

History

27 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

JSON MessageSource for Spring

This package provides a MessageSource for using translations from JSON files.

Quality Gate Status Maven Central

Table of Contents

Dependency

Maven

<dependency>
    <groupId>io.github.alaugks</groupId>
    <artifactId>spring-messagesource-json</artifactId>
    <version>2.1.0</version>
</dependency>

Gradle

implementation group: 'io.github.alaugks', name: 'spring-messagesource-json', version: '2.1.0'

MessageSource Configuration

builder(Locale defaultLocale, String locationPattern) (required)
builder(Locale defaultLocale, List<String> locationPattern) (required)

  • Argument Locale defaultLocale: Defines the default locale.
  • Argument locationPattern (a single pattern or a list of patterns):
    • Defines the pattern(s) used to select the JSON files.
    • The package uses the PathMatchingResourcePatternResolver to select the JSON files. So you can use the supported patterns.
    • Files with the extension json are filtered from the result list.

enableICU4j()

parentMessageSource(MessageSource messageSource)

  • Sets a parent MessageSource to delegate to. When a code cannot be resolved from the JSON files, the lookup falls back to the parent source. See Parent MessageSource for usage in either order.

targetLocaleResolver(TargetLocaleResolverInterface targetLocaleResolver)

jsonCatalog(JsonCatalogInterface jsonCatalog)

  • Overrides how the JSON files are parsed into translation codes and values. By default, each file's top-level keys are read as a flat code → value map (see JSON Files). See Custom JSON Catalog for details and an example.

Example

  • Default locale is en.
  • The JSON files are stored in src/main/resources/translations.
import io.github.alaugks.spring.messagesource.json.JsonResourceMessageSource;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Locale;

@Configuration
public class MessageSourceConfig {

    @Bean
    public MessageSource messageSource() {
       return JsonResourceMessageSource
               .builder(
                   Locale.forLanguageTag("en"),
                   "translations/*" // or List.of(...)
               )
               .build();
    }

}

JSON Files

  • Translations can be split across multiple files; the key is always taken from the JSON key itself, the filename has no effect on it. Since the key is what's looked up, keys must be unique across all files.
  • Translation files must be stored in the resource folder and have the extension json.

Structure of the Translation Filename

The <name> part is freely choosable and has no functional meaning; it's not used to derive keys and files aren't otherwise linked by it (see JSON Files). What matters is that the locale is recognised as a suffix of the filename:

# Default language
<name>.json    // <name>_<language>.json also works.

# Name + Language
<name>[-_]<language>.json

# Name + Language + Region
<name>[-_]<language>[-_]<region>.json

Example with JSON Files

  • Default locale is en without region.
  • Translations are provided for the locale en, de and en-US.
[resources]
     |-[translations]
             |-messages.json   // messages_en.json also works.
             |-messages_de.json
             |-messages_en-US.json

JSON Files

Tip

Translations can be organized across multiple JSON files however you like (e.g. by feature or module); this example keeps everything in one file per locale. Only requirement: keys must be unique across all files, since it is the key.

messages.json
{
  "headline": "Headline",
  "postcode": "Postcode",
  "payment.headline": "Payment",
  "payment.expiry_date": "Expiry date"
}
messages_de.json
{
  "headline": "Überschrift",
  "postcode": "Postleitzahl",
  "payment.headline": "Zahlung",
  "payment.expiry_date": "Ablaufdatum"
}
messages_en-US.json
{
  "postcode": "Zip code",
  "payment.expiry_date": "Expiration date"
}

Target value

The behaviour of resolving the target value based on the code is equivalent to the ResourceBundleMessageSource or ReloadableResourceBundleMessageSource.

id (code) en en-US de jp**
headline Headline Headline* Überschrift Headline
postcode Postcode Zip code Postleitzahl Postcode
payment.headline Payment Payment* Zahlung Payment
payment.expiry_date Expiry date Expiration date Ablaufdatum Expiry date

*Example of a fallback from Language_Region (en-US) to Language (en). The id does not exist in en-US, so it tries to select the translation with locale en.

**There is no translation for Japanese (jp). The default locale translations (en) are selected.

Custom Target Locale Resolver

By default, the locale of a JSON file is derived from its filename (see Structure of the Translation Filename). Pass a custom TargetLocaleResolverInterface implementation to targetLocaleResolver(...) to derive it differently instead, e.g. from a field inside the JSON file itself.

messages_de.json
{
  "targetLocale": "de",
  "postcode": "Postleitzahl",
  "payment.headline": "Zahlung"
}
@Bean
public MessageSource messageSource() {
    return JsonResourceMessageSource
            .builder(
                Locale.forLanguageTag("en"),
                "translations/*"
            )
            .targetLocaleResolver(resource -> {
                try (InputStream inputStream = resource.getInputStream()) {
                    JsonNode json = new ObjectMapper().readTree(inputStream);
                    Locale locale = Locale.forLanguageTag(json.path("targetLocale").asText());
                    return new TransFileTargetLocale(locale);
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            })
            .build();
}

Note

targetLocale is only read to resolve the locale, it is not removed from the file. Since a JSON file is loaded as a flat code → value map (see JSON Files), targetLocale also shows up as an (unused) message code with the value de.

Custom JSON Catalog

By default, a JSON file's top-level keys are read as a flat code → value map (see JSON Files). Pass a custom JsonCatalogInterface implementation to jsonCatalog(...) to parse the files differently instead, e.g. to nest the translations under their own key and keep metadata, such as targetLocale, out of the message codes.

messages_de.json
{
  "targetLocale": "de",
  "translation": {
    "postcode": "Postleitzahl",
    "payment.headline": "Zahlung"
  }
}
@Bean
public MessageSource messageSource() {
    return JsonResourceMessageSource
            .builder(
                Locale.forLanguageTag("en"),
                "translations/*"
            )
            .targetLocaleResolver(resource -> {
                try (InputStream inputStream = resource.getInputStream()) {
                    JsonNode json = new ObjectMapper().readTree(inputStream);
                    Locale locale = Locale.forLanguageTag(json.path("targetLocale").asText());
                    return new TransFileTargetLocale(locale);
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            })
            .jsonCatalog(translationFiles -> {
                List<TransUnitInterface> transUnits = new ArrayList<>();
                ObjectMapper objectMapper = new ObjectMapper();

                for (TransFileInterface file : translationFiles) {
                    try {
                        JsonNode translation = objectMapper.readTree(file.content()).path("translation");
                        translation.properties().forEach(entry -> transUnits.add(
                            new TransUnit(file.locale(), entry.getKey(), entry.getValue().asText())
                        ));
                    } catch (IOException e) {
                        throw new UncheckedIOException(e);
                    }
                }

                return transUnits;
            })
            .build();
}

Combined with the Custom Target Locale Resolver above, targetLocale now only drives the locale and no longer leaks into the message codes, since the catalog only reads the translation node.

Note

JsonCatalogInterface has a single method, getTransUnits(List<TransFileInterface>), so — like TargetLocaleResolverInterface — it can be implemented as a lambda or as a standalone class.

Message Formatting

A resolved value is formatted before it is returned, applying the arguments passed to getMessage(...) to the message pattern. Two formatters are available: the default java.text.MessageFormat and, once enableICU4j() is set, com.ibm.icu.text.MessageFormat.

Important

Named arguments and ICU plural/select patterns (e.g. {count, plural, …}) cannot be resolved by the default java.text.MessageFormat and fail at getMessage() time. To use them you must enable ICU4J via enableICU4j().

ICU4J is the com.ibm.icu:icu4j dependency, which is shipped transitively with this package — no extra dependency is required. Its com.ibm.icu.text.MessageFormat is a syntax superset of java.text.MessageFormat, so existing numeric-index patterns keep working.

Note that the two are not fully output-compatible: ICU4J uses Unicode CLDR locale data, so the formatted result for a given locale can differ from the JDK's — for example the decimal and grouping separators in numbers (. vs ,). Verify locale-sensitive output after enabling ICU4J.

Default (java.text.MessageFormat)

Without enableICU4j(), values are formatted with java.text.MessageFormat — the same formatter Spring's ResourceBundleMessageSource uses. It only understands numeric argument indices ({0}, {1}, …), passed positionally as an Object[]. Numbers are formatted locale-aware (grouping separators differ per locale).

messages.json

{
  "files": "There are {0,number,integer} files."
}

messages_de.json

{
  "files": "Es gibt {0,number,integer} Dateien."
}
messageSource.getMessage(
    "files",
    new Object[] { 10000 },
    Locale.forLanguageTag("de")
);

Result: Es gibt 10.000 Dateien.

ICU4J (com.ibm.icu.text.MessageFormat)

Enable ICU4J on the builder to format with ICU4J's MessageFormat:

@Bean
public MessageSource messageSource() {
    return JsonResourceMessageSource
            .builder(
                Locale.forLanguageTag("en"),
                "translations/*" // or List.of(...)
            )
            .enableICU4j() // required for named arguments and plural/select
            .build();
}

With ICU4J enabled, patterns can use named arguments and the ICU plural/select constructs. Named arguments are passed as a single Map (not as positional {0} / {1} arguments); the underlying catalog detects a lone Map argument and formats the pattern with it.

Plural

A plural switch selects a variant based on a number. Each case is either an exact number — matched as =N — or a CLDR plural keyword (zero, one, two, few, many, other) that the locale's plural rules select from the number. The number itself is inserted into a case by referencing the argument name, {count}.

Which keywords a language uses, and how each number maps to one, is defined per language in the Unicode CLDR Language Plural Rules.

messages.json
{
  "file_deleted": "{count, plural, =0 {You deleted no files.} =1 {You deleted one file.} other {You deleted {count} files.}}"
}
messages_de.json
{
  "file_deleted": "{count, plural, =0 {Sie haben keine Dateien gelöscht.} =1 {Sie haben eine Datei gelöscht.} other {Sie haben {count} Dateien gelöscht.}}"
}
messageSource.getMessage(
    "file_deleted",
    new Object[] { Map.of("count", 1000) },
    Locale.forLanguageTag("de")
);

Result: Sie haben 1.000 Dateien gelöscht.

Select (and gender)

A select switch picks the case whose value matches the argument. Use it for any value-based choice such as grammatical gender; a final other case acts as the fallback.

messages.json
{
  "greeting": "{recipient_gender, select, feminine {How is she?} masculine {How is he?} other {How are they?}}"
}
messages_de.json
{
  "greeting": "{recipient_gender, select, feminine {Wie geht es ihr?} masculine {Wie geht es ihm?} other {Wie geht es ihnen?}}"
}
messageSource.getMessage(
    "greeting",
    new Object[] { Map.of("recipient_gender", "feminine") },
    Locale.forLanguageTag("de")
);

Result: Wie geht es ihr?

Full Example

https://github.com/alaugks/spring-messagesource-json-example

Related MessageSources and Examples

License

Licensed under the Apache License, Version 2.0.

About

This package provides a MessageSource for using translations from JSON files.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages