kamer.dev

FasterXML/jackson Tips for JSON in Java

- #java - #json - #jackson

Introduction

All my examples will be in a Spring Boot project that has Web and Lombok dependencies. I will not list all features and explain them one by one like a documentation. Instead, I will ask some questions and answer them. I hope you will find your questions! If not so, you can ask me on Twitter or e-mail.

What is Jackson?

This article is not a zero-to-hero-like article. So, I will not start from basics but introduce Jackson with a few words. Because you may have been using Jackson library without knowing you do so. Jackson is the most widely used JSON processing library for Java. It has 3 core modules (jackson-core, jackson-annotations, jackson-databind), third-party modules for different integrations. You may have been using them because spring-boot-starter-web includes these three modules(and more) and Jackson is registered as default object mapper library. This is why you can produce JSON data by just returning Java object in your controller in your Spring application.

Q1: How to create a Java POJO for any JSON input?

Perhaps, this is the simplest question that comes to mind. Let’s see an example JSON:

{
  "title": "2019-2020 Algorithms Final Exam",
  "lecture": "Algorithms",
  "examDate": "2020-03-04T09:00:00.000Z",
  "studentNumber": 75,
  "questions": [
    {
      "questionNumber": 1,
  "question": "What is an asymptotic notation?"
  },
  {
      "questionNumber": 2,
  "questions": "Find worst case for insertion sort?"
  },
  {
      "questionNumber": 3,
  "question": "Which algorithm would you choose to sort 1m numbers, why?"
  }
  ]
}

Let’s analyze this JSON. This is an object that has title (string), lecture (string), examDate (date), studentNumber (numeric value, int is the best option for this case.) and questions (array) fields. So, we should map this JSON to a Java object that has the same fields. All fields are OK but questions array has an object type that has questionNumber(numberic value, int) and question (string) fields.

First of all, create a Question object as below:

@Getter
@Setter
@ToString
class Question {

   private Integer questionNumber;

   private String question;

}

Then, create Exam object.

@Getter
@Setter
@ToString
public class Exam {

   private String title;

   private String lecture;

   private LocalDateTime examDate;

   private Integer studentNumber;

   private List<Question> questions;

}

That’s all. Finally, create a controller.

@PostMapping("/question-one")
ResponseEntity<Void> questionOne(@RequestBody Exam exam) {
   log.info("Parsed object: {}", exam);
   return ResponseEntity.ok().build();
}

Now, send JSON with any method you like.

We mapped JSON array as List in our POJO. This is not a must, we can also use Java Array (Question[]), Collection<>, Set<>, Iterable<> and any other type that implements Iterable<>.

Q2: How to use different field names in POJO to map JSON?

In Q1 we named our fields according to given JSON. But this is not a must. We can use different names with many different options. But I will explain only two of them. So let’s create an example JSON:

{
  "productTitle": "TWSBI ECO Fountain Pen White M Nib",
  "productPrice": "30.00",
  "productCategoryId": 13,
  "productTagId": 235
}

As you see, there is a JSON object -let’s call it product- and it has 4 fields. Creating a POJO with the same field names are possible. But removing product... prefix is better.

Option 1

Create a POJO as below:

@Getter
@Setter
@ToString
public class Q2ProductOptionOne {

    @JsonAlias("productTitle")
    private String title;

    @JsonAlias("productPrice")
    private String price;

    @JsonAlias("productCategoryId")
    private Integer categoryId;

    @JsonAlias("productTagId")
    private Integer tagId;
}

@JsonAlias annotation makes possible to define multiple options for a property. So @JsonAlias("productTitle") annotation catches productTitle key in JSON. But don’t forget, this annotation creates alternatives only. Actual property name doesn’t change.

This option works perfectly but not the best option. Because what we want is to change the property name not to create alternatives.

Option 2

@JsonProperty annotation is the other option. This will change the property name:

@Getter
@Setter
@ToString
public class Q2ProductOptionTwo {

    @JsonProperty("productTitle")
    private String title;

    @JsonProperty("productPrice")
    private String price;

    @JsonProperty("productCategoryId")
    private Integer categoryId;

    @JsonProperty("productTagId")
    private Integer tagId;

}

We can use these two annotations at the same time. @JsonProperty will change the actual name and @JsonAlias will add other options.

You can set an access option on @JsonProperty. This will provide you some options such as changing the property name on deserialization/serialization/both.

Q3: How to deserialize nested JSON objects?

In a standard JSON communication, you are likely to encounter nested objects inside JSON. In Q1 we deserialized a JSON input with multiple classes. But there are other options.

{
    "title": "TWSBI ECO Fountain Pen White M Nib",
    "price": "30.00",
    "taxonomy": {
	"categoryId": 13,
	"tagId": 235
    }
}

Option 1

First option is to map nested object to a Map. This is very easy.

@Getter
@Setter
@ToString
public class Q3ProductOptionOne {

    private String title;

    private Float price;

    private Map<String, String> taxonomy;

}

Jackson will initialize this map with categoryId and tagId keys.

Option 2

@Getter
@Setter
@ToString
public class Q3ProductOptionTwo {

    private String title;

    private Float price;

    private Integer tagId;

    private Integer categoryId;

    @JsonProperty("taxonomy")
    @SuppressWarnings("unchecked")
    private void taxonomyDeserializer(Map<String, Object> taxonomy) {
	this.tagId = (Integer) taxonomy.get("tagId");
	this.categoryId = (Integer) taxonomy.get("categoryId");
    }
}

This option looks like the previous option. But it’s better I think. Because we does not use map class as nested object. Instead, we give this nested map to a private method annotated with @JsonProperty and @SuppressWarnings. Then extract values from map and set to fields.

Option 3

This option is a different question. So, check Q4 for this.

Q4: How to create a custom deserializer?

There may be some cases that you don’t want to use annotations or these annotations are not enough for your development. So, you can create your own deserializer that you receive raw JSON and export POJO, then, register it to overwrite default deserializer. Let’s create a JSON that has nested objects.

{
    "applicant": {
	"name": "Joss",
	"surname": "Stone"
    },
    "application": {
	"announcementId": 2,
	"givenInput": {
	    "profileLink": "<https://dummypage.com>",
	    "cvFileName": "myAwesomeCv.pdf"
	}
    }
}

We can deserialize it as it is explained in Q3. But creating a custom deserializer is another option.

This is our POJO:

@Getter
@Setter
@Builder
@ToString
public class Application {

    private String applicationFullName;

    private Integer announcementId;

    private String applicantProfileLink;

    private String cvName;
}

And this is deserializer:

public class ApplicationDeserializer extends StdDeserializer<Application> {

    public ApplicationDeserializer() {
	this(null);
    }

    public ApplicationDeserializer(Class<?> vc) {
	super(vc);
    }

    @Override
    public Application deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException {

    final JsonNode jsonNode = jp.getCodec().readTree(jp);
    final JsonNode applicantNode = jsonNode.get("applicant");
    final JsonNode applicationNode = jsonNode.get("application");

    final String name = applicantNode.get("name").asText();
    final String surname = applicantNode.get("surname").asText();
    final Integer announcementId = applicationNode.get("announcementId").asInt();

    final JsonNode givenInputNode = applicationNode.get("givenInput");
    final String profileLink = givenInputNode.get("profileLink").asText();
    final String cvFileName = givenInputNode.get("cvFileName").asText();

    return Application.builder()
		      .applicationFullName(name + " " + surname)
		      .announcementId(announcementId)
		      .applicantProfileLink(profileLink)
		      .cvName(cvFileName)
		      .build();
    }
}

All elements are JsonNode in this deserialization process. If you chain a method such as asText(), asInt(), they are converted into desired form. Otherwise they are treated as JsonNode. You can perform all kinds of validations and manipulations also.

Lastly, let’s register deserializer class in our POJO class with the annotation below:

@JsonDeserialize(using = ApplicationDeserializer.class)

Q5: How to deserialize enum values?

Deserializing enum values is easy. You can do it without a tutorial. But there are different options. First of all, create an enum class.

public enum ContainerStatusOptionOne { RUNNING, FAILED, PENDING, REMOVING }

Then, create a POJO that has this enum as a field.

@Getter
@Setter
@ToString
public class Container {
    private String containerTitle;
    private ContainerStatus containerStatus;
}

Option 1

This is option is the first that comes to mind. Just send the name of the enum value.

{
    "containerTitle": "Stage",
    "containerStatus": "RUNNING"
}

But don’t forget, this option works case sensitive. If you send running as a value, you get HttpMessageNotReadableException.

Option 2

This option is as easy as the first option but personally, I don’t find it useful. When you send numeric values for enum, Jackson deserializes it also. RUNNING is 0 for our example and REMOVING is 3. So:

{
    "containerTitle": "Stage",
    "containerStatus": 0
}

is the same as the example above.

Option 3

There are some situations that you may work with 3rd party services and you cannot change your request body according to your POJO. For instance, 3rd party service sends lowercase values for enums. Jackson provides an annotation for this also. We need to change enum values as below:

public enum ContainerStatusOptionTwo {
    @JsonProperty("running") RUNNING,
    @JsonProperty("failed") FAILED,
    @JsonProperty("pending") PENDING,
    @JsonProperty("removing") REMOVING
}

Now, Jackson will look for running, failed, pending and removing as input. So, sending the JSON below works:


{
    "containerTitle": "Stage",
    "containerStatus": "running"
}

References

Github Repo: https://github.com/kamer/jackson-tips-for-json-in-java