Fork me on GitHub

I18N strings

The library provides classes for internationalized strings, compatibles with Java Beans model. The core interface, I18nString provides methods to obtain the string current user locale or in an specified locale.

Beans that contain potentially internationalized strings can use the interface as property type:

interface MyBean {
    I18nString getDescription();
}
class MyBeanImpl {
    private I18nString description;
    public I18nString getDescription() {
        return this.description;
    }
    public void setDescription(I18nString value) {
        this.description = value;
    }
}

The I18nString interface provides methods to retrieve the String values for specific languages or for the I18N context's language:

I18nString value ...;
value.get(); // Returns the text for current language
value.toString(); // Returns the default text
value.get(lang); // Returns the text for specified language
value.get(locale); // Returns the text for specified language

This allows defining data models and APIs that declare potentially internationalized texts without exposing and/or conditioning the implementation details.

Implementations

Three implementations are provided “out of the box”:

classDiagram
    class I18nString {
        <<interface>>
        get() String
        get(String language) String
        get(Locale locale) String
    }
    class I18nFixedString
    class I18nResourcesString
    class I18nStringMap
    I18nString <|.. I18nFixedString
    I18nString <|.. I18nResourcesString
    I18nString <|.. I18nStringMap

I18nFixedString

Immutable implementation that contains a single shared string for all languages. To be used when a I18N string is declared (in the API, for example) but it's known that there is no I18N support for the value.

Use its factory method, I18nFixedString.from(String) (or the shortcut I18nString.fixed(String)) to create new instances:

I18nString description = I18nString.fixed("This text has no translation");

I18nResourcesString

Immutable implementation based on the application's I18N resources that lazily retrieves text for the desired language.

Its factory method, I18nResourcesString.forDefault(String) (or the shortcut I18nString.fromResources(String)), returns a builder that, after calling build(), creates an immutable instance.

I18nString description = I18nString.fromResources("Default text")
        .ofResources("my-alt-resources") // Optional, named I18N resources key
        .withCode("my.app.i18n.resource") // Required I18N resources code
        .withCode("my.app.i18n.alt.resource") // Optional, fallback I18N resources codes
        .withArg(my) // Optional, arguments to apply to resulting texts
        .build;

This implementation can be used, for example, to provide user friendly enumeration constant names or descriptions:

enum MyDiscreteType {
    ...
    CONSTANT_A("CONSTANT_A", "my.app.MyDiscreteType.CONSTANT_A"),
    ...
    ;
    private final I18nString description;
    private MyDiscreteType(String defaultDesc, String i18nCode) {
        this.description = I18nString.fromResources(defaultDesc)
            .withCode(i18nCode)
            .build();
    }
    public String getCode() {
        return this.name();
    }
    public I18nString getDescription() {
        return this.description;
    }

I18nStringMap

Mutable map-based implementation. Can be used to provide the complete I18N configuration of, for example, DB backed entities.

Its constructor, I18nStringMap(String) (or the shortcut I18nString.mapped(String)), creates an instance that accepts method chaining to set available translations:

I18nString description = I18nString.mapped("Default text")
        .set(Locale.FRENCH, "French translation")
        .set("de", "German translation");

Validation

Jakarta EE validation support is provided out of the box for the following constraints:

  • javax.validation.constraints.NotEmpty
  • javax.validation.constraints.NotBlank
  • javax.validation.constraints.Size
  • javax.validation.constraints.Pattern

For all constraints, if the I18N string is a I18nStringMap both default text and defined translations are validated. Otherwise only the current language's translation (I18nString.get()) is validated.

Jackson support

Support is provided for I18N texts serialization and deserialization using Jackson out-of-the-box.

By default the instances are serialized as a string with the translation for current I18N context:

class MyBean {
    private I18nString text;
    // Getter and setter
}

I18N.setLocale(Locale.FRENCH);
MyBean bean = new MyBean();

mapper.writeValueAsString(bean);
// { "text": null }

bean.setText(fixedI18nString);
mapper.writeValueAsString(bean);
// { "text": "Fixed text" }

bean.setText(resourcesI18nString);
mapper.writeValueAsString(bean);
// { "text": "French translation" }

bean.setText(mappedI18nString);
mapper.writeValueAsString(bean);
// { "text": "French translation" }

To serialize an instance as a JSON object with all the available translations simply add the Jackson @JsonFormat(shape = JsonFormat.Shape.OBJECT) annotation:

class MyBean {
    @JsonFormat(shape = JsonFormat.Shape.OBJECT)
    private I18nString text;
    // Getter and setter
}

I18N.setLocale(Locale.FRENCH);
MyBean bean = new MyBean();

mapper.writeValueAsString(bean);
// { "text": null }

bean.setText(fixedI18nString);
mapper.writeValueAsString(bean);
// {
//   "text": {
//     "defaultText": "Fixed text",
//     "i18n": {}
// }

bean.setText(resourcesI18nString);
mapper.writeValueAsString(bean);
// {
//   "text": {
//     "defaultText": "Default text",
//     "i18n": {
//       "fr": "French translation"
//     }
// }

bean.setText(mappedI18nString);
mapper.writeValueAsString(bean);
// {
//   "text": {
//     "defaultText": "Default text",
//     "i18n": {
//       "fr": "French translation",
//       "de": "German translation",
//     }
// }

Note that when serialized as JSON objects fixed I18N strings provide an empty i18n object and application resources based instances provide an i18n object with a single translation for the current locale. The later behavior is due to the fact that I18N resources don't expose the list of available languages.

During deserialization a fixed I18N string is created for text JSON nodes and a mapped I18N string for object JSON nodes:

class MyBean {
    private I18nString text;
    // Getter and setter
}

MyBean bean = new MyBean();

// { "text": null }
assertNull(mapper.readValue(json, I18nString.class).getText());

// { "text": "Some text" }
assertEquals(
        I18nString.fixed("Some text"),
        mapper.readValue(json, I18nString.class).getText());

// {
//   "text": {
//     "defaultText": "Default text",
//     "i18n": {
//       "fr": "French translation",
//       "de": "German translation",
//     }
// }
assertEquals(
        I18nString.mapped("Default text")
            .set("fr", "French translation")
            .set("de", "German translation");),
        mapper.readValue(json, I18nString.class).getText());

JSON Schema with I18N types is provided at https://orne-dev.github.io/java-i18n/schema.json.

To define a I18n string property just add a reference to https://orne-dev.github.io/java-i18n/schema.json#string in your JSON schema:

{
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://example.com/schemas/bean",
    "type": "object",
    "properties": {
        "text": { "$ref": "https://orne-dev.github.io/java-i18n/schema.json#string" }
    }
}

JAXB support

Support is provided for I18N texts serialization and deserialization using JAXB out-of-the-box.

XML Schema with I18N types is provided at https://orne-dev.github.io/java-i18n/schema.xsd.

By default the instances are serialized as a string with the translation for current I18N context:

@XmlRootElement(namespace=MyBean.NS, name=MyBean.ROOT)
class MyBean {
    public static final String NS = "http://orne.dev/example";
    public static final String ROOT = "bean";
    public static final String TEXT = "text";
    @XmlElement(namespace=NS, name=TEXT)
    private I18nString text;
    // Getter and setter
}

I18N.setLocale(Locale.FRENCH);
MyBean bean = new MyBean();

marshaller.marshal(bean, out);
// <bean xmlns="http://orne.dev/example" xmlns:i18n="http://orne.dev/i18n/">
//   <text />
// </bean>

bean.setText(fixedI18nString);
marshaller.marshal(bean, out);
// <bean xmlns="http://orne.dev/example" xmlns:i18n="http://orne.dev/i18n/">
//   <text>Fixed text</text>
// </bean>

bean.setText(resourcesI18nString);
marshaller.marshal(bean, out);
// <bean xmlns="http://orne.dev/example" xmlns:i18n="http://orne.dev/i18n/">
//   <text>French translation</text>
// </bean>

bean.setText(mappedI18nString);
marshaller.marshal(bean, out);
// <bean xmlns="http://orne.dev/example" xmlns:i18n="http://orne.dev/i18n/">
//   <text>French translation</text>
// </bean>

To serialize an instance with nested elements with all the available translations simply add the JAXB @XmlJavaTypeAdapter(I18nStringAdapter.Full.class) annotation:

@XmlRootElement(namespace=MyBean.NS, name=MyBean.ROOT)
class MyBean {
    public static final String NS = "http://orne.dev/example";
    public static final String ROOT = "bean";
    public static final String TEXT = "text";
    @XmlElement(namespace=NS, name=TEXT)
    @XmlJavaTypeAdapter(I18nStringAdapter.Full.class)
    private I18nString text;
    // Getter and setter
}

I18N.setLocale(Locale.FRENCH);
MyBean bean = new MyBean();

marshaller.marshal(bean, out);
// <bean xmlns="http://orne.dev/example" xmlns:i18n="http://orne.dev/i18n/">
//   <text />
// </bean>

bean.setText(fixedI18nString);
marshaller.marshal(bean, out);
// <bean xmlns="http://orne.dev/example" xmlns:i18n="http://orne.dev/i18n/">
//   <text>Fixed text</text>
// </bean>

bean.setText(resourcesI18nString);
marshaller.marshal(bean, out);
// <bean xmlns="http://orne.dev/example" xmlns:i18n="http://orne.dev/i18n/">
//   <text>
//     Default text
//     <i18n:translation lang="fr">French translation</i18n:translation>
//   </text>
// </bean>

bean.setText(mappedI18nString);
marshaller.marshal(bean, out);
// <bean xmlns="http://orne.dev/example" xmlns:i18n="http://orne.dev/i18n/">
//   <text>
//     Default text
//     <i18n:translation lang="fr">French translation</i18n:translation>
//     <i18n:translation lang="de">German translation</i18n:translation>
//   </text>
// </bean>

Note that when serialized with full translation fixed I18N strings provides no translations and application resources based instances provide a single translation for the current locale. The later behavior is due to the fact that I18N resources don't expose the list of available languages.

During deserialization a fixed I18N string is created for text nodes without translations and a mapped I18N string for elements with nested translations:

@XmlRootElement(namespace=MyBean.NS, name=MyBean.ROOT)
class MyBean {
    public static final String NS = "http://orne.dev/example";
    public static final String ROOT = "bean";
    public static final String TEXT = "text";
    @XmlElement(namespace=NS, name=TEXT)
    private I18nString text;
    // Getter and setter
}

MyBean bean = new MyBean();

// <bean xmlns="http://orne.dev/example" xmlns:i18n="http://orne.dev/i18n/">
//   <text />
// </bean>
assertNull(unmarshaller.unmarshal(reader).getText());

// <bean xmlns="http://orne.dev/example" xmlns:i18n="http://orne.dev/i18n/">
//   <text>Some text</text>
// </bean>
assertEquals(
        I18nString.fixed("Some text"),
        unmarshaller.unmarshal(reader).getText());

// <bean xmlns="http://orne.dev/example" xmlns:i18n="http://orne.dev/i18n/">
//   <text>
//     Default text
//     <i18n:translation lang="fr">French translation</i18n:translation>
//     <i18n:translation lang="de">German translation</i18n:translation>
//   </text>
// </bean>
assertEquals(
        I18nString.mapped("Default text")
            .set("fr", "French translation")
            .set("de", "German translation");),
        unmarshaller.unmarshal(reader).getText());

Orne Tests Generators support

Generation of random values of I18nString values for testing using Orne Tests Generators is supported out-of-the-box. All the following will work as expected:

Generators.randomValue(I18nString.class);
Generators.randomValue(I18nFixedString.class);
Generators.randomValue(I18nStringMap.class);
Generators.randomValue(I18nResourcesString.class);

All the generation parameters and parameters extraction available for string generation are supported for the generation of default texts and translations.

Generators.randomValue(
        I18nString.class,
        GenerationParameters.forSizes().withMinSize(5).withMaxSize(10));