Skip to content

Issue with deserialization when there are unexpected properties (due to null StreamReadConstraints) #3913

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
sbertault opened this issue May 3, 2023 · 4 comments
Milestone

Comments

@sbertault
Copy link

Hello,
We encountered a weird behaviour with jackson-core 2.15.0 that was not present in version 2.14.2 :
Disclaimer : this is a corner case (but still annoying ;))

When trying to unmarshall the raw json {"list":[{"type":"impl","unmappedKey":"unusedValue"}]}
with "type" being the discriminator for a sealed class, I get this error
com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke "com.fasterxml.jackson.core.JsonParser.streamReadConstraints()" because "p" is null (through reference chain: com.orange.ccmd.connector.MyResponse["list"]->java.util.ArrayList[0])

In the debugger, there is no trace of a variable p being null though.
This happens only when there is all three conditions:

  • an extra field in the json that is not mapped to a field of the target class
  • an extra member in the class that is not present in the json.
  • a class hierarchy

Here is a complete working unit test showing the bug, with the stacktrace :

import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test


internal class JacksonBugTest {

    private val objectMapper = ObjectMapper().apply {
        disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
        registerModules(KotlinModule.Builder().build())
    }

    @Test
    fun thisFails() {
        val rawResponse = """{"list":[{"type":"impl","unmappedKey":"unusedValue"}]}"""
        shouldThrow<JsonMappingException> {
            objectMapper.readValue(rawResponse, MyResponse::class.java)
        }
        /**
         * com.fasterxml.jackson.databind.JsonMappingException: Cannot invoke "com.fasterxml.jackson.core.JsonParser.streamReadConstraints()" because "p" is null (through reference chain: com.orange.ccmd.connector.MyResponse["list"]->java.util.ArrayList[0])
         * 	at app//com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:402)
         * 	at app//com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:373)
         * 	at app//com.fasterxml.jackson.databind.deser.std.CollectionDeserializer._deserializeFromArray(CollectionDeserializer.java:375)
         * 	at app//com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:244)
         * 	at app//com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:28)
         * 	at app//com.fasterxml.jackson.databind.deser.SettableBeanProperty.deserialize(SettableBeanProperty.java:545)
         * 	at app//com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeWithErrorWrapping(BeanDeserializer.java:568)
         * 	at app//com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:439)
         * 	at app//com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1409)
         * 	at app//com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:352)
         * 	at app//com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185)
         * 	at app//com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:323)
         * 	at app//com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4825)
         * 	at app//com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3772)
         * 	at app//com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3740)
         * 	at app//com.orange.ccmd.connector.utils.CustomJacksonObjectMapperFactory.readObject(CustomJacksonJsonProvider.kt:36)
         */
    }

    @Test
    fun `is ok if no missing field in json`() {
        val rawResponse = """{"list":[{"type":"impl","missingInJson":"not missing","unmappedKey":"unusedValue"}]}"""
        val marshalled = objectMapper.readValue(rawResponse, MyResponse::class.java)
        marshalled shouldBe MyResponse(
            list = listOf(Base.Impl(
                type = "impl",
                missingInJson = "not missing"
            ))
        )
    }

    @Test
    fun `is ok if no extra field in json`() {
        val rawResponse = """{"list":[{"type":"impl"}]}"""
        val marshalled = objectMapper.readValue(rawResponse, MyResponse::class.java)
        marshalled shouldBe MyResponse(
            list = listOf(Base.Impl(
                type = "impl",
                missingInJson = null
            ))
        )
    }

    @Test
    fun `is ok if no class hierarchy`() {
        val rawResponse = """{"list":[{"type":"impl"}]}"""
        val marshalled = objectMapper.readValue(rawResponse, MyResponseWithoutHierarchy::class.java)
        marshalled shouldBe MyResponseWithoutHierarchy(
            list = listOf(Base.Impl(
                type = "impl",
                missingInJson = null
            ))
        )
    }
}

data class MyResponse(
    val list: List<Base>
)

data class MyResponseWithoutHierarchy(
    val list: List<Base.Impl>
)

sealed class Base  {
    companion object {
        @JsonCreator
        @JvmStatic
        fun unmarshall(
            @JsonProperty("missingInJson") missingInJson: String? = null,
            @JsonProperty("type") type: String? = null,
        ): Base? {
            return when (type) {
                "impl" -> Impl(
                    missingInJson = missingInJson,
                    type = type,
                )
                else -> null
            }
        }
    }

    data class Impl(
        val type: String? = null,
        val missingInJson: String? = null
    ) : Base()
}

Thanks for the invaluable work

@pjfanning
Copy link
Member

this is best moved to https://github.com/FasterXML/jackson-module-kotlin/ - noone in the team that maintains the core Jackson modules uses Kotlin

@sbertault
Copy link
Author

I detected it in a kotlin project but had the impression that I was in the core stack all the way. Will try to reproduce with java class hierarchy.

@sbertault
Copy link
Author

Same behaviour in java. I am not even attaching the Kotlin module, nor am I using fancy java 17 stuff like records or sealed interface.

package jackson;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

import java.util.List;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;

class JacksonBugTest {

    private ObjectMapper objectMapper() {
        ObjectMapper om = new ObjectMapper();
        om.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
        return om;
    }

    @Test
    void thisFails() {
        String rawResponse = "{\"list\":[{\"type\":\"impl\",\"unmappedKey\":\"unusedValue\"}]}";
        Assertions.assertThrows(JsonMappingException.class, () ->
                objectMapper().readValue(rawResponse, MyResponse.class));
    }

    @Test
    void isOkIfNoMissingFieldInJson() throws JsonProcessingException {
        String rawResponse = "{\"list\":[{\"type\":\"impl\",\"missingInJson\":\"not missing\",\"unmappedKey\":\"unusedValue\"}]}";
        MyResponse marshalled = objectMapper().readValue(rawResponse, MyResponse.class);
        assertEquals("impl", marshalled.getList().get(0).getType());
        assertEquals("not missing", marshalled.getList().get(0).getMissingInJson());
    }

    @Test
    void isOkIfNoExtraFieldInJson() throws JsonProcessingException {
        String rawResponse = "{\"list\":[{\"type\":\"impl\"}]}";
        MyResponse marshalled = objectMapper().readValue(rawResponse, MyResponse.class);
        assertEquals("impl", marshalled.getList().get(0).getType());
        assertNull(marshalled.getList().get(0).getMissingInJson());
    }

    @Test
    void isOkIfNoClassHierarchy() throws JsonProcessingException {
        String rawResponse = "{\"list\":[{\"type\":\"impl\"}]}";
        MyResponseWithoutHierarchy marshalled = objectMapper().readValue(rawResponse, MyResponseWithoutHierarchy.class);
        assertEquals("impl", marshalled.getList().get(0).getType());
        assertNull(marshalled.getList().get(0).getMissingInJson());
    }

}

class MyResponse {
    private List<Base> list;

    public List<Base> getList() {
        return list;
    }

    public void setList(List<Base> list) {
        this.list = list;
    }
}

class MyResponseWithoutHierarchy {

    private List<Impl> list;

    public List<Impl> getList() {
        return list;
    }

    public void setList(List<Impl> list) {
        this.list = list;
    }
}

interface Base {

    String getType();

    String getMissingInJson();

    @JsonCreator
    static Base unmarshall(
            @JsonProperty("missingInJson") String missingInJson,
            @JsonProperty("type") String type
    ) {
        return switch (type) {
            case "impl" -> new Impl(type, missingInJson);
            default -> null;
        };
    }
}

final class Impl implements Base {
    private String type;
    private String missingInJson;

    public Impl() {
    }

    public Impl(String type, String missingInJson) {
        this.type = type;
        this.missingInJson = missingInJson;
    }

    @Override public String getType() {
        return type;
    }

    @Override public String getMissingInJson() {
        return missingInJson;
    }

    public void setType(String type) {
        this.type = type;
    }

    public void setMissingInJson(String missingInJson) {
        this.missingInJson = missingInJson;
    }
}

@sbertault sbertault changed the title Strange JsonMappingException with kotlin in 2.15.0 Strange JsonMappingException in 2.15.0 May 3, 2023
@pjfanning pjfanning changed the title Strange JsonMappingException in 2.15.0 Issue with deserialization when there are unexpected properties (related to null streamReadConstraints) May 3, 2023
@pjfanning
Copy link
Member

pjfanning commented May 3, 2023

@cowtowncoder can you move this to jackson-databind? I have a PR there - #3911

@sbertault thanks for the Java test case

@cowtowncoder cowtowncoder transferred this issue from FasterXML/jackson-core May 3, 2023
@cowtowncoder cowtowncoder changed the title Issue with deserialization when there are unexpected properties (related to null streamReadConstraints) Issue with deserialization when there are unexpected properties (due to null StreamReadConstraints) May 3, 2023
@cowtowncoder cowtowncoder added this to the 2.15.1 milestone May 3, 2023
cowtowncoder added a commit that referenced this issue May 3, 2023
chadlwilson added a commit to gocd/docker-registry-artifact-plugin that referenced this issue May 6, 2023
Better fix for #922 - root cause of issue appears to be a bug specific to Jackson 2.15.0 at FasterXML/jackson-databind#3913
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants