Skip to content

Commit e5ff549

Browse files
committed
ProblemDetail XML support via Jackson
Closes gh-29927
1 parent 9c0b28f commit e5ff549

File tree

11 files changed

+191
-29
lines changed

11 files changed

+191
-29
lines changed

spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,6 @@ public abstract class Jackson2CodecSupport {
8080
new MediaType("application", "*+json"),
8181
MediaType.APPLICATION_NDJSON);
8282

83-
private static final List<MimeType> problemDetailMimeTypes =
84-
Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON);
85-
8683

8784
protected final Log logger = HttpLogging.forLogName(getClass());
8885

@@ -186,7 +183,16 @@ protected List<MimeType> getMimeTypes(ResolvableType elementType) {
186183
if (!CollectionUtils.isEmpty(result)) {
187184
return result;
188185
}
189-
return (ProblemDetail.class.isAssignableFrom(elementClass) ? problemDetailMimeTypes : getMimeTypes());
186+
return (ProblemDetail.class.isAssignableFrom(elementClass) ? getMediaTypesForProblemDetail() : getMimeTypes());
187+
}
188+
189+
/**
190+
* Return the supported media type(s) for {@link ProblemDetail}.
191+
* By default, an empty list, unless overridden in subclasses.
192+
* @since 6.0.5
193+
*/
194+
protected List<MimeType> getMediaTypesForProblemDetail() {
195+
return Collections.emptyList();
190196
}
191197

192198
protected boolean supportsMimeType(@Nullable MimeType mimeType) {

spring-web/src/main/java/org/springframework/http/codec/json/Jackson2JsonEncoder.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
1717
package org.springframework.http.codec.json;
1818

1919
import java.util.Arrays;
20+
import java.util.Collections;
2021
import java.util.List;
2122
import java.util.Map;
2223

@@ -46,6 +47,10 @@
4647
*/
4748
public class Jackson2JsonEncoder extends AbstractJackson2Encoder {
4849

50+
private static final List<MimeType> problemDetailMimeTypes =
51+
Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON);
52+
53+
4954
@Nullable
5055
private final PrettyPrinter ssePrettyPrinter;
5156

@@ -68,6 +73,11 @@ private static PrettyPrinter initSsePrettyPrinter() {
6873
}
6974

7075

76+
@Override
77+
protected List<MimeType> getMediaTypesForProblemDetail() {
78+
return problemDetailMimeTypes;
79+
}
80+
7181
@Override
7282
protected ObjectWriter customizeWriter(ObjectWriter writer, @Nullable MimeType mimeType,
7383
ResolvableType elementType, @Nullable Map<String, Object> hints) {

spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,6 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener
9191
ENCODINGS.put("US-ASCII", JsonEncoding.UTF8);
9292
}
9393

94-
private static final List<MediaType> problemDetailMediaTypes =
95-
Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON);
96-
9794

9895
protected ObjectMapper defaultObjectMapper;
9996

@@ -209,13 +206,23 @@ public List<MediaType> getSupportedMediaTypes(Class<?> clazz) {
209206
if (!CollectionUtils.isEmpty(result)) {
210207
return result;
211208
}
212-
return (ProblemDetail.class.isAssignableFrom(clazz) ? problemDetailMediaTypes : getSupportedMediaTypes());
209+
return (ProblemDetail.class.isAssignableFrom(clazz) ?
210+
getMediaTypesForProblemDetail() : getSupportedMediaTypes());
213211
}
214212

215213
private Map<Class<?>, Map<MediaType, ObjectMapper>> getObjectMapperRegistrations() {
216214
return (this.objectMapperRegistrations != null ? this.objectMapperRegistrations : Collections.emptyMap());
217215
}
218216

217+
/**
218+
* Return the supported media type(s) for {@link ProblemDetail}.
219+
* By default, an empty list, unless overridden in subclasses.
220+
* @since 6.0.5
221+
*/
222+
protected List<MediaType> getMediaTypesForProblemDetail() {
223+
return Collections.emptyList();
224+
}
225+
219226
/**
220227
* Whether to use the {@link DefaultPrettyPrinter} when writing JSON.
221228
* This is a shortcut for setting up an {@code ObjectMapper} as follows:

spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@
9999
*/
100100
public class Jackson2ObjectMapperBuilder {
101101

102+
private static boolean jackson2XmlPresent = ClassUtils.isPresent(
103+
"com.fasterxml.jackson.dataformat.xml.XmlMapper", Jackson2ObjectMapperBuilder.class.getClassLoader());
104+
105+
102106
private final Map<Class<?>, Class<?>> mixIns = new LinkedHashMap<>();
103107

104108
private final Map<Class<?>, JsonSerializer<?>> serializers = new LinkedHashMap<>();
@@ -755,7 +759,12 @@ else if (this.findWellKnownModules) {
755759
objectMapper.setFilterProvider(this.filters);
756760
}
757761

758-
objectMapper.addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class);
762+
if (jackson2XmlPresent) {
763+
objectMapper.addMixIn(ProblemDetail.class, ProblemDetailJacksonXmlMixin.class);
764+
}
765+
else {
766+
objectMapper.addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class);
767+
}
759768
this.mixIns.forEach(objectMapper::addMixIn);
760769

761770
if (!this.serializers.isEmpty() || !this.deserializers.isEmpty()) {

spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,6 +17,8 @@
1717
package org.springframework.http.converter.json;
1818

1919
import java.io.IOException;
20+
import java.util.Collections;
21+
import java.util.List;
2022

2123
import com.fasterxml.jackson.core.JsonGenerator;
2224
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -45,6 +47,10 @@
4547
*/
4648
public class MappingJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
4749

50+
private static final List<MediaType> problemDetailMediaTypes =
51+
Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON);
52+
53+
4854
@Nullable
4955
private String jsonPrefix;
5056

@@ -88,6 +94,11 @@ public void setPrefixJson(boolean prefixJson) {
8894
}
8995

9096

97+
@Override
98+
protected List<MediaType> getMediaTypesForProblemDetail() {
99+
return problemDetailMediaTypes;
100+
}
101+
91102
@Override
92103
protected void writePrefix(JsonGenerator generator, Object object) throws IOException {
93104
if (this.jsonPrefix != null) {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.http.converter.json;
18+
19+
import java.net.URI;
20+
import java.util.Map;
21+
22+
import com.fasterxml.jackson.annotation.JsonAnyGetter;
23+
import com.fasterxml.jackson.annotation.JsonAnySetter;
24+
import com.fasterxml.jackson.annotation.JsonInclude;
25+
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
26+
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
27+
28+
import org.springframework.lang.Nullable;
29+
30+
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY;
31+
32+
/**
33+
* Intended to be identical to {@link ProblemDetailJacksonMixin} but for used
34+
* instead of it when jackson-dataformat-xml is on the classpath. Customizes the
35+
* XML root element name and adds namespace information.
36+
*
37+
* <p>Note: Unfortunately, we cannot just use {@code JsonRootName} to specify
38+
* the namespace since that is not inherited by fields of the class. This is
39+
* why we need a dedicated mixin for use when jackson-dataformat-xml is on the
40+
* classpath. For more details, see
41+
* <a href="https://github.com/FasterXML/jackson-dataformat-xml/issues/355">FasterXML/jackson-dataformat-xml#355</a>.
42+
*
43+
* @author Rossen Stoyanchev
44+
* @since 6.0.5
45+
*/
46+
@JsonInclude(NON_EMPTY)
47+
@JacksonXmlRootElement(localName = "problem", namespace = "urn:ietf:rfc:7807")
48+
public interface ProblemDetailJacksonXmlMixin {
49+
50+
@JacksonXmlProperty(namespace = "urn:ietf:rfc:7807")
51+
URI getType();
52+
53+
@JacksonXmlProperty(namespace = "urn:ietf:rfc:7807")
54+
String getTitle();
55+
56+
@JacksonXmlProperty(namespace = "urn:ietf:rfc:7807")
57+
int getStatus();
58+
59+
@JacksonXmlProperty(namespace = "urn:ietf:rfc:7807")
60+
String getDetail();
61+
62+
@JacksonXmlProperty(namespace = "urn:ietf:rfc:7807")
63+
URI getInstance();
64+
65+
@JsonAnySetter
66+
void setProperty(String name, @Nullable Object value);
67+
68+
@JsonAnyGetter
69+
@JacksonXmlProperty(namespace = "urn:ietf:rfc:7807")
70+
Map<String, Object> getProperties();
71+
72+
}

spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,6 +17,8 @@
1717
package org.springframework.http.converter.xml;
1818

1919
import java.nio.charset.StandardCharsets;
20+
import java.util.Collections;
21+
import java.util.List;
2022

2123
import com.fasterxml.jackson.databind.ObjectMapper;
2224
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
@@ -42,6 +44,10 @@
4244
*/
4345
public class MappingJackson2XmlHttpMessageConverter extends AbstractJackson2HttpMessageConverter {
4446

47+
private static final List<MediaType> problemDetailMediaTypes =
48+
Collections.singletonList(MediaType.APPLICATION_PROBLEM_XML);
49+
50+
4551
/**
4652
* Construct a new {@code MappingJackson2XmlHttpMessageConverter} using default configuration
4753
* provided by {@code Jackson2ObjectMapperBuilder}.
@@ -74,4 +80,9 @@ public void setObjectMapper(ObjectMapper objectMapper) {
7480
super.setObjectMapper(objectMapper);
7581
}
7682

83+
@Override
84+
protected List<MediaType> getMediaTypesForProblemDetail() {
85+
return problemDetailMediaTypes;
86+
}
87+
7788
}

spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ void mixIn() {
371371
.build();
372372

373373
assertThat(mapper.mixInCount()).isEqualTo(2);
374-
assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class);
374+
assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonXmlMixin.class);
375375
assertThat(mapper.findMixInClassFor(target)).isSameAs(mixInSource);
376376
}
377377

@@ -387,7 +387,7 @@ void mixIns() {
387387
.build();
388388

389389
assertThat(mapper.mixInCount()).isEqualTo(2);
390-
assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class);
390+
assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonXmlMixin.class);
391391
assertThat(mapper.findMixInClassFor(target)).isSameAs(mixInSource);
392392
}
393393

spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ public void setMixIns() {
243243
ObjectMapper mapper = this.factory.getObject();
244244

245245
assertThat(mapper.mixInCount()).isEqualTo(2);
246-
assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class);
246+
assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonXmlMixin.class);
247247
assertThat(mapper.findMixInClassFor(target)).isSameAs(mixinSource);
248248
}
249249

spring-web/src/test/java/org/springframework/http/converter/json/ProblemDetailJacksonMixinTests.java

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,22 +65,40 @@ void writeCustomProperty() throws Exception {
6565

6666
@Test
6767
void readCustomProperty() throws Exception {
68-
ProblemDetail problemDetail = this.mapper.readValue(
68+
ProblemDetail detail = this.mapper.readValue(
6969
"{\"type\":\"about:blank\"," +
7070
"\"title\":\"Bad Request\"," +
7171
"\"status\":400," +
7272
"\"detail\":\"Missing header\"," +
7373
"\"host\":\"abc.org\"," +
7474
"\"user\":null}", ProblemDetail.class);
7575

76-
assertThat(problemDetail.getType()).isEqualTo(URI.create("about:blank"));
77-
assertThat(problemDetail.getTitle()).isEqualTo("Bad Request");
78-
assertThat(problemDetail.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
79-
assertThat(problemDetail.getDetail()).isEqualTo("Missing header");
80-
assertThat(problemDetail.getProperties()).containsEntry("host", "abc.org");
81-
assertThat(problemDetail.getProperties()).containsEntry("user", null);
76+
assertThat(detail.getType()).isEqualTo(URI.create("about:blank"));
77+
assertThat(detail.getTitle()).isEqualTo("Bad Request");
78+
assertThat(detail.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
79+
assertThat(detail.getDetail()).isEqualTo("Missing header");
80+
assertThat(detail.getProperties()).containsEntry("host", "abc.org");
81+
assertThat(detail.getProperties()).containsEntry("user", null);
8282
}
8383

84+
@Test
85+
void readCustomPropertyFromXml() throws Exception {
86+
ObjectMapper xmlMapper = new Jackson2ObjectMapperBuilder().createXmlMapper(true).build();
87+
ProblemDetail detail = xmlMapper.readValue(
88+
"<problem xmlns=\"urn:ietf:rfc:7807\">" +
89+
"<type>about:blank</type>" +
90+
"<title>Bad Request</title>" +
91+
"<status>400</status>" +
92+
"<detail>Missing header</detail>" +
93+
"<host>abc.org</host>" +
94+
"</problem>", ProblemDetail.class);
95+
96+
assertThat(detail.getType()).isEqualTo(URI.create("about:blank"));
97+
assertThat(detail.getTitle()).isEqualTo("Bad Request");
98+
assertThat(detail.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
99+
assertThat(detail.getDetail()).isEqualTo("Missing header");
100+
assertThat(detail.getProperties()).containsEntry("host", "abc.org");
101+
}
84102

85103
private void testWrite(ProblemDetail problemDetail, String expected) throws Exception {
86104
String output = this.mapper.writeValueAsString(problemDetail);

spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,8 @@ private void testProblemDetailMediaType(String expectedContentType) throws Excep
425425
this.servletRequest.setRequestURI("/path");
426426

427427
RequestResponseBodyMethodProcessor processor =
428-
new RequestResponseBodyMethodProcessor(
429-
Collections.singletonList(new MappingJackson2HttpMessageConverter()));
428+
new RequestResponseBodyMethodProcessor(List.of(
429+
new MappingJackson2HttpMessageConverter(), new MappingJackson2XmlHttpMessageConverter()));
430430

431431
MethodParameter returnType =
432432
new MethodParameter(getClass().getDeclaredMethod("handleAndReturnProblemDetail"), -1);
@@ -435,11 +435,29 @@ private void testProblemDetailMediaType(String expectedContentType) throws Excep
435435

436436
assertThat(this.servletResponse.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
437437
assertThat(this.servletResponse.getContentType()).isEqualTo(expectedContentType);
438-
assertThat(this.servletResponse.getContentAsString()).isEqualTo(
439-
"{\"type\":\"about:blank\"," +
440-
"\"title\":\"Bad Request\"," +
441-
"\"status\":400," +
442-
"\"instance\":\"/path\"}");
438+
439+
if (expectedContentType.equals(MediaType.APPLICATION_PROBLEM_XML_VALUE)) {
440+
assertThat(this.servletResponse.getContentAsString()).isEqualTo(
441+
"<problem xmlns=\"urn:ietf:rfc:7807\">" +
442+
"<type>about:blank</type>" +
443+
"<title>Bad Request</title>" +
444+
"<status>400</status>" +
445+
"<instance>/path</instance>" +
446+
"</problem>");
447+
}
448+
else {
449+
assertThat(this.servletResponse.getContentAsString()).isEqualTo(
450+
"{\"type\":\"about:blank\"," +
451+
"\"title\":\"Bad Request\"," +
452+
"\"status\":400," +
453+
"\"instance\":\"/path\"}");
454+
}
455+
}
456+
457+
@Test
458+
void problemDetailWhenProblemXmlRequested() throws Exception {
459+
this.servletRequest.addHeader("Accept", MediaType.APPLICATION_PROBLEM_XML_VALUE);
460+
testProblemDetailMediaType(MediaType.APPLICATION_PROBLEM_XML_VALUE);
443461
}
444462

445463
@Test // SPR-13135

0 commit comments

Comments
 (0)