FasterXML / jackson-dataformat-xml

Extension for Jackson JSON processor that adds support for serializing POJOs as XML (and deserializing from XML) as an alternative to JSON
Apache License 2.0
561 stars 221 forks source link

Deserialization of Xml with `@JacksonXmlText` fails #615

Open kistlers opened 7 months ago

kistlers commented 7 months ago

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]
cowtowncoder commented 7 months ago

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

kistlers commented 7 months ago

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();
            }
        }
    }
cowtowncoder commented 7 months ago

@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 commented 5 months ago

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.

kistlers commented 4 months ago

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();
        }
    }
Aemmie commented 3 months ago

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 {
}
unoexperto commented 1 month ago

@Aemmie Your meta-annotation solution wraps value into another xml element. It's not text. And use of @JacksonXmlText @JacksonXmlProperty(localName = "") leads to the original exception during deserialization.