jackson-dataformat-xml icon indicating copy to clipboard operation
jackson-dataformat-xml copied to clipboard

Deserialization of Xml with `@JacksonXmlText` using `@JsonCreator` (into `java.util.Map`) fails

Open kistlers opened this issue 2 years ago • 18 comments

This is a reproduction of #198. It was mentioned opening a new issue is preferred.

The issue is, that @JacksonXmlText seems to work as intended for serialization, but not for deserialization.

Hence, my reproduction of the original issue with 2.15.4:

I have the following models and tests:

@JacksonXmlRootElement(localName = "ITEMROOT")
public record ItemRoot(
        @JsonProperty("Item") @JacksonXmlElementWrapper(useWrapping = false)
                List<Item> item) {
    
	public record Item(
            @JsonProperty("name") @JacksonXmlProperty(isAttribute = true) String name,
            @JacksonXmlText String value) {

        @JsonCreator
        public Item(final Map<String, String> item) {
            this(item.get("name"), item.get(""));
        }
    }
}

class Tests {

    @Test
    void testDeserializeItemRoot() throws JsonProcessingException {
        final var xmlMapper = new XmlMapper().registerModule(new ParanamerModule());
        final var itemRoot =
                new ItemRoot(
                        List.of(
                                new ItemRoot.Item("name1", "value1"),
                                new ItemRoot.Item("name2", "value2")));

        final var itemRootSerialized =
                xmlMapper.writerWithDefaultPrettyPrinter().writeValueAsString(itemRoot);

        final var itemRootXml =
                """
                <ITEMROOT>
                  <Item name="name1">value1</Item>
                  <Item name="name2">value2</Item>
                </ITEMROOT>
                """;
        assertEquals(itemRootXml, itemRootSerialized);

        final var itemRootDeserialized = xmlMapper.readValue(itemRootXml, ItemRoot.class);
        assertEquals(itemRoot, itemRootDeserialized);
    }
}

First, I serialize the model to verify what I actually want to deserialize is correct and then I serialize the XML again.

The tests pass because of @JsonCreator in Item. Without the annotation, I get the following error on the xmlMapper.readValue():

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Invalid definition for property '' (of type `ch.sample.model.ItemRoot$Item`): Could not find creator property with name '' (known Creator properties: [name, value])
 at [Source: (StringReader); line: 1, column: 1]

kistlers avatar Nov 16 '23 17:11 kistlers

Thank you @kistlers. Yes, a new issue with reproduction works. I labelled it with record since that is likely relevant here.

cowtowncoder avatar Nov 17 '23 04:11 cowtowncoder

I think the record is not necessarily relevant here, but rather the constructor/JsonCreator (see below).

At least, I still got the same error using these two final classes (IntelliJ -> convert to record, the make sure they correspond to the same records as above) when I remove the @JsonCreator:

@JacksonXmlRootElement(localName = "ITEMROOT")
    static final class ItemRoot {
        @JsonProperty("Item")
        @JacksonXmlElementWrapper(useWrapping = false)
        private final List<Item> item;

        ItemRoot(
                @JsonProperty("Item") @JacksonXmlElementWrapper(useWrapping = false)
                        final List<Item> item) {
            this.item = item;
        }

        @JsonProperty("Item")
        @JacksonXmlElementWrapper(useWrapping = false)
        public List<Item> item() {
            return item;
        }

        @Override
        public boolean equals(final Object o) {
            if (this == o) {
                return true;
            }

            if (!(o instanceof final ItemRoot itemRoot)) {
                return false;
            }

            return new EqualsBuilder().append(item, itemRoot.item).isEquals();
        }

        @Override
        public int hashCode() {
            return new HashCodeBuilder(17, 37).append(item).toHashCode();
        }

        public static final class Item {
            @JsonProperty("name")
            @JacksonXmlProperty(isAttribute = true)
            private final String name;

            @JacksonXmlText private final String value;

            public Item(
                    @JsonProperty("name") @JacksonXmlProperty(isAttribute = true) final String name,
                    @JacksonXmlText final String value) {
                this.name = name;
                this.value = value;
            }

            @JsonCreator
            public Item(final Map<String, String> item) {
                this(item.get("name"), item.get(""));
            }

            @JsonProperty("name")
            @JacksonXmlProperty(isAttribute = true)
            public String name() {
                return name;
            }

            @JacksonXmlText
            public String value() {
                return value;
            }

            @Override
            public boolean equals(final Object o) {
                if (this == o) {
                    return true;
                }

                if (!(o instanceof final Item item)) {
                    return false;
                }

                return new EqualsBuilder()
                        .append(name, item.name)
                        .append(value, item.value)
                        .isEquals();
            }

            @Override
            public int hashCode() {
                return new HashCodeBuilder(17, 37).append(name).append(value).toHashCode();
            }
        }
    }

However, this works (no final classes and fields, all public properties, no setters/getters). I also quickly tested that with private fields with getters and setters, which also works:

@JacksonXmlRootElement(localName = "ITEMROOT")
    static class ItemRoot {
        @JsonProperty("Item")
        @JacksonXmlElementWrapper(useWrapping = false)
        public List<Item> item;

        @Override
        public boolean equals(final Object o) {
            if (this == o) {
                return true;
            }

            if (!(o instanceof final ItemRoot itemRoot)) {
                return false;
            }

            return new EqualsBuilder().append(item, itemRoot.item).isEquals();
        }

        @Override
        public int hashCode() {
            return new HashCodeBuilder(17, 37).append(item).toHashCode();
        }

        public static class Item {
            @JsonProperty("name")
            @JacksonXmlProperty(isAttribute = true)
            public String name;

            @JacksonXmlText public String value;

            //            @JsonCreator
            //            public Item(final Map<String, String> item) {
            //                this(item.get("name"), item.get(""));
            //            }

            @Override
            public boolean equals(final Object o) {
                if (this == o) {
                    return true;
                }

                if (!(o instanceof final Item item)) {
                    return false;
                }

                return new EqualsBuilder()
                        .append(name, item.name)
                        .append(value, item.value)
                        .isEquals();
            }

            @Override
            public int hashCode() {
                return new HashCodeBuilder(17, 37).append(name).append(value).toHashCode();
            }
        }
    }

kistlers avatar Nov 17 '23 10:11 kistlers

@kistlers Thank you. I was about to suggest trying to see if equivalent POJO exhibited same problem.

I suspect this may be due to more general jackson-databind problem with linking (or lack thereof) of property annotations for Constructors not explicitly annotated with @JsonCreator. Although I am not 100% sure since you are providing all annotations via constructor parameter too, so that should not matter (normally all annotations from all "accesors", including constructor parameters, are merged -- but this does not work for auto-detected constructors).

cowtowncoder avatar Nov 17 '23 20:11 cowtowncoder

Another note: use of Map<String, String> may be problematic as well: XML structures are not good match with Java Maps.

But I am also confused as to intent of 2 annotated constructrors:

            public Item(
                    @JsonProperty("name") @JacksonXmlProperty(isAttribute = true) final String name,
                    @JacksonXmlText final String value) {
                this.name = name;
                this.value = value;
            }

            @JsonCreator
            public Item(final Map<String, String> item) {
                this(item.get("name"), item.get(""));
            }

both of which would be detected; but that cannot really be used together (how would Jackson know which one to use, basically).

I guess it'd be good to have still bit more minimal reproduction as I am not quite sure how this model is expected to work, esp. wrt Map value.

cowtowncoder avatar Feb 05 '24 05:02 cowtowncoder

About the use of the Map, I used it as it was the only solution I found to make deserialization work with records.

Anyway, here is a simpler reproduction (I think). I removed the outer ItemRoot class and just kept Item.

So this fails:

class XmlMapperReproductionTest {

    @Test
    void testDeserializeItemRoot() throws JsonProcessingException {
        var xmlMapper = new XmlMapper().registerModule(new ParanamerModule());
        var item = new Item("name1", "value1");

        var itemSerialized = xmlMapper.writerWithDefaultPrettyPrinter().writeValueAsString(item);

        var itemXml = """
                <Item name="name1">value1</Item>
                """;
        assertEquals(itemXml, itemSerialized);

        var itemDeserialized = xmlMapper.readValue(itemXml, Item.class);
        assertEquals(item, itemDeserialized);
    }

    @JacksonXmlRootElement
    public record Item(
            @JsonProperty("name") @JacksonXmlProperty(isAttribute = true) String name,
            @JacksonXmlText String value) {}
}

with the error message:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Invalid definition for property '' (of type `ch.ubique.backend.test.assertion.XmlMapperReproductionTest$Item`): Could not find creator property with name '' (known Creator properties: [name, value])
 at [Source: (StringReader); line: 1, column: 1]

Swapping the record to this very simple POJO (the Equals, HashCode, and constructor are only there to keep the Test class identical):

    @JacksonXmlRootElement
    public static class Item {
        @JsonProperty("name")
        @JacksonXmlProperty(isAttribute = true)
        public String name;

        @JacksonXmlText public String value;

        public Item(String name, String value) {
            this.name = name;
            this.value = value;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }

            if (!(o instanceof Item item)) {
                return false;
            }

            return new EqualsBuilder().append(name, item.name).append(value, item.value).isEquals();
        }

        @Override
        public int hashCode() {
            return new HashCodeBuilder(17, 37).append(name).append(value).toHashCode();
        }
    }

Also, using this Item class with private final fields also passes the test:

    @JacksonXmlRootElement
    public static class Item {
        @JsonProperty("name")
        @JacksonXmlProperty(isAttribute = true)
        private final String name;

        @JacksonXmlText private final String value;

        public Item(String name, String value) {
            this.name = name;
            this.value = value;
        }

        public String getName() {
            return name;
        }

        public String getValue() {
            return value;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }

            if (!(o instanceof Item item)) {
                return false;
            }

            return new EqualsBuilder().append(name, item.name).append(value, item.value).isEquals();
        }

        @Override
        public int hashCode() {
            return new HashCodeBuilder(17, 37).append(name).append(value).toHashCode();
        }
    }

kistlers avatar Feb 10 '24 19:02 kistlers

Simple workaround is to add @JacksonXmlProperty(localName = "") along with @JacksonXmlText. Could be done in meta-annotation too.

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@JacksonXmlText
@JacksonXmlProperty(localName = "")
@JacksonAnnotationsInside
public @interface XmlText {
}

Aemmie avatar Apr 03 '24 10:04 Aemmie