Formatting Java Time with Spring Boot using JSON > http://lewandowski.io/2016/02/formatting-java-time-with-spring-boot-using-json/
OpenAPITools / jackson-databind-nullable > https://github.com/OpenAPITools/jackson-databind-nullable
What is the "right" JSON date format? > https://stackoverflow.com/a/15952652/1323562
This article digs into ISO 8601 serialization/deserialization of Java 8 Date & Time API data types in an Spring Boot project with Maven.
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
------------------------------------------------------------------------------
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>
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
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.cou.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.cou.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"
}
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?
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;
JsonNullable is the recommended alternative.
Optional also works fine but it was never intended to assign a null to an attribute of type Optional.
The official GIT repository for JsonNullable is "OpenAPITools/jackson-databind-nullable":
https://github.com/OpenAPITools/jackson-databind-nullable
pom.xml
Dependency:
<dependency>
<groupId>org.openapitools</groupId>
<artifactId>jackson-databind-nullable</artifactId>
<version>0.2.11</version>
</dependency>
Spring configuration
import org.openapitools.jackson.nullable.JsonNullableModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfig {
@Bean
public JsonNullableModule jsonNullableModule() {
return new JsonNullableModule();
}
}
JsonNullable DTO
import org.openapitools.jackson.nullable.JsonNullable;
public class UpdateUserRequest {
private JsonNullable<String> name = JsonNullable.undefined();
private JsonNullable<String> email = JsonNullable.undefined();
public JsonNullable<String> getName() {
return name;
}
public void setName(JsonNullable<String> name) {
this.name = name;
}
public JsonNullable<String> getEmail() {
return email;
}
public void setEmail(JsonNullable<String> email) {
this.email = email;
}
}
Handle Updates in Service / Controller (JsonNullable)
@RestController
@RequestMapping("/users")
public class UserController {
@PatchMapping("/{id}")
public ResponseEntity<Void> patchUser(@PathVariable Long id, @RequestBody UpdateUserRequest request) {
User user = userRepository.findById(id).orElseThrow();
// State checks
if (request.getName().isPresent()) {
// Evaluates to true if present as value OR explicitly set to null
user.setName(request.getName().orElse(null));
}
if (request.getEmail().isPresent()) {
user.setEmail(request.getEmail().orElse(null));
}
userRepository.save(user);
return ResponseEntity.noContent().build();
}
}
OpenAPI / Swagger UI (JsonNullable)
If you use springdoc-openapi to document your REST endpoints, JsonNullable properties might sometimes show up wrapped awkwardly in the generated schema. To fix this, register the OpenAPI Jackson converter bean:
import io.swagger.v3.core.jackson.ModelResolver;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public ModelResolver modelResolver(ObjectMapper objectMapper) {
return new ModelResolver(objectMapper);
}
}
Mapstruct example to map JsonNullable fields automatically
To map JsonNullable fields to your JPA entities automatically with MapStruct, you need to configure MapStruct to handle JsonNullable wrapping and unwrapping correctly.
Because JsonNullable represents three states (undefined, explicit null, and value), standard null-checking in MapStruct isn't sufficient. Here is how to configure MapStruct to execute field updates only when a field is present.
When MapStruct generates the implementation class (UserMapperImpl), it uses the @Condition method (isPresent) before attempting to update each field.
Dependency:
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.3</version>
</dependency>
Helper Mapper (JsonNullable)
import org.mapstruct.Condition;
import org.mapstruct.Mapper;
import org.openapitools.jackson.nullable.JsonNullable;
@Mapper(componentModel = "spring")
public interface JsonNullableMapper {
/**
* Unwraps JsonNullable<T> to T. Returns null if JsonNullable is null or contains null.
*/
default <T> T unwrap(JsonNullable<T> jsonNullable) {
return jsonNullable == null ? null : jsonNullable.orElse(null);
}
/**
* Condition check for MapStruct.
* MapStruct will only execute the mapping if this method returns true.
*/
@Condition
default <T> boolean isPresent(JsonNullable<T> jsonNullable) {
return jsonNullable != null && jsonNullable.isPresent();
}
}
Entity update mapper definition
import org.mapstruct.BeanMapping;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;
@Mapper(
componentModel = "spring",
uses = { JsonNullableMapper.class },
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public interface UserMapper {
/**
* Updates an existing entity instance in-place.
*/
@BeanMapping(ignoreByDefault = false)
void updateUserFromDto(UpdateUserRequest dto, @MappingTarget User entity);
}
Controller / Service usage (JsonNullable)
Once the Mapstruct configuration is in place, the controller endpoint can stay clean without manual isPresent() checks.
@RestController
@RequestMapping("/users")
public class UserController {
private final UserRepository userRepository;
private final UserMapper userMapper;
public UserController(UserRepository userRepository, UserMapper userMapper) {
this.userRepository = userRepository;
this.userMapper = userMapper;
}
@PatchMapping("/{id}")
public ResponseEntity<Void> patchUser(@PathVariable Long id, @RequestBody UpdateUserRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
// Applies patch updates automatically
userMapper.updateUserFromDto(request, user);
userRepository.save(user);
return ResponseEntity.noContent().build();
}
}
Validate JsonNullable fields (e.g. @Email or @Size) with Jakarta Bean Validation
Note: This configuration is probably not needed in current versions of Spring Boot and JsonNullable.
To validate JsonNullable fields using standard Jakarta Bean Validation annotations (@NotNull, @Size, @Email, etc.), you must tell the Hibernate Validator engine how to extract the wrapped value from JsonNullable.
Because JsonNullable is a custom wrapper class, Hibernate Validator does not know how to unpack it out of the box. You solve this by implementing a ValueExtractor.
Implement ValueExtractor (JsonNullable)
import jakarta.validation.valueextraction.ExtractedValue;
import jakarta.validation.valueextraction.UnwrapByDefault;
import jakarta.validation.valueextraction.ValueExtractor;
import org.openapitools.jackson.nullable.JsonNullable;
@UnwrapByDefault
public class JsonNullableValueExtractor implements ValueExtractor<JsonNullable<@ExtractedValue ?>> {
@Override
public void extractValues(JsonNullable<?> receiver, ValueExtractor.ValueReceiver receiverApi) {
// Only attempt validation if the field was present in the request
if (receiver != null && receiver.isPresent()) {
receiverApi.value(null, receiver.get());
}
}
}
Note: Marking the extractor with @UnwrapByDefault automatically applies annotations like @Email directly to the contained String inside JsonNullable<String>, rather than trying to validate the JsonNullable container itself.
Register ValueExtractor with Spring (JsonNullable)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
@Configuration
public class ValidationConfig {
@Bean
public LocalValidatorFactoryBean validator() {
LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
factoryBean.setValidationValueExtractors(List.of(new JsonNullableValueExtractor()));
return factoryBean;
}
}
Apply annotation to DTO (JsonNullable)
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.openapitools.jackson.nullable.JsonNullable;
public class UpdateUserRequest {
// Validates length IF present (omitted or set to null/value)
@Size(min = 2, max = 50)
private JsonNullable<String> name = JsonNullable.undefined();
// Validates email format IF present, but allows explicit null in JSON
private JsonNullable<String> email = JsonNullable.undefined();
// Disallows explicit "age": null in JSON payload while keeping field optional to omit
@NotNull
private JsonNullable<Integer> age = JsonNullable.undefined();
public JsonNullable<String> getName() {
return name;
}
public void setName(JsonNullable<String> name) {
this.name = name;
}
public JsonNullable<String> getEmail() {
return email;
}
public void setEmail(JsonNullable<String> email) {
this.email = email;
}
public JsonNullable<Integer> getAge() {
return age;
}
public void setAge(JsonNullable<Integer> age) {
this.age = age;
}
}
Enable validation in Controller (JsonNulable)
Trigger validation on incoming payloads using @Valid:
@RestController
@RequestMapping("/users")
public class UserController {
@PatchMapping("/{id}")
public ResponseEntity<Void> patchUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
// Endpoint logic executed only if request passes validation
return ResponseEntity.noContent().build();
}
}
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
}