Skip to content
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

Deserialization of Xml with @JacksonXmlText fails #615

Open
kistlers opened this issue Nov 16, 2023 · 7 comments
Open

Deserialization of Xml with @JacksonXmlText fails #615

kistlers opened this issue Nov 16, 2023 · 7 comments
Labels
2.17 Issues planned at earliest for 2.17

Comments

@kistlers
Copy link

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 cowtowncoder added the 2.17 Issues planned at earliest for 2.17 label Nov 17, 2023
@cowtowncoder
Copy link
Member

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

@kistlers
Copy link
Author

kistlers commented Nov 17, 2023

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
Copy link
Member

cowtowncoder commented Nov 17, 2023

@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
Copy link
Member

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
Copy link
Author

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
Copy link

Aemmie commented Apr 3, 2024

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
Copy link

unoexperto commented May 23, 2024

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2.17 Issues planned at earliest for 2.17
Projects
None yet
Development

No branches or pull requests

4 participants