Fusion JSON support
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 aMap<String, X>of a supported type -
Supported primitives are
-
String -
String,BigDecimal(represented asstringin 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
Objectas aMap<String, Object>values beingStringfor JSON strings,BigDecimalfor JSON numbers, anotherMap<String, Object>for JSON objects andList<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
@JsonPropertyon the record members, -
You can map all unknown attributes in a
Map<String, Object>member marked with@JsonOthersannotation.
|
IMPORTANT
|
static model (annotations) are in |
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
|
|
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 |
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 + "'");
};
}
}
-
toJsonStringis an instance method with no parameter used to replace.name()call during serialization, -
fromJsonStringis a static method with aStringparameter 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)
}
}
-
use the
jsonMapperto load the json schema you want to use, - the factory instance can be reuse and is thread safe,
- create a validator from the json schema loaded,
- apply the validator to the input model object,
- 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: truebecomestype: ["<base>", "null"](2020-12 droppednullablein favor oftypeunions), -
definitionsand the generatedschemascontainer are renamed$defs, -
#/definitions/...and#/schemas/...references are relocated to#/$defs/..., -
a
$schema: https://json-schema.org/draft/2020-12/schemaheader 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)
-
nullable:truebecomestype:["<base>","null"], refs become#/$defs/..., -
the document gets a
$schemaheader and a root$defsmap 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));
}
}
-
use the injected
jsonMapperto 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
.lengthkey and indexed children (path.0.xxx,path.1.xxx, ...). -
$asListattribute on a map changes the flattening to use the list convention (path.length,path.N.key,path.N.value), which matches theMap<String, V>reading pattern inConfigurationFactoryGenerator. When all values are primitives, the map is serialized as aProperties-format string. This enables to comply toMap<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 |
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}")));
}
}