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 ac19c4feff9..56dcd96ae7c 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 @@ public abstract class Jackson2CodecSupport { 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 015449d18f5..2a79854b3d8 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 @@ import org.springframework.util.MimeType; */ 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 @@ public class Jackson2JsonEncoder extends AbstractJackson2Encoder { } + @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 a380f0171a6..9c585ab6ae5 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 abstract class AbstractJackson2HttpMessageConverter extends AbstractGener 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 158c2cd0086..4c2fc199fe8 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 @@ import org.springframework.util.xml.StaxUtils; */ 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 @@ public class Jackson2ObjectMapperBuilder { 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 5280926d324..ecbd9400a79 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 @@ import org.springframework.lang.Nullable; */ 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 class MappingJackson2HttpMessageConverter extends AbstractJackson2HttpMes } + @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 00000000000..bd4a98c5121 --- /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 a5c3d1cb962..7295dffe72f 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 @@ import org.springframework.util.Assert; */ 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 class MappingJackson2XmlHttpMessageConverter extends AbstractJackson2Http 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 9bd77dc0d47..537ca04d1c0 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 @@ class Jackson2ObjectMapperBuilderTests { .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 @@ class Jackson2ObjectMapperBuilderTests { .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 3add547c036..bd1d7757947 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 class Jackson2ObjectMapperFactoryBeanTests { 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 f521cb023ae..0e46a70a1e2 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 @@ public class ProblemDetailJacksonMixinTests { @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 @@ public class ProblemDetailJacksonMixinTests { "\"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 8b151228f82..27bc565bf0a 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 @@ public class RequestResponseBodyMethodProcessorTests { 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 @@ public class RequestResponseBodyMethodProcessorTests { 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