Java Spring Patch RFC-6902 Instant type conversion exception

1.4k views Asked by At

I have patching problem which is related to converting the String value the corresponding type. When I try to patch the "Locale" type (or primitives), it works. But it fails for Instant

Entity:

@JsonIgnore
@Field("locale")
private Locale locale;

@JsonIgnore
@Field("dateOfBirth")
private Instant dateOfBirth;

@JsonIgnore
public Locale getLocale() {
    return this.locale;
}

@JsonIgnore
public void setLocale(Locale locale) {
    this.locale = locale;
}

@JsonIgnore
public Instant getDateOfBirth() {
    return this.dateOfBirth;
}

@JsonIgnore
public void setDateOfBirth(Instant dateOfBirth) {
    this.dateOfBirth = dateOfBirth;
}

Patch method:

public static <T> T applyPatchOnObject(Class<T> type, T object, JsonNode jsonNode) {
    try {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        return new JsonPatchPatchConverter(mapper).convert(jsonNode).apply(object, type);
    } catch (Exception e) {
        throw new UnprocessableEntityException(e.getMessage());
    }
}

pom.xml

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.5.10.RELEASE</version>
    <relativePath />
</parent>

<!-- Date -->
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
</dependency>

Data:

[{"op": "replace", "path": "dateOfBirth", "value": "1971-01-01T01:01:01.001Z"}]

The exception:

EL1034E: A problem occurred whilst attempting to set the property 'dateOfBirth': Type conversion failure

Deeper exception:

EL1001E: Type conversion problem, cannot convert from java.lang.String to @com.fasterxml.jackson.annotation.JsonIgnore @org.springframework.data.mongodb.core.mapping.Field java.time.Instant

Edit 1:

The following code blocks work:

Code: System.out.println(mapper.readValue("1517846620.12312312", Instant.class)); Result: 2018-02-05T16:03:40.123123120Z

The following code blocks DO NOT work:

Patch: [{"op": "replace", "path": "dateOfBirth", "value": "1517846620.12312312"}]

Solution:

Although the answer from @Babl will probably work, I figure the following things out.

  1. As @Babl pointed out, the Spring framework patching is NOT done FasterXML but by Spring Expression Context so all Jackson annotations DO NOT take any effect.

  2. I was patching the User entity directly which is VERY BAD practice.

So I ended up with the following implementation

The Patch library

<dependency>
    <groupId>com.flipkart.zjsonpatch</groupId>
    <artifactId>zjsonpatch</artifactId>
    <version>${zjsonpatch.version}</version>
</dependency>

The Patch metod

public static <T extends EmbeddedResource> T applyPatchOnObject(Class<T> type, T object, JsonNode jsonNode) {
    Assert.notNull(type, "Given type must not be null!");
    Assert.notNull(object, "Given object must not be null!");
    Assert.notNull(jsonNode, "Given jsonNode must not be null!");
    try {
        ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
        return mapper.convertValue(JsonPatch.fromJson(jsonNode).apply(mapper.convertValue(object, JsonNode.class)),
                type);
    } catch (Exception e) {
        throw new UnprocessableEntityException(e.getMessage());
    }
}

!NOTE: theapplyPatchOnObject method ONLY accepts classes which extend EmbeddedResource, which in extends ResourceSupport. So basically DTOs only.

The entity is the same

Introduce UserDTO with all the proper Jackson annotations:

@NotNull(message = "locale cannot be null")
@JsonProperty("locale")
private Locale locale;

@NotNull(message = "dateOfBirth cannot be null")
@JsonProperty("dateOfBirth")
private Instant dateOfBirth;

@JsonIgnore
public Locale getLocale() {
    return this.locale;
}

@JsonIgnore
public void setLocale(Locale locale) {
    this.locale = locale;
}

@JsonIgnore
public Instant getDateOfBirth() {
    return this.dateOfBirth;
}

@JsonIgnore
public void setDateOfBirth(Instant dateOfBirth) {
    this.dateOfBirth = dateOfBirth;
}

After I have my DTO patched with values. I will use ObjectMapper or some custom way to apply changes from the DTO to the Entity.

All recommendations and advices are welcome.

2

There are 2 answers

0
Babl On BEST ANSWER

Basically, the problem is the fact that the data binding is not done by FasterXML but by Spring Expression Context. So adding the jackson-datatype-jsr310 will not help at all. The FasterXML will be only used if the patch value is object or an array. But in your case, the patch value is string type so JsonPatchPatchConverter will try to convert values using purely Spring tools (Spring Expression Context). So what now you are missing is the String to Instant converter for a Spring Framework. I'm quite sure that there are some implementations available and even maybe some are within the Spring libraries, but I will create here a simple one and show how you can register it. Initially, let's create a converter (Not the best implementation, just for proof of concept).

public static enum StringToInstantConverter implements Converter<String, Instant> {
    INSTANCE;
    @Override
    public Instant convert(String source) {
        try {
            return Instant.parse(source);
        } catch(DateTimeParseException ex) {
        }
        return null;
    }
}

And register it before calling the applyPatchOnObject method

Something like this will work.

// REGISTER THE CONVERTER
ConversionService conversionService = DefaultConversionService.getSharedInstance();
ConverterRegistry converters = (ConverterRegistry) conversionService;
converters.addConverter(StringToInstantConverter.INSTANCE);

ObjectMapper mapper = new ObjectMapper();
ArrayNode patchArray = mapper.createArrayNode();
ObjectNode patch = mapper.createObjectNode();
patch.put("op", "replace");
patch.put("path", "dateOfBirth");
patch.put("value", "1971-01-01T01:01:01.001Z");
// [{"op": "replace", "path": "dateOfBirth", "value": "1971-01-01T01:01:01.001Z"}]
patchArray.add(patch);

// apply the patch
User patchedUser = applyPatchOnObject(User.class, new User(), patchArray);
0
sbs On

Just to complement the answer above. If you have the Instant class available, why use SimpleDateFormat then? Just parse the input directly:

public Instant convert(String source) {
    try {
        return Instant.parse(source);
    } catch(DateTimeParseException ex) {
    }
    return null;
}

A String like "1971-01-01T01:01:01.001Z" is already in the format parsed by the method above, so this should work.

If you need to parse inputs in different formats, just use a DateTimeFormatter: https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatter.html