JSON serialization & deserialization (Spring Boot)
1. Reference
Formatting Java Time with Spring Boot using JSON > http://lewandowski.io/2016/02/formatting-java-time-with-spring-boot-using-json/
What is the "right" JSON date format? > https://stackoverflow.com/a/15952652/1323562
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
}