Zero-dependency Jackson entities with custom annotations
Jackson is a great library for working with JSON.
However, it is a bit too heavyweight to have it as a public dependency of your API. Especially if you’re working on an Open Source library.
While refactoring docker-java, I got a challenge:
- There are two modules:
api
andcore
api
module (unlikecore
) should not have any dependencycore
can be shaded, which means that we cannot useprovided
scope for Jackson’s annotations inapi
- The entities in
api
have Jackson annotations like@JsonProperty
,@JsonIgnore
,@Creator
and others - Some entities require custom (de-)serialization
If not 4
, everything would be so easy. But hey, things are never easy, right? 😅
Domain
Let’s say we have the following entity:
@Value
@RequiredArgsConstructor
@NoArgsConstructor(force = true)
public class User {
String firstName;
String lastName;
int age;
Sex sex;
PhoneNumber phoneNumber;
public boolean isAdult() {
return age > 18;
}
@Value
public static class PhoneNumber {
int countryCode;
long number;
}
public enum Sex {
MALE,
FEMALE,
UNKNOWN,
;
}
}
ℹ️ Note that I am using Project Lombok here to generate some boilerplate.
Let’s write a test for it to verify that it matches our requirements:
public class UserTest {
static final ObjectMapper objectMapper = new ObjectMapper()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
static final User USER = new User(
"John",
"Smith",
25,
User.Sex.MALE,
new User.PhoneNumber(1, 2065550100)
);
static final String JSON = "{" +
"\"age\":25," +
"\"sex\":\"m\"," +
"\"first_name\":\"John\"," +
"\"last_name\":\"Smith\"," +
"\"phone_number\":\"+1-2065550100\"" +
"}";
@Test
public void testSerialization() throws Exception {
assertEquals(JSON, objectMapper.writeValueAsString(USER));
}
@Test
public void testDeserialization() throws Exception {
assertEquals(USER, objectMapper.readValue(JSON, User.class));
}
}
Note that:
- every field name should use the snake_case
- the phone number field should be encoded as a String
sex
enum is usingm/f/u
notation
If I run the test it will of course fail:
Expected :{"age":25,"sex":"m","first_name":"John","last_name":"Smith","phone_number":"+1-2065550100"}
Actual :{"adult":true,"firstName":"John","lastName":"Smith","age":25,"sex":"MALE","phoneNumber":{"countryCode":1,"number":2065550100}}
Note "adult":true
! It seems that Jackson automatically detected isAdult
method and serialized it too!
Fixing the test
Let’s fix the test with Jackson’s annotations.
First, we need to override the property names:
@Value
@RequiredArgsConstructor
@NoArgsConstructor(force = true)
public class User {
@JsonProperty("first_name")
String firstName;
@JsonProperty("last_name")
String lastName;
int age;
Sex sex;
@JsonProperty("phone_number")
PhoneNumber phoneNumber;
// ...
}
age
and sex
are single-word properties and do not require an annotation.
Now, let’s ignore the isAdult
method:
@JsonIgnore
public boolean isAdult() {
return age >= 18;
}
We also need to override the enum values:
public enum Sex {
@JsonProperty("m")
MALE,
@JsonProperty("f")
FEMALE,
@JsonProperty("u")
UNKNOWN,
;
}
But what aboout the PhoneNumber
?
We could write a (De)Serializer by extending Jackson’s JsonSerializer
,
but this would require a hard dependency on Jackson’s tree structure.
Instead, we will be using @JsonCreator
and @JsonValue
:
@Value
public static class PhoneNumber {
int countryCode;
long number;
@JsonCreator
public static PhoneNumber fromPrimitive(String string) {
// Don't do this at home :D
int beginIndex = string.indexOf("+") + 1;
int endIndex = string.indexOf("-", beginIndex);
return new PhoneNumber(
Integer.parseInt(string.substring(beginIndex, endIndex)),
Long.parseLong(string.substring(endIndex + 1))
);
}
@JsonValue
public String toPrimitive() {
return "+" + countryCode + "-" + number;
}
}
Now, if we run the tests, everything will be green. But… we still have the Jackson dependency!
Custom annotations
One of the things that make Jackson so popular is that it is very flexible.
One of the flexibility vectors is that you can Bring Your Own Annotations!
Let’s define our own set to use later:
For @JsonProperty
:
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface FieldName {
String value();
}
For @JsonIgnore
:
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IgnoredField {}
For @JsonCreator
:
@Target({ElementType.METHOD, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.RUNTIME)
public @interface FromPrimitive {}
For @JsonValue
:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ToPrimitive {}
As you can see, they don’t have to exactly match the ones fom Jackson.
There are no Jackson classes in them too, so that we can remove the Jackson dependency completely!
In our case, we can use them as a drop-in replacement:
@Value
@RequiredArgsConstructor
@NoArgsConstructor(force = true)
public class User {
@FieldName("first_name")
String firstName;
@FieldName("last_name")
String lastName;
int age;
Sex sex;
@FieldName("phone_number")
PhoneNumber phoneNumber;
@IgnoredField
public boolean isAdult() {
return age >= 18;
}
@Value
public static class PhoneNumber {
int countryCode;
long number;
@FromPrimitive
public static PhoneNumber fromPrimitive(String string) {
// Don't do this at home :D
int beginIndex = string.indexOf("+") + 1;
int endIndex = string.indexOf("-", beginIndex);
return new PhoneNumber(
Integer.parseInt(string.substring(beginIndex, endIndex)),
Long.parseLong(string.substring(endIndex + 1))
);
}
@ToPrimitive
public String toPrimitive() {
return "+" + countryCode + "-" + number;
}
}
public enum Sex {
@FieldName("m")
MALE,
@FieldName("f")
FEMALE,
@FieldName("u")
UNKNOWN,
;
}
}
But, if we run it as-is, the tests would obviosly start failing because Jackson does not know about our custom annotations, so we need to “map” them.
To do so, we will be using the AnnotationIntrospector class:
public class CustomJacksonAnnotationIntrospector extends JacksonAnnotationIntrospector {
// ...
}
I define this class in the module where I do have the Jackson dependency (testCompile
, in my case),
so that my API stays Jackson-free.
I haven’t checked, but I assume other JSON fameworks could use these annotations too.
As you can see, I extend JacksonAnnotationIntrospector
, so that I support the Jackson annotations too!
First, let’s map our @IgnoredField
annotation:
@Override
protected boolean _isIgnorable(Annotated a) {
return a.hasAnnotation(IgnoredField.class) || super._isIgnorable(a);
}
Annotated
object gives us a very convinient way of checking whether some annotation is present, or access it.
Now, let’s support @ToPrimitive
/@FromPrimitive
:
@Override
public JsonCreator.Mode findCreatorAnnotation(MapperConfig<?> config, Annotated a) {
if (a.hasAnnotation(FromPrimitive.class)) {
return JsonCreator.Mode.DEFAULT;
}
return super.findCreatorAnnotation(config, a);
}
@Override
public Boolean hasAsValue(Annotated a) {
if (a.hasAnnotation(ToPrimitive.class)) {
return true;
}
return super.hasAsValue(a);
}
The return JsonCreator.Mode.DEFAULT;
value I got from analysing JacksonAnnotationIntrospector#findCreatorAnnotation
.
Last but definitelly not least comes the @FieldName
support:
@Override
public String[] findEnumValues(Class<?> enumType, Enum<?>[] enumValues, String[] names) {
Map<String, String> overrides = Stream.of(ClassUtil.getDeclaredFields(enumType))
.filter(it -> !it.isEnumConstant())
.filter(it -> it.getAnnotation(FieldName.class) != null)
.collect(Collectors.toMap(
it -> it.getName(),
it -> it.getAnnotation(FieldName.class).value()
));
if (overrides.isEmpty()) {
return super.findEnumValues(enumType, enumValues, names);
}
for (int i = 0; i < enumValues.length; ++i) {
names[i] = overrides.getOrDefault(enumValues[i].name(), names[i]);
}
return names;
}
@Override
public PropertyName findNameForSerialization(Annotated a) {
FieldName fieldName = a.getAnnotation(FieldName.class);
if (fieldName != null) {
return PropertyName.construct(fieldName.value());
}
return super.findNameForSerialization(a);
}
@Override
public PropertyName findNameForDeserialization(Annotated a) {
FieldName fieldName = a.getAnnotation(FieldName.class);
if (fieldName != null) {
return PropertyName.construct(fieldName.value());
}
return super.findNameForDeserialization(a);
}
As you can see, it requires a bit of a boilerplate code to be copied from JacksonAnnotationIntrospector#findEnumValues
.
The last step is to pass our AnnotationIntrospector
to ObjectMapper
:
static final ObjectMapper objectMapper = new ObjectMapper()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.setAnnotationIntrospector(new CustomJacksonAnnotationIntrospector());
(you may also create a Jackson Module
and append the AnnotationIntrospector
to the existing ones)
We run the tests and they are back to green! Yay!
Conclusion
Jackson’s support for custom annotations makes it very easy to bring your own annotations, or adopt other framework’s ones.
There is also JaxbAnnotationIntrospector
that leverages JAXB annotations where applicable to JSON mapping.
The source code is available on GitHub: https://github.com/bsideup/blog-custom-Jackson-annotations