From e5ff54955faf298a272fe24821f8f4448be36883 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 8 Feb 2023 10:39:41 +0000 Subject: [PATCH] ProblemDetail XML support via Jackson Closes gh-29927 --- .../http/codec/json/Jackson2CodecSupport.java | 14 ++-- .../http/codec/json/Jackson2JsonEncoder.java | 12 +++- .../AbstractJackson2HttpMessageConverter.java | 15 ++-- .../json/Jackson2ObjectMapperBuilder.java | 11 ++- .../MappingJackson2HttpMessageConverter.java | 13 +++- .../json/ProblemDetailJacksonXmlMixin.java | 72 +++++++++++++++++++ ...appingJackson2XmlHttpMessageConverter.java | 13 +++- .../Jackson2ObjectMapperBuilderTests.java | 4 +- .../Jackson2ObjectMapperFactoryBeanTests.java | 2 +- .../json/ProblemDetailJacksonMixinTests.java | 32 +++++++-- ...questResponseBodyMethodProcessorTests.java | 32 +++++++-- 11 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/http/converter/json/ProblemDetailJacksonXmlMixin.java diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java index ac19c4feff94..56dcd96ae7c4 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java @@ -80,9 +80,6 @@ public abstract class Jackson2CodecSupport { new MediaType("application", "*+json"), MediaType.APPLICATION_NDJSON); - private static final List problemDetailMimeTypes = - Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON); - protected final Log logger = HttpLogging.forLogName(getClass()); @@ -186,7 +183,16 @@ protected List getMimeTypes(ResolvableType elementType) { if (!CollectionUtils.isEmpty(result)) { return result; } - return (ProblemDetail.class.isAssignableFrom(elementClass) ? problemDetailMimeTypes : getMimeTypes()); + return (ProblemDetail.class.isAssignableFrom(elementClass) ? getMediaTypesForProblemDetail() : getMimeTypes()); + } + + /** + * Return the supported media type(s) for {@link ProblemDetail}. + * By default, an empty list, unless overridden in subclasses. + * @since 6.0.5 + */ + protected List getMediaTypesForProblemDetail() { + return Collections.emptyList(); } protected boolean supportsMimeType(@Nullable MimeType mimeType) { diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2JsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2JsonEncoder.java index 015449d18f59..2a79854b3d82 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2JsonEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2JsonEncoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -17,6 +17,7 @@ package org.springframework.http.codec.json; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -46,6 +47,10 @@ */ public class Jackson2JsonEncoder extends AbstractJackson2Encoder { + private static final List problemDetailMimeTypes = + Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON); + + @Nullable private final PrettyPrinter ssePrettyPrinter; @@ -68,6 +73,11 @@ private static PrettyPrinter initSsePrettyPrinter() { } + @Override + protected List getMediaTypesForProblemDetail() { + return problemDetailMimeTypes; + } + @Override protected ObjectWriter customizeWriter(ObjectWriter writer, @Nullable MimeType mimeType, ResolvableType elementType, @Nullable Map hints) { diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java index a380f0171a65..9c585ab6ae5d 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java @@ -91,9 +91,6 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener ENCODINGS.put("US-ASCII", JsonEncoding.UTF8); } - private static final List problemDetailMediaTypes = - Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON); - protected ObjectMapper defaultObjectMapper; @@ -209,13 +206,23 @@ public List getSupportedMediaTypes(Class clazz) { if (!CollectionUtils.isEmpty(result)) { return result; } - return (ProblemDetail.class.isAssignableFrom(clazz) ? problemDetailMediaTypes : getSupportedMediaTypes()); + return (ProblemDetail.class.isAssignableFrom(clazz) ? + getMediaTypesForProblemDetail() : getSupportedMediaTypes()); } private Map, Map> getObjectMapperRegistrations() { return (this.objectMapperRegistrations != null ? this.objectMapperRegistrations : Collections.emptyMap()); } + /** + * Return the supported media type(s) for {@link ProblemDetail}. + * By default, an empty list, unless overridden in subclasses. + * @since 6.0.5 + */ + protected List getMediaTypesForProblemDetail() { + return Collections.emptyList(); + } + /** * Whether to use the {@link DefaultPrettyPrinter} when writing JSON. * This is a shortcut for setting up an {@code ObjectMapper} as follows: diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.java b/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.java index 158c2cd0086e..4c2fc199fe8e 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.java @@ -99,6 +99,10 @@ */ public class Jackson2ObjectMapperBuilder { + private static boolean jackson2XmlPresent = ClassUtils.isPresent( + "com.fasterxml.jackson.dataformat.xml.XmlMapper", Jackson2ObjectMapperBuilder.class.getClassLoader()); + + private final Map, Class> mixIns = new LinkedHashMap<>(); private final Map, JsonSerializer> serializers = new LinkedHashMap<>(); @@ -755,7 +759,12 @@ else if (this.findWellKnownModules) { objectMapper.setFilterProvider(this.filters); } - objectMapper.addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class); + if (jackson2XmlPresent) { + objectMapper.addMixIn(ProblemDetail.class, ProblemDetailJacksonXmlMixin.class); + } + else { + objectMapper.addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class); + } this.mixIns.forEach(objectMapper::addMixIn); if (!this.serializers.isEmpty() || !this.deserializers.isEmpty()) { diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java index 5280926d3244..ecbd9400a795 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -17,6 +17,8 @@ package org.springframework.http.converter.json; import java.io.IOException; +import java.util.Collections; +import java.util.List; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.ObjectMapper; @@ -45,6 +47,10 @@ */ public class MappingJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter { + private static final List problemDetailMediaTypes = + Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON); + + @Nullable private String jsonPrefix; @@ -88,6 +94,11 @@ public void setPrefixJson(boolean prefixJson) { } + @Override + protected List getMediaTypesForProblemDetail() { + return problemDetailMediaTypes; + } + @Override protected void writePrefix(JsonGenerator generator, Object object) throws IOException { if (this.jsonPrefix != null) { diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/ProblemDetailJacksonXmlMixin.java b/spring-web/src/main/java/org/springframework/http/converter/json/ProblemDetailJacksonXmlMixin.java new file mode 100644 index 000000000000..bd4a98c51210 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/json/ProblemDetailJacksonXmlMixin.java @@ -0,0 +1,72 @@ +/* + * Copyright 2002-2023 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.converter.json; + +import java.net.URI; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; + +import org.springframework.lang.Nullable; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY; + +/** + * Intended to be identical to {@link ProblemDetailJacksonMixin} but for used + * instead of it when jackson-dataformat-xml is on the classpath. Customizes the + * XML root element name and adds namespace information. + * + *

Note: Unfortunately, we cannot just use {@code JsonRootName} to specify + * the namespace since that is not inherited by fields of the class. This is + * why we need a dedicated mixin for use when jackson-dataformat-xml is on the + * classpath. For more details, see + * FasterXML/jackson-dataformat-xml#355. + * + * @author Rossen Stoyanchev + * @since 6.0.5 + */ +@JsonInclude(NON_EMPTY) +@JacksonXmlRootElement(localName = "problem", namespace = "urn:ietf:rfc:7807") +public interface ProblemDetailJacksonXmlMixin { + + @JacksonXmlProperty(namespace = "urn:ietf:rfc:7807") + URI getType(); + + @JacksonXmlProperty(namespace = "urn:ietf:rfc:7807") + String getTitle(); + + @JacksonXmlProperty(namespace = "urn:ietf:rfc:7807") + int getStatus(); + + @JacksonXmlProperty(namespace = "urn:ietf:rfc:7807") + String getDetail(); + + @JacksonXmlProperty(namespace = "urn:ietf:rfc:7807") + URI getInstance(); + + @JsonAnySetter + void setProperty(String name, @Nullable Object value); + + @JsonAnyGetter + @JacksonXmlProperty(namespace = "urn:ietf:rfc:7807") + Map getProperties(); + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java index a5c3d1cb9622..7295dffe72ff 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -17,6 +17,8 @@ package org.springframework.http.converter.xml; import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.xml.XmlMapper; @@ -42,6 +44,10 @@ */ public class MappingJackson2XmlHttpMessageConverter extends AbstractJackson2HttpMessageConverter { + private static final List problemDetailMediaTypes = + Collections.singletonList(MediaType.APPLICATION_PROBLEM_XML); + + /** * Construct a new {@code MappingJackson2XmlHttpMessageConverter} using default configuration * provided by {@code Jackson2ObjectMapperBuilder}. @@ -74,4 +80,9 @@ public void setObjectMapper(ObjectMapper objectMapper) { super.setObjectMapper(objectMapper); } + @Override + protected List getMediaTypesForProblemDetail() { + return problemDetailMediaTypes; + } + } diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java index 9bd77dc0d47d..537ca04d1c04 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java @@ -371,7 +371,7 @@ void mixIn() { .build(); assertThat(mapper.mixInCount()).isEqualTo(2); - assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class); + assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonXmlMixin.class); assertThat(mapper.findMixInClassFor(target)).isSameAs(mixInSource); } @@ -387,7 +387,7 @@ void mixIns() { .build(); assertThat(mapper.mixInCount()).isEqualTo(2); - assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class); + assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonXmlMixin.class); assertThat(mapper.findMixInClassFor(target)).isSameAs(mixInSource); } diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java index 3add547c0364..bd1d77579478 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java @@ -243,7 +243,7 @@ public void setMixIns() { ObjectMapper mapper = this.factory.getObject(); assertThat(mapper.mixInCount()).isEqualTo(2); - assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class); + assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonXmlMixin.class); assertThat(mapper.findMixInClassFor(target)).isSameAs(mixinSource); } diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/ProblemDetailJacksonMixinTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/ProblemDetailJacksonMixinTests.java index f521cb023ae5..0e46a70a1e28 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/ProblemDetailJacksonMixinTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/ProblemDetailJacksonMixinTests.java @@ -65,7 +65,7 @@ void writeCustomProperty() throws Exception { @Test void readCustomProperty() throws Exception { - ProblemDetail problemDetail = this.mapper.readValue( + ProblemDetail detail = this.mapper.readValue( "{\"type\":\"about:blank\"," + "\"title\":\"Bad Request\"," + "\"status\":400," + @@ -73,14 +73,32 @@ void readCustomProperty() throws Exception { "\"host\":\"abc.org\"," + "\"user\":null}", ProblemDetail.class); - assertThat(problemDetail.getType()).isEqualTo(URI.create("about:blank")); - assertThat(problemDetail.getTitle()).isEqualTo("Bad Request"); - assertThat(problemDetail.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); - assertThat(problemDetail.getDetail()).isEqualTo("Missing header"); - assertThat(problemDetail.getProperties()).containsEntry("host", "abc.org"); - assertThat(problemDetail.getProperties()).containsEntry("user", null); + assertThat(detail.getType()).isEqualTo(URI.create("about:blank")); + assertThat(detail.getTitle()).isEqualTo("Bad Request"); + assertThat(detail.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + assertThat(detail.getDetail()).isEqualTo("Missing header"); + assertThat(detail.getProperties()).containsEntry("host", "abc.org"); + assertThat(detail.getProperties()).containsEntry("user", null); } + @Test + void readCustomPropertyFromXml() throws Exception { + ObjectMapper xmlMapper = new Jackson2ObjectMapperBuilder().createXmlMapper(true).build(); + ProblemDetail detail = xmlMapper.readValue( + "" + + "about:blank" + + "Bad Request" + + "400" + + "Missing header" + + "abc.org" + + "", ProblemDetail.class); + + assertThat(detail.getType()).isEqualTo(URI.create("about:blank")); + assertThat(detail.getTitle()).isEqualTo("Bad Request"); + assertThat(detail.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + assertThat(detail.getDetail()).isEqualTo("Missing header"); + assertThat(detail.getProperties()).containsEntry("host", "abc.org"); + } private void testWrite(ProblemDetail problemDetail, String expected) throws Exception { String output = this.mapper.writeValueAsString(problemDetail); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java index 8b151228f82d..27bc565bf0a1 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java @@ -425,8 +425,8 @@ private void testProblemDetailMediaType(String expectedContentType) throws Excep this.servletRequest.setRequestURI("/path"); RequestResponseBodyMethodProcessor processor = - new RequestResponseBodyMethodProcessor( - Collections.singletonList(new MappingJackson2HttpMessageConverter())); + new RequestResponseBodyMethodProcessor(List.of( + new MappingJackson2HttpMessageConverter(), new MappingJackson2XmlHttpMessageConverter())); MethodParameter returnType = new MethodParameter(getClass().getDeclaredMethod("handleAndReturnProblemDetail"), -1); @@ -435,11 +435,29 @@ private void testProblemDetailMediaType(String expectedContentType) throws Excep assertThat(this.servletResponse.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); assertThat(this.servletResponse.getContentType()).isEqualTo(expectedContentType); - assertThat(this.servletResponse.getContentAsString()).isEqualTo( - "{\"type\":\"about:blank\"," + - "\"title\":\"Bad Request\"," + - "\"status\":400," + - "\"instance\":\"/path\"}"); + + if (expectedContentType.equals(MediaType.APPLICATION_PROBLEM_XML_VALUE)) { + assertThat(this.servletResponse.getContentAsString()).isEqualTo( + "" + + "about:blank" + + "Bad Request" + + "400" + + "/path" + + ""); + } + else { + assertThat(this.servletResponse.getContentAsString()).isEqualTo( + "{\"type\":\"about:blank\"," + + "\"title\":\"Bad Request\"," + + "\"status\":400," + + "\"instance\":\"/path\"}"); + } + } + + @Test + void problemDetailWhenProblemXmlRequested() throws Exception { + this.servletRequest.addHeader("Accept", MediaType.APPLICATION_PROBLEM_XML_VALUE); + testProblemDetailMediaType(MediaType.APPLICATION_PROBLEM_XML_VALUE); } @Test // SPR-13135