JSON serialization & deserialization (Spring Boot)

1. Reference


2. Introduction

This article will enable ISO 8601 serialization/deserialization of Java 8 Date & Time API data types in an Spring Boot project with Maven.


2.1. OpenAPI 3.0 'date' and 'date-time' formats

It uses the notation as defined by RFC 3339, section 5.6 [https://datatracker.ietf.org/doc/html/rfc3339#section-5.6].


Notes:

  • Fractional seconds can help re-establish chronology

  • Common 'local' fomats marked w/ (****); 'offset' ones w/ (xxxx)

------------------------------------------------------------------------------

"2016-01-31" LocalDate (****)

"10:24:35" LocalTime (****)

"18:25:10.511" LocalTime w/ fractional seconds

"2016-01-31T10:24:35" LocalDateTime (****)

"2016-01-31T10:24:35.511" LocalDateTime w/ fractional seconds

"10:15:30+01:00" OffsetTime w/ time zone (xxxx)

"18:25:10.511Z" OffsetTime w/ fractional seconds and time zone

"2016-01-31T10:24:00+01:00" OffsetDateTime w/ time zone

"2012-04-23T18:25:43.511Z" OffsetDateTime w/ fractional seconds and time zone

------------------------------------------------------------------------------


3. pom.xml (Maven)

It's necessary to add JSR-310 module for making Jackson recognize Java 8 Date & Time API data types.

/pom.xml

<properties>

<jackson.version>2.9.6</jackson.version>

</properties>


<dependencies>

<dependency>

<groupId>com.fasterxml.jackson.datatype</groupId>

<artifactId>jackson-datatype-jsr310</artifactId>

</dependency>

</dependencies>


4. Enable ISO 8601 formatting (Spring Boot)

Remark: This configuration is not longer needed with versions Spring Boot >= 2.2 because ISO 8601 is already the default.

/src/main/resources/application.properties

# JACKSON (JSON serialization): ISO 8601

spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS = false


4.1. LocalTime formatting

Even with Spring Boot >= 2.2, when serializing using com.fasterxml.jackson.databind.ObjectMapper the result is not any of the expected ISO 8601 formats for time (eg: [hh]:[mm]:[ss]).

It can be solved with a custom Json serializer, as the following example demonstrates.


  • MyBean.java with the LocalTime attribute which serialization we'll customize

import java.time.LocalTime;

import org.springframework.lang.Nullable;


import io.swagger.v3.oas.annotations.media.Schema;

import lombok.Data;


import edu.uoc.defensatf.jackson.LocalTimeJsonSerializer;

import com.fasterxml.jackson.databind.annotation.JsonSerialize;


@Data

public class MyBean {

@Schema(description = "My time, Europe/Madrid zone",

example = "18:07:22", required = false, type = "string", format = "time")

@JsonSerialize(using = LocalTimeJsonSerializer.class)

@Nullable

private LocalTime myTime;

}


  • LocalTimeJsonSerializer.java with the LocalTime custom format serializer implementation

package edu.cou.myapp.jackson;


import java.io.IOException;

import java.time.LocalTime;

import java.time.format.DateTimeFormatter;


import org.springframework.boot.jackson.JsonComponent;


import com.fasterxml.jackson.core.JsonGenerator;

import com.fasterxml.jackson.core.JsonProcessingException;

import com.fasterxml.jackson.databind.JsonSerializer;

import com.fasterxml.jackson.databind.ObjectMapper;

import com.fasterxml.jackson.databind.SerializerProvider;


import edu.uoc.defensatf.dto.CalendariTfEstructuraDto;


/**

* Jackson serializer for LocalTime with format "HH:mm:ss"

*/

@JsonComponent

public class LocalTimeJsonSerializer extends JsonSerializer<LocalTime> {


private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");


@Override

public void serialize(LocalTime value, JsonGenerator gen, SerializerProvider serializers)

throws IOException {


if (value == null) {

gen.writeNull();

} else {

gen.writeString(formatter.format(value));

}


}


}


  • Usage of ObjectMapper for serializing the bean

MyBean o = new MyBean();

o.setMyTime(LocalTime.now());


ObjectMapper mapper = new ObjectMapper();


String jsonInString = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(o);

System.out.println(jsonInString);

It prints:

{

"myTime" : "22:00:57"

}



5. Money and Currency API (JSR 354)

Jackson Datatype Money (https://github.com/zalando/jackson-datatype-money) is a Jackson module to support JSON serialization and deserialization of JavaMoney data types.

The JSR 354 reference implementation (RI) is org.javamoney:moneta


Maven dependency:

<dependency>

<groupId>org.zalando</groupId>

<artifactId>jackson-datatype-money</artifactId>

<version>${jackson-datatype-money.version}</version>

</dependency>


For serialization this module currently supports javax.money.MonetaryAmount and will, by default, serialize it as:

{

"amount": 99.95,

"currency": "EUR"

}

This module will use org.javamoney.moneta.Money as an implementation for javax.money.MonetaryAmount by default when deserializing money values.


Usage

Directly use MonetaryAmount in your data types:

import javax.money.MonetaryAmount;


public class Product {

private String sku;

private MonetaryAmount price;

...

}


Doubts

Is it possible to serialize/de-serialize 2 attributes of type MonetaryAmount in the same class?

6. Read-only property

Read-only properties are serialized when sent as part of the response of a REST operation, but they are not shown nor unmarshalled when received as part of a @RequestBody.

Example: The attribute 'updateInstant' is shown by Swagger UI for a response but hidden for a request:

/** Read-only (not unmarshalled) */

@Setter(value = lombok.AccessLevel.PRIVATE)

@JsonProperty(access = JsonProperty.Access.READ_ONLY)

@Schema(accessMode = Schema.AccessMode.READ_ONLY)

private LocalDateTime updateInstant;



7. Differentiate between not provided and provided with null value

For sample usage as operation parameters, see also page "springdoc-openapi (Spring Boot)":

https://sites.google.com/site/pawneecity/sprint-boot/springdoc-openapi-spring-boot#h.13npl4hoxkxp


Sample inner class with Optional attributes:

/**

* Inner class.<br>

* -By default, Jackson deserializes JSON String null to Java String "". Behavior is changed in

* the setter invoked by Jackson.<br>

* -By default, Jackson serializes all attributes. Behavior is changed to exclude null (but not

* Optional.empty).

*/

@JsonInclude(JsonInclude.Include.NON_NULL)

@Data

private class TestOptionalAttrs {

@Schema(nullable = true)

@Nullable // NOSONAR (if absent property)

Optional<Integer> int1;


@Schema(nullable = true)

@Nullable // NOSONAR (if absent property)

Optional<Integer> int2;


@Schema(nullable = true)

@Nullable // NOSONAR (if absent property)

Optional<Integer> int3;


@Schema(nullable = true)

@Nullable // NOSONAR (if absent property)

Optional<String> str4;


@SuppressWarnings("unused")

public void setStr4(String s) {

this.str4 = Optional.ofNullable(s.isEmpty() ? null : s);

}


@Schema(nullable = true)

@Nullable // NOSONAR (if absent property)

Optional<String> str5;


@SuppressWarnings("unused")

public void setStr5(String s) {

this.str5 = Optional.ofNullable(s.isEmpty() ? null : s);

}


@Schema(nullable = true)

@Nullable // NOSONAR (if absent property)

Optional<String> str6;


@SuppressWarnings("unused")

public void setStr6(String s) {

this.str6 = Optional.ofNullable(s.isEmpty() ? null : s);

}

}

Sample JSON received by REST operation (note that properties 'int2' and 'str5' are not provided):

{

"int1": 27,


"int3": null,

"str4": "MyValue",


"str6": null

}

The deserialized Java Object is (notice property not provided and property provided with null are different):

TestOptionalAttrs(int1=Optional[27], int2=null, int3=Optional.empty, str4=Optional[MyValue], str5=null, str6=Optional.empty)

And the serialization of the previous object (notice that only attributes deserialized from the request are serialized):

{

"int1": 27,

"int3": null,

"str4": "MyValue",

"str6": null

}