Jagger is an annotation-processor that generates databind classes to map JSON and similar formats to Java classes and vice versa. It has several advantages over most traditional libraries:
- No reflection is required. Works great with AOT, e.g. when working with GraalVM native images.
- Barely any runtime dependencies. Shaded jars can get very small. With Nanojson, the overhead is just 34 KiB including the parser.
- Some errors, which are usually only discovered at runtime, are now compiler errors. E.g. no accessible constructor, duplicate property names, unsuitable types for factory methods. Static code analysis, e.g. nullness checks, can be extended to the serialization code. Jagger includes additional compile time checks, see symmetry.
Jagger does not include any parsers or formatters and requires external ones. Various JSON libraries are supported out-of-the-box, including:
- Jackson streaming (
JsonParser
andJsonGenerator
). This gives you support of many additional input and output formats through existing extensions of these classes like YAML, CBOR, Smile, and more. - Jackson objects (
JsonNode
) - Gson (
JsonParser
andJsonWriter
) - JSON-P
- Fastjson2 (
JSONReader
andJSONWriter
) - Nanojson
You can use any backend by implementing the JaggerReader
and JaggerWriter
adapter classes and generating code
for the adapters.
See Backends.
If you have ever used Mapstruct, you will feel right at home with Jagger.
Include the following in your POM:
<dependency>
<groupId>org.tillerino.jagger</groupId>
<artifactId>jagger-core</artifactId>
<version>${jagger.version}</version>
</dependency>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>org.tillerino.jagger</groupId>
<artifactId>jagger-processor</artifactId>
<version>${jagger.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
To generate readers and writers, create an interface and annotate a method with @JsonInput
or @JsonOutput
:
interface MyObjectSerde {
@JsonInput
MyObject read(JsonParser parser, DeserializationContext context) throws IOException;
@JsonOutput
void write(MyObject object, JsonGenerator generator, SerializationContext context) throws IOException;
}
The example above is based on Jackson streaming, which provides JsonParser
for parsing and JsonGenerator
for writing JSON.
The Jagger annotation processor will generate MyJsonMapperImpl
, which implements the interface.
The context parameters can be omitted if they are not explicitly needed.
The @JsonTemplate
annotation allows you to specify prototypes from templates without specifying each as a separate method.
// jagger-tests/jagger-tests-jackson/src/main/java/org/tillerino/jagger/tests/base/features/TemplatesSerde.java#L14-L16
@JsonTemplate(
templates = {GenericInputSerde.class, GenericOutputSerde.class},
types = {double.class, AnEnum.class, double[].class, AnEnum[].class})
To keep the generated code small, Jagger can split up implementations across multiple, reusable methods. Take the following example:
interface MyObjectSerde {
@JsonInput
List<MyObject> read(JsonParser parser) throws IOException;
@JsonInput
MyObject read(JsonParser parser) throws IOException;
}
Here, the implementation of the first method will call the second method for each element in the list. It is recommended to view the generated code and declare further methods to break down large generated methods. This will work at any level, and you can even declare methods for primitive types.
It is impractical to write actual (de-)serialisers for data types which have a simpler representation like a string.
@JsonValue
and @JsonCreator
are supported, but if you cannot (or do not want to) modify the actual types, you can use converters.
Converters are static methods annotated with @JsonOutputConverter
or @JsonInputConverter
.
See this example for OffsetDateTime
:
public class OffsetDateTimeConverters {
@JsonOutputConverter
public static String offsetDateTimeToString(OffsetDateTime offsetDateTime) {
return offsetDateTime.toString();
}
@JsonInputConverter
public static OffsetDateTime stringToOffsetDateTime(String string) {
return OffsetDateTime.parse(string);
}
}
Converter methods can be either located in the same class as the @JsonInput
or @JsonOutput
method
or in a separate class and referenced with the @JsonConfig
uses
value.
Generics are supported for converters, @JsonValue
, and @JsonCreator
methods.
For example, you can write a converter for Optional<T>
:
public class OptionalConverters {
@JsonOutputConverter
public static <T> T optionalToNullable(Optional<T> optional) {
return optional.orElse(null);
}
@JsonInputConverter
public static <T> Optional<T> nullableToOptional(T value) {
return Optional.ofNullable(value);
}
}
This specific case has already been implemented for reuse in OptionalInputConverters and OptionalOutputConverters. See the converters package for more premade converters.
Generics are well-supported. Object properties can be generic, but also collection and array components as well as map values.
See generics for details.
Jagger supports polymorphism through Jackson's @JsonTypeInfo
and @JsonSubTypes
annotations, as well as automatic detection for sealed interfaces and classes. It handles various type identification strategies:
- Class-based identification (
JsonTypeInfo.Id.CLASS
): Uses the full class name as the type identifier - Name-based identification (
JsonTypeInfo.Id.NAME
): Uses custom names defined in@JsonSubTypes
- Simple name identification (
JsonTypeInfo.Id.SIMPLE_NAME
): Uses the simple class name or custom names from@Type
- Minimal class identification (
JsonTypeInfo.Id.MINIMAL_CLASS
): Uses a minimal class identifier
Limitations:
- Does not support
JsonTypeInfo.Id.CUSTOM
orJsonTypeInfo.Id.DEDUCTION
(throws an exception) - Always uses
include = PROPERTY
(does not support other inclusion mechanisms likeWRAPPER_OBJECT
orWRAPPER_ARRAY
) defaultImpl
is ignoredvisible
is alwaysfalse
Example with explicit subtypes:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "@type")
@JsonSubTypes({
@Type(value = RecordOne.class, name = "1"),
@Type(value = RecordTwo.class, name = "2")
})
interface MyInterface {
record RecordOne(String s) implements MyInterface {}
record RecordTwo(int i) implements MyInterface {}
}
interface MySerde {
@JsonOutput
void writeMyInterface(MyInterface obj, JsonGenerator generator) throws Exception;
@JsonInput
MyInterface readMyInterface(JsonParser parser) throws Exception;
}
Example with sealed interfaces (no explicit subtypes needed):
@JsonTypeInfo(use = JsonTypeInfo.Id.MINIMAL_CLASS, property = "@c")
sealed interface SealedInterface permits RecordOne, RecordTwo {}
record RecordOne(String s) implements SealedInterface {}
record RecordTwo(int i) implements SealedInterface {}
interface SealedSerde {
@JsonOutput
void writeSealed(SealedInterface obj, JsonGenerator generator) throws Exception;
@JsonInput
SealedInterface readSealed(JsonParser parser) throws Exception;
}
The generated serializer will include a type discriminator in the JSON output (e.g., {"@type": "1", "s": "value"}
), and the deserializer will use this discriminator to instantiate the correct subtype.
When deserializing JSON and a property is missing, Jagger can use default values defined with the @JsonInputDefaultValue
annotation.
interface MySerde {
@JsonInput
MyObject read(JsonParser parser) throws IOException;
@JsonInputDefaultValue
static String defaultString() {
return "N/A";
}
}
Default value methods can be defined as siblingsor in separate classes referenced with @JsonConfig(uses = {...})
.
The symmetry of serialization and deserialization can be checked at runtime. The main consideration is: For each serialized type, is the set of written properties equal to the set of read properties?
Consider the following class:
//jagger-tests/jagger-tests-base/src/main/java/org/tillerino/jagger/tests/model/features/VerificationModel.java#L18-L25
class MoreSettersThanGetters {
@Getter
@Setter
String s;
@Setter
String t;
}
Assuming that both s
and t
are properties that are required to reconstruct the object correctly, then this
class is missing a @Getter
on t
.
Inversely, having more getters than setters means possibly serializing redundant information.
Once we move away from POJOs and involve creators or inheritance, this symmetry becomes quite hard judge.
Setting @JsonConfig(verifySymmetry=FAIL)
will verify symmetry of serialization and deserialization at compile time.
In addition to this symmetry of individual properties, it will verify:
- That each object's fields are serialized and deserialized in exactly one place. Not only does this prevent duplicating methods for the same type, but prevents code bloat from nested serde.
- That for each reader/writer, the corresponding writer/reader exists in the first place.
- That properties are not duplicated (with
@JsonProperty("name")
, one could define the same property twice).
It is recommended to generate all code with this configuration.
Jagger supports several configuration options through annotations that can be applied at different levels:
The @JsonConfig
annotation provides several configuration options:
uses
: References other classes containing serializers/deserializers for delegationimplement
: Controls whether methods should be implemented (DO_IMPLEMENT
,DO_NOT_IMPLEMENT
)delegateTo
: Controls whether methods can be called from other serializers (DELEGATE_TO
,DO_NOT_DELEGATE_TO
)unknownProperties
: Controls handling of unknown properties (THROW
,IGNORE
)
Jagger supports several Jackson annotations for configuration:
@JsonProperty
: Customize property names in JSON@JsonIgnore
: Ignore specific properties during serialization/deserialization@JsonIgnoreProperties
: Ignore multiple properties or control unknown properties handling
Obviously, Jagger is not complete in any sense, and you may reach the limits of the core functionality. In this section, we show some ways to get your own functionality into jagger.
You can always simply implement serializers yourself:
interface CustomizedSerialization {
@JsonOutput
void writeMyObj(MyObj o, JsonGenerator generator) throws IOException;
@JsonOutput
default void writeOffsetDate(OffsetDateTime timestamp, JsonGenerator generator) throws IOException {
generator.writeString(timestamp.toString());
}
record MyObj(OffsetDateTime t) { }
}
This works for output and input.
- jackson-databind: The definitive standard for Java JSON serialization ❤️. Jackson is the anti-Jagger: It is entirely based on reflection, and even includes a mechanism to write Java bytecode at runtime to boost performance. Jackson is so large that there is a smaller version called jackson-jr.
- https://github.com/ngs-doo/dsl-json
In general, Jagger tries to be compatible with Jackson's default behaviour. Some of Jackson's annotations are supported, but not all and not each supported annotation is supported fully.
- With polymorphism, Jackson will always write and require a discriminator, even when explicitly limiting the type to a specific subtype. Jagger will not write or require a discriminator when the subtype is known.
- Jackson requires
ParameterNamesModule
and compilation with the-parameters
flag to support creator-based deserialization without @JsonProperty annotations. Jagger does not require this since this information is always present during annotation processing. - Jagger will assign the default value of the property type to absent properties even when converters are used.
Jackson will always use the converter and invoke it with its default argument - I think.
An example of this is that Jagger will initialize an absent
Optional<Optional<T>>
property withOptional.empty()
whereas Jackson will instead initialize it withOptional.of(Optional.empty())
. I asked here: FasterXML/jackson-modules-java8#310
The following is a rough indication of compatibility with Jackson's annotations. A checkmark indicates basic compatibility, although there can be edge cases where we are not compatible.
- JacksonInject
- JsonAlias
- JsonAnyGetter
- JsonAnySetter
- JsonAutoDetect
- JsonBackReference
- JsonClassDescription
- JsonCreator
- JsonEnumDefaultValue
- JsonFilter
- JsonFormat
- JsonGetter
- JsonIdentityInfo (regular generators and property-based IDs (
PropertyGenerator
) are supported. JSOG not supported) - JsonIdentityReference
- JsonIgnore
- JsonIgnoreProperties (
value
andignoreUnknown
properties) - JsonIgnoreType
- JsonInclude
- JsonIncludeProperties
- JsonKey
- JsonManagedReference
- JsonMerge
- JsonProperty (
value
andrequired
) - JsonPropertyDescription
- JsonPropertyOrder
- JsonRawValue
- JsonRootName
- JsonSetter
- JsonSubTypes (
failOnRepeatedNames
unsupported) - JsonTypeId
- JsonTypeInfo (not
use
CUSTOM
orDEDUCE
, alwaysinclude
PROPERTY
,defaultImpl
ignored,visible
alwaysfalse
) - JsonTypeName
- JsonUnwrapped
- JsonValue
- JsonView
- Custom converters per property.
- Slowly add support for more Jackson annotations, but on a need-to-have basis. There are so many annotations that we cannot support them all.
- Get rid of Mapstruct dependency