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:

  1. There are two modules: api and core
  2. api module (unlike core) should not have any dependency
  3. core can be shaded, which means that we cannot use provided scope for Jackson’s annotations in api
  4. The entities in api have Jackson annotations like @JsonProperty, @JsonIgnore, @Creator and others
  5. 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 using m/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

comments powered by Disqus