Search before asking

  • [X] I searched in the issues and found nothing similar.

Describe the bug

Deserialzing a JSON with a duplicated property fails for a Java Record with a single property.

Version Information

2.17.2

Reproduction

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.databind.ObjectMapper;

class JacksonDeserializationTest {

    record MyRecord(String first) {
    }

    @Test
    void test() throws Exception {
        ObjectMapper mapper = new ObjectMapper();

        String json = """
                {
                    "first": "test@example.com",
                    "first": "test@example.com"
                }
                """;
        var value = mapper.readValue(json, MyRecord.class);
        System.out.println(value);
    }

}

Produces the following error:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No fallback setter/field defined for creator property 'first' (through reference chain: JacksonDeserializationTest$MyRecord["first"])
    at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67)
    at com.fasterxml.jackson.databind.deser.CreatorProperty._reportMissingSetter(CreatorProperty.java:354)
    at com.fasterxml.jackson.databind.deser.CreatorProperty._verifySetter(CreatorProperty.java:341)
    at com.fasterxml.jackson.databind.deser.CreatorProperty.deserializeAndSet(CreatorProperty.java:270)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:273)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:470)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1493)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:348)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185)
    at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:342)
    at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4905)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3848)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3816)
    at JacksonDeserializationTest.test(JacksonDeserializationTest.java:20)
    at java.base/java.lang.reflect.Method.invoke(Method.java:580)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)


Expected behavior

Deserialization works, or a proper exception describing the problem (e.g. "duplicate property") is thrown.

Additional context

Workaround: Add a 2nd dummy property to the Java Record

Ofc the duplicate property in the JSON is not the default. It was detected during a penetration test when testing for parameter pollution vulnerabilities.

Comment From: cowtowncoder

Thank you for reporting this issue. I think I know why it occurs, technically speaking, but not sure how to address it.

But in the meantime... to fail on duplicate property values, generally, it's enough to enable

StreamReadFeature.STRICT_DUPLICATE_DETECTION

on JsonFactory used to construct ObjectMapper / JsonMapper.

Comment From: yihtserns

Quoting @cowtowncoder from https://github.com/FasterXML/jackson-core/issues/60:

...JSON specification does not make duplicate Object values strictly illegal (behavior is undefined I think)...

...checking for duplicates adds non-trivial amount of cost which for valid content is pure overhead...so...add a feature to let users request that duplicate detection is enabled...

Comment From: sseelmann

Thanks for the suggestions, but I think I won't enable the STRICT_DUPLICATE_DETECTION feature because * of the processing overhead and * we use spring boot so the object mapper is "managed" by it, even thought it's probably configurable, but I'd prefer to use the default config

Comment From: tgyurci

I ran into the same error when tried to use @JsonAlias on a records's member. Both cases throw the same exception:

@Test
void testRecordAliasLast() throws Exception {
    final var r = objectMapper.readValue("""
            {"a": "A", "b": "B"}""", R.class);

    assertThat(r).isEqualTo(new R("B"));
}

@Test
void testRecordAliasFirst() throws Exception {
    final var r = objectMapper.readValue("""
            {"b": "B", "a": "A"}""", R.class);

    assertThat(r).isEqualTo(new R("A"));
}

public record R(@JsonAlias("b") String a) {
}
No fallback setter/field defined for creator property 'a' (of `R`) (through reference chain: R["a"])
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No fallback setter/field defined for creator property 'a' (of `R`) (through reference chain: R["a"])
    at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67)
    at com.fasterxml.jackson.databind.deser.CreatorProperty._reportMissingSetter(CreatorProperty.java:358)
    at com.fasterxml.jackson.databind.deser.CreatorProperty._verifySetter(CreatorProperty.java:341)
    at com.fasterxml.jackson.databind.deser.CreatorProperty.deserializeAndSet(CreatorProperty.java:270)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:273)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:472)
    at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1497)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:348)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185)
    at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:342)
    at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4917)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3860)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3828)

Comment From: JooHyukKim

@tgyurci thank you for sharing your case, but is there anything else you are expecting?

Comment From: cowtowncoder

Ideally we should at least give better error message. Good to know of multiple paths that can result in this problem.

Comment From: tgyurci

@tgyurci thank you for sharing your case, but is there anything else you are expecting?

@JooHyukKim I'd expect to work like how it does for normal classes:

public class C {
    @JsonAlias("b")
    private String a;
}

Comment From: cowtowncoder

Due to implementation I suspect our most practical solution would be just use the first value and ignore the second value (which is different from POJO with setters/fields). This because Constructor-passed values are collected as a set, passed via constructor invocation, and there is no way to mutate values afterward. That should be doable.

An alternative -- which may or may not be practical would be to change construct-based handling to defer call until... hmmh. Yeah, that probably wouldn't work.

But "use first, ignore rest" (or, possibly, "fail on dups", maybe with config setting) is probably achievable.

Comment From: tgyurci

My real world scenario is that I want to migrate a field to a new name thus either the actual property or the alias is present in the JSON. Therefore the preference order is irrelevant in my case.

Comment From: yihtserns

My real world scenario is that I want to migrate a field to a new name thus either the actual property or the alias is present in the JSON. Therefore the preference order is irrelevant in my case.

@tgyurci If you're migrating, that means the JSON would NOT have both the actual property and the alias - which is a case that Records + JsonAlias DO support:

@Test
void testAlias() throws Exception {
    final var r = objectMapper.readValue("""{"b": "B"}""", R.class); // alias only

    assertThat(r).isEqualTo(new R("B"));
}

@Test
void testActualProperty() throws Exception {
    final var r = objectMapper.readValue("""{"a": "A"}""", R.class); // actual property only

    assertThat(r).isEqualTo(new R("A"));
}

public record R(@JsonAlias("b") String a) {
}

...yet your original example include BOTH the actual property & alias in the same JSON, which does not fit the word "migration" & "either/or".

Comment From: cowtowncoder

I think one way to catch duplicates for Records would be to add virtual FieldProperty (or similar) that would throw suitable exception. But that could be confusing for the Alias use case.