diff --git a/spring-web/src/main/java/org/springframework/http/codec/xml/JacksonXmlDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/xml/JacksonXmlDecoder.java new file mode 100644 index 00000000000..d75e13f692b --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/xml/JacksonXmlDecoder.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.codec.xml; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.dataformat.xml.XmlFactory; +import tools.jackson.dataformat.xml.XmlMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.http.codec.AbstractJacksonDecoder; +import org.springframework.util.MimeType; +import org.springframework.util.xml.StaxUtils; + +/** + * Decode bytes into XML and convert to Objects with Jackson 3.x. + * + *

Stream decoding is currently not supported. + * + * @author Sebastien Deleuze + * @since 7.0.3 + * @see JacksonXmlEncoder + */ +public class JacksonXmlDecoder extends AbstractJacksonDecoder { + + private static final MediaType[] DEFAULT_XML_MIME_TYPES = new MediaType[] { + new MediaType("application", "xml", StandardCharsets.UTF_8), + new MediaType("text", "xml", StandardCharsets.UTF_8), + new MediaType("application", "*+xml", StandardCharsets.UTF_8) + }; + + + /** + * Construct a new instance with a {@link XmlMapper} customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)}. + */ + public JacksonXmlDecoder() { + super(XmlMapper.builder(defensiveXmlFactory()), DEFAULT_XML_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link XmlMapper.Builder} + * customized with the {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * @see XmlMapper#builder() + * @see #defensiveXmlFactory() + */ + public JacksonXmlDecoder(XmlMapper.Builder builder) { + super(builder, DEFAULT_XML_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link XmlMapper}. + * @see XmlMapper#builder() + * @see #defensiveXmlFactory() + */ + public JacksonXmlDecoder(XmlMapper mapper) { + super(mapper, DEFAULT_XML_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link XmlMapper.Builder} + * customized with the {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}, and + * {@link MimeType}s. + * @see XmlMapper#builder() + * @see #defensiveXmlFactory() + */ + public JacksonXmlDecoder(XmlMapper.Builder builder, MimeType... mimeTypes) { + super(builder, mimeTypes); + } + + /** + * Construct a new instance with the provided {@link XmlMapper} and {@link MimeType}s. + * @see XmlMapper#builder() + * @see #defensiveXmlFactory() + */ + public JacksonXmlDecoder(XmlMapper mapper, MimeType... mimeTypes) { + super(mapper, mimeTypes); + } + + + @Override + public Flux decode(Publisher input, ResolvableType elementType, @Nullable MimeType mimeType, + @Nullable Map hints) { + + throw new UnsupportedOperationException("Stream decoding is currently not supported"); + } + + /** + * Return an {@link XmlFactory} created from {@link StaxUtils#createDefensiveInputFactory} + * with Spring's defensive setup, i.e. no support for the resolution of DTDs and external + * entities. + */ + public static XmlFactory defensiveXmlFactory() { + return new XmlFactory(StaxUtils.createDefensiveInputFactory()); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/xml/JacksonXmlEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/xml/JacksonXmlEncoder.java new file mode 100644 index 00000000000..a298a7b3d0f --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/xml/JacksonXmlEncoder.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.codec.xml; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.dataformat.xml.XmlMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.MediaType; +import org.springframework.http.codec.AbstractJacksonEncoder; +import org.springframework.util.MimeType; + +/** + * Encode from an {@code Object} to bytes of XML objects using Jackson 3.x. + * + *

Stream encoding is currently not supported. + * + * @author Sebastien Deleuze + * @since 7.0.3 + * @see JacksonXmlDecoder + */ +public class JacksonXmlEncoder extends AbstractJacksonEncoder { + + private static final MediaType[] DEFAULT_XML_MIME_TYPES = new MediaType[] { + new MediaType("application", "xml", StandardCharsets.UTF_8), + new MediaType("text", "xml", StandardCharsets.UTF_8), + new MediaType("application", "*+xml", StandardCharsets.UTF_8) + }; + + + /** + * Construct a new instance with a {@link XmlMapper} customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)}. + */ + public JacksonXmlEncoder() { + super(XmlMapper.builder(), DEFAULT_XML_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link XmlMapper.Builder} + * customized with the {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * @see XmlMapper#builder() + */ + public JacksonXmlEncoder(XmlMapper.Builder builder) { + super(builder, DEFAULT_XML_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link XmlMapper}. + * @see XmlMapper#builder() + */ + public JacksonXmlEncoder(XmlMapper mapper) { + super(mapper, DEFAULT_XML_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link XmlMapper.Builder} + * customized with the {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}, and + * {@link MimeType}s. + * @see XmlMapper#builder() + */ + public JacksonXmlEncoder(XmlMapper.Builder builder, MimeType... mimeTypes) { + super(builder, mimeTypes); + } + + /** + * Construct a new instance with the provided {@link XmlMapper} and {@link MimeType}s. + * @see XmlMapper#builder() + */ + public JacksonXmlEncoder(XmlMapper mapper, MimeType... mimeTypes) { + super(mapper, mimeTypes); + } + + + @Override + public Flux encode(Publisher inputStream, DataBufferFactory bufferFactory, ResolvableType elementType, + @Nullable MimeType mimeType, @Nullable Map hints) { + + throw new UnsupportedOperationException("Stream encoding is currently not supported"); + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/xml/JacksonXmlDecoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/xml/JacksonXmlDecoderTests.java new file mode 100644 index 00000000000..b9c62214799 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/xml/JacksonXmlDecoderTests.java @@ -0,0 +1,106 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.codec.xml; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import tools.jackson.core.JacksonException; +import tools.jackson.dataformat.xml.XmlMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.testfixture.codec.AbstractDecoderTests; +import org.springframework.http.MediaType; +import org.springframework.web.testfixture.xml.Pojo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link JacksonXmlDecoder}. + * + * @author Sebastien Deleuze + */ +class JacksonXmlDecoderTests extends AbstractDecoderTests { + + private final Pojo pojo1 = new Pojo("f1", "b1"); + + private final Pojo pojo2 = new Pojo("f2", "b2"); + + private final XmlMapper mapper = XmlMapper.builder().build(); + + + public JacksonXmlDecoderTests() { + super(new JacksonXmlDecoder()); + } + + + @Override + @Test + protected void canDecode() { + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), MediaType.APPLICATION_XML)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), MediaType.TEXT_XML)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), new MediaType("application", "soap+xml"))).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), null)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(String.class), null)).isTrue(); + assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), MediaType.APPLICATION_JSON)).isFalse(); + } + + @Override + @Test + protected void decode() { + Flux input = Flux.just(this.pojo1, this.pojo2) + .map(this::writeObject) + .flatMap(this::dataBuffer); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> + testDecodeAll(input, Pojo.class, step -> step + .expectNext(pojo1) + .expectNext(pojo2) + .verifyComplete())); + + } + + private byte[] writeObject(Object o) { + try { + return this.mapper.writer().writeValueAsBytes(o); + } + catch (JacksonException e) { + throw new AssertionError(e); + } + + } + + @Override + @Test + protected void decodeToMono() { + List expected = Arrays.asList(pojo1, pojo2); + + Flux input = Flux.just(expected) + .map(this::writeObject) + .flatMap(this::dataBuffer); + + ResolvableType elementType = ResolvableType.forClassWithGenerics(List.class, Pojo.class); + testDecodeToMono(input, elementType, step -> step + .expectNext(expected) + .expectComplete() + .verify(), null, null); + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/xml/JacksonXmlEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/xml/JacksonXmlEncoderTests.java new file mode 100644 index 00000000000..3306e072153 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/xml/JacksonXmlEncoderTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.codec.xml; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import tools.jackson.dataformat.xml.XmlMapper; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.testfixture.io.buffer.AbstractLeakCheckingTests; +import org.springframework.core.testfixture.io.buffer.DataBufferTestUtils; +import org.springframework.http.MediaType; +import org.springframework.web.testfixture.xml.Pojo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.core.io.buffer.DataBufferUtils.release; + +/** + * Tests for {@link JacksonXmlEncoder}. + * + * @author Sebastien Deleuze + */ +class JacksonXmlEncoderTests extends AbstractLeakCheckingTests { + + private final XmlMapper mapper = XmlMapper.builder().build(); + + private final JacksonXmlEncoder encoder = new JacksonXmlEncoder(); + + + @Test + protected void canEncode() { + ResolvableType pojoType = ResolvableType.forClass(Pojo.class); + assertThat(this.encoder.canEncode(pojoType, MediaType.APPLICATION_XML)).isTrue(); + assertThat(this.encoder.canEncode(pojoType, MediaType.TEXT_XML)).isTrue(); + assertThat(this.encoder.canEncode(pojoType, new MediaType("application", "soap+xml"))).isTrue(); + assertThat(this.encoder.canEncode(pojoType, null)).isTrue(); + assertThat(this.encoder.canEncode(ResolvableType.forClass(String.class), null)).isTrue(); + assertThat(this.encoder.canEncode(ResolvableType.NONE, null)).isTrue(); + assertThat(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), MediaType.APPLICATION_JSON)).isFalse(); + } + + @Test + protected void encode() { + Pojo value = new Pojo("foo", "bar"); + DataBuffer result = encoder.encodeValue(value, this.bufferFactory, ResolvableType.forClass(Pojo.class), + MediaType.APPLICATION_XML, null); + pojoConsumer(value).accept(result); + } + + private Consumer pojoConsumer(Pojo expected) { + return dataBuffer -> { + Pojo actual = this.mapper.reader().forType(Pojo.class) + .readValue(DataBufferTestUtils.dumpBytes(dataBuffer)); + assertThat(actual).isEqualTo(expected); + release(dataBuffer); + }; + } + + @Test + void encodeStream() { + Pojo pojo1 = new Pojo("foo", "bar"); + Pojo pojo2 = new Pojo("foofoo", "barbar"); + Pojo pojo3 = new Pojo("foofoofoo", "barbarbar"); + Flux input = Flux.just(pojo1, pojo2, pojo3); + ResolvableType type = ResolvableType.forClass(Pojo.class); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> + encoder.encode(input, this.bufferFactory, type, MediaType.APPLICATION_XML, null)); + } + +}