Fusion JSON proposes a JsonMapper API which intends to support record models.

Here the supported features list:

  • Models must be records or List<X> of a supported type, or a Map<String, X> of a supported type

  • Supported primitives are
    • String
    • String, BigDecimal (represented as string in JSON but incoming data can be a number), {b,B}oolean, {d,D}ouble, int/Integer, {l,L}ong, OffsetDateTime, ZonedDateTime, LocalDate, LocalDateTime

  • Generic mapper is supported, it will bind Object as a Map<String, Object> values being String for JSON strings, BigDecimal for JSON numbers, another Map<String, Object> for JSON objects and List<Object> for JSON lists,

  • A simple post processor prettifier (takes a JSON as input and formats it). It is used decorating the default JsonMapper: io.yupiik.fusion.json.pretty.PrettyJsonMapper,

  • The Fusion annotation processor will generate the JSON "codecs" from the code when a record is marked with @JsonModel, the codec will be reflection free,

  • You can customize the attribute names using @JsonProperty on the record members,

  • You can map all unknown attributes in a Map<String, Object> member marked with @JsonOthers annotation.

IMPORTANT

static model (annotations) are in fusion-build-api which is a provided bundle - build time only.

Runtime Dependency

<dependency>
  <groupId>${project.groupId}</groupId>
  <artifactId>fusion-json</artifactId>
  <version>${project.version}</version>
</dependency>

Example

To modelise the flow you just have to define a record marked with @JsonModel:

@JsonModel
public record MyModel(
    @JsonProperty("boolean") boolean aBool,
    BigDecimal bigDecimal,
    int integer,
    Integer nullableInt,
    long lg,
    double more,
    String simplest,
    LocalDate date,
    LocalDateTime dateTime,
    OffsetDateTime offset,
    ZonedDateTime zoned,
    Object generic,
    AnotherModel nested,
    List<Boolean> booleanList,
    List<BigDecimal> bigDecimalList,
    List<Integer> intList,
    Collection<Long> longList,
    List<Double> doubleList,
    Set<String> stringList,
    List<LocalDate> dateList,
    List<LocalDateTime> dateTimeList,
    List<OffsetDateTime> offsetList,
    List<ZonedDateTime> zonedList,
    List<Object> genericList,
    List<AnotherModel> nestedList,
    Map<String, String> mapStringString,
    Map<String, Integer> mapStringInt,
    Map<String, AnotherModel> mapNested) {
}
WARNING

BigDecimal is supported but avoid to use toBigInteger() or scale related methods without size validation due to java implementation.

Then read/write data using JsonMapper:

@Bean
public class MyService extends HttpServlet {
    @Injection
    JsonMapper mapper;

    @Override
    public void doGet(final HttpServletRequest req, final HttpServletResponse resp) throws IOException {
        try (final var out = resp.getWriter()) {
            mapper.write(createMyModelInstance(req), out);
        }
    }
}
TIP

for byte oriented sources/sinks prefer the InputStream/OutputStream flavors (mapper.read(MyModel.class, inputStream), mapper.write(instance, outputStream), both UTF-8): the mapper does the UTF-8 conversion itself with a decoder optimized for JSON payloads which is faster than an InputStreamReader/OutputStreamWriter bridge.

Enums

Enumerations (de)serialization behavior can be customized by using some specific methods:

public enum MyEnum {
    A, B;

    public String toJsonString() { (1)
        return this == A ? "first" : "second";
    }

    public static MyEnum fromJsonString(final String v) { (2)
        return switch (v) {
            case "first" -> MyEnum.A;
            case "second" -> MyEnum.B;
            default -> throw new IllegalArgumentException("Unsupported '" + v + "'");
        };
    }
}
  1. toJsonString is an instance method with no parameter used to replace .name() call during serialization,
  2. fromJsonString is a static method with a String parameter used to replace .valueOf(String) call during deserialization.

JSON-Pointer and JSON-Patch

JSON-Pointer ( https://datatracker.ietf.org/doc/html/rfc6901) and JSON-Patch ( https://www.rfc-editor.org/rfc/rfc6902.html) are available for generic types today (ie you type as Object the serialized/deserialized instances to use Map<String, Object> and List<Object> as JSON-Object/JSON-Array).

To use them, rely on GenericJsonPointer and GenericJsonPatch classes:

// is reusable
final var patch = new GenericJsonPatch(List.of(new JsonPatchOperation(add, "/baz", null, "qux")));

// "runtime"
final var object = Map.of("foo", "bar");
final var patched = patch.apply(object);
// patched={"foo":"bar","baz":"qux"}

Json Schema Validator

The Json Schema Validator can be used to validate a json content according to a json-schema ( https://json-schema.org/understanding-json-schema/reference).

@Bean
public class MyService {

    @Injection
    JsonMapper mapper;

    public boolean validate(final MyModel myModel) throws IOException {
        final var schema = mapper.fromString(Object.class,
                """
                        {
                          "$id": "https://spec.openapis.org/oas/3.1/schema/2022-10-07",
                          "$schema": "https://json-schema.org/draft/2020-12/schema",
                          "description": "The description of OpenAPI v3.1.x documents without schema validation, as defined by https://spec.openapis.org/oas/v3.1.0",
                          "type": "object"
                          ...
                        }
                      """); (1)
        final JsonSchemaValidatorFactory factory = new JsonSchemaValidatorFactory(); (2)
        final JsonSchemaValidator validator = factory.newInstance((Map<String, Object>) schema); (3)
        final var result = validator.apply(myModel); (4)
        validator.close();
        return result.isSuccess(); (5)
    }
}
  1. use the jsonMapper to load the json schema you want to use,
  2. the factory instance can be reuse and is thread safe,
  3. create a validator from the json schema loaded,
  4. apply the validator to the input model object,
  5. result of the validation.

If the validation failed, the result object of the apply method will contain a list of the messages for each validation check:

final var result = validator.apply(myModel);
final var error = result.errors().iterator().next();
logger.info("Error on field '" + error.field() + "' " + error.message());

If the validation failed, the result object of the apply method will contain a list of the messages for each validation check:

final var result = validator.apply(myModel);
final var error = result.errors().iterator().next();
logger.info("Error on field '" + error.field() + "' " + error.message());

Json Schema Draft 2020-12 conversion

The JSON schemas emitted by the annotation processor use a lightweight Draft-07/OpenAPI hybrid format: they rely on anullable keyword and reference shared models with #/schemas/<fqn> pointers. When you need a > document, useio.yupiik.fusion.json.schema.JsonSchemaService to convert them in a purely structural and reflectionless way.

The conversion is applied on the generic JSON tree (//scalar values as produced by JsonMapper) and rewrites the schema keywords only, never the payload values:

  • nullable: true becomes type: ["<base>", "null"] (2020-12 dropped nullable in favor of type unions),

  • definitions and the generated schemas container are renamed $defs,

  • #/definitions/... and #/schemas/... references are relocated to #/$defs/...,

  • a $schema: https://json-schema.org/draft/2020-12/schema header is added at the root when missing.

Use it on a single schema, or on a whole META-INF/fusion/json/schemas.json bundle to get a self-contained document:

final JsonSchemaService service = new JsonSchemaService(); // or @Injection

// convert a single generic schema
final Map<String, Object> converted = service.toJsonSchema202012(rawSchemaMap); // (1)

// convert the whole "schemas.json" bundle {"schemas": {...}} into a document with a root $defs
final var bundle = mapper.fromString(Object.class, json);
final Map<String, Object> document = service.to2020Bundle((Map<String, Object>) bundle); // (2)
  1. nullable:true becomes type:["<base>","null"] , refs become #/$defs/... ,
  2. the document gets a $schema header and a root $defs map holding every model keyed by its $id .

The processor also ships a typed view (RawBuildJsonSchema) and a hand-written reflectionless JsonCodec(RawBuildJsonSchemaJsonCodec) so the generated schema payloads can be deserialized with a JsonMapper and then upgraded:

// read one schema-json payload into the typed model with the standard mapper
final RawBuildJsonSchema schema = mapper.read(RawBuildJsonSchema.class, reader);
final Map<String, Object> converted = service.toJsonSchema202012(schema); // -> Draft 2020-12

Pretty mapper

Fusion provide a json pretty mapper to print a json string.

import io.yupiik.fusion.framework.build.api.scanning.Injection;
import io.yupiik.fusion.json.JsonMapper;
import io.yupiik.fusion.json.pretty.PrettyJsonMapper;

@Bean
public class MyClass {

    @Injection
    JsonMapper jsonMapper;

    public void printJson() {
        final JsonMapper prettyJsonMapper = new PrettyJsonMapper(jsonMapper); (1)
        logger.info(jsonMapper.toString(MyClass));
    }
}
  1. use the injected jsonMapper to create the instance of the PrettyJsonMapper.

JsonConfigurationSource

JsonConfigurationSource is a ConfigurationSource that reads a JSON document and flattens it into a Map<String, String> accessible via dot-notation keys. It uses the generic Object mapping (JsonMapper.fromString(Object.class, ...)) to parse the JSON and then recursively flattens the result. The goal is to be able to register a custom JSON resource which serve as Yupiik Fusion ConfigurationSource - backing Configuration API.

Creating a JsonConfigurationSource

Use ReaderSupplier to load the JSON from a classpath resource, file, or inline reader:

import io.yupiik.fusion.framework.api.io.ReaderSupplier;
import io.yupiik.fusion.json.configuration.JsonConfigurationSource;

// from classpath
final var source = new JsonConfigurationSource(
    ReaderSupplier.fromClasspath("config.json"));

// from a file
final var source = new JsonConfigurationSource(
    ReaderSupplier.fromFile(Path.of("/etc/app/config.json")));

// from a file
final var source = new JsonConfigurationSource(
    ReaderSupplier.fromInline("{\"my\":\"content\"}");

// from a resource or file with a default
final var source = new JsonConfigurationSource(
    ReaderSupplier.from("config.json", "{\"myDefaultPort\": 8080}"));

Flattening rules

The JSON structure is flattened as follows:

  • Primitive values (Boolean, String, BigDecimal) are stored directly as strings at the current path.

  • Maps (JSON objects) are flattened as bean properties: each key becomes a path segment using dot-notation. Nested objects are recursively flattened.

  • Collections (JSON arrays) of primitives are serialized as comma-separated values. Collections of objects use a .length key and indexed children (path.0.xxx, path.1.xxx, ...).

  • $asList attribute on a map changes the flattening to use the list convention (path.length, path.N.key, path.N.value), which matches the Map<String, V> reading pattern in ConfigurationFactoryGenerator. When all values are primitives, the map is serialized as a Properties-format string. This enables to comply to Map<String, Object> handling of the configuration mapper.

Examples

Objects

final var source = new JsonConfigurationSource(mapper,
    () -> new StringReader("""
        {"server": {"host": "localhost", "port": 8080}}
        """));

source.get("server.host"); // "localhost"
source.get("server.port"); // "8080"

Collections

final var source = new JsonConfigurationSource(mapper,
    () -> new StringReader("""
        {"tags": ["tag1", "tag2"], "items": [{"name": "a"}, {"name": "b"}]}
        """));

// primitive collection → CSV
source.get("tags"); // "tag1, tag2"

// object collection → length + indexed children
source.get("items.length"); // "2"
source.get("items.0.name"); // "a"
source.get("items.1.name"); // "b"

Map support with $asList

When a JSON object represents a Map<String, V>, mark it with "$asList": true:

final var source = new JsonConfigurationSource(mapper,
    () -> new StringReader("""
        {
          "config": {
            "$asList": true,
            "key1": {"enabled": true},
            "key2": {"enabled": false}
          }
        }
        """));

source.get("config.length");      // "2"
source.get("config.0.key");       // "key1"
source.get("config.0.value.enabled"); // "true"
source.get("config.1.key");       // "key2"
source.get("config.1.value.enabled"); // "false"

For Map<String, String> with $asList, all-primitive values are serialized as a Properties-format string:

final var source = new JsonConfigurationSource(mapper,
    () -> new StringReader("""
        {"metadata": {"$asList": true, "dc": "us-east", "env": "prod"}}
        """));

// uses Properties.store() format, readable by Properties.load()
final var value = source.get("metadata"); // contains "dc=us-east" and "env=prod" - 2 lines

Registration

Since ConfigurationSource are just plain bean you can create a method marked with @Bean and return your instance of JsonConfigurationSource.

IMPORTANT

if you do use Configuration anywhere before your enclosing class and method is called, it will ignore your ConfigurationSource until you do re-register the Configuration instance or invalidate its singleton cache.

An alternative to avoid that is to register the configuration source in a listener, listening for ConfigurationRegistration event:

public class MyListener {
    public void onStart(@OnEvent final ConfigurationRegistration event) {
        event.addSource().accept(new JsonConfigurationSource(
            ReaderSupplier.from(
                    event.configuration().get("my.config.path").orElse("config.json"),
                    "{\"myDefaultPort\": 8080}")));
    }
}