ProblemDetail XML support via Jackson

Closes gh-29927
This commit is contained in:
rstoyanchev 2023-02-08 10:39:41 +00:00
parent 9c0b28ffdc
commit e5ff54955f
11 changed files with 191 additions and 29 deletions

View File

@ -80,9 +80,6 @@ public abstract class Jackson2CodecSupport {
new MediaType("application", "*+json"),
MediaType.APPLICATION_NDJSON);
private static final List<MimeType> 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<MimeType> getMediaTypesForProblemDetail() {
return Collections.emptyList();
}
protected boolean supportsMimeType(@Nullable MimeType mimeType) {

View File

@ -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<MimeType> problemDetailMimeTypes =
Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON);
@Nullable
private final PrettyPrinter ssePrettyPrinter;
@ -68,6 +73,11 @@ public class Jackson2JsonEncoder extends AbstractJackson2Encoder {
}
@Override
protected List<MimeType> getMediaTypesForProblemDetail() {
return problemDetailMimeTypes;
}
@Override
protected ObjectWriter customizeWriter(ObjectWriter writer, @Nullable MimeType mimeType,
ResolvableType elementType, @Nullable Map<String, Object> hints) {

View File

@ -91,9 +91,6 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener
ENCODINGS.put("US-ASCII", JsonEncoding.UTF8);
}
private static final List<MediaType> 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<Class<?>, Map<MediaType, ObjectMapper>> 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<MediaType> 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:

View File

@ -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<?>, Class<?>> mixIns = new LinkedHashMap<>();
private final Map<Class<?>, 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()) {

View File

@ -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<MediaType> problemDetailMediaTypes =
Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON);
@Nullable
private String jsonPrefix;
@ -88,6 +94,11 @@ public class MappingJackson2HttpMessageConverter extends AbstractJackson2HttpMes
}
@Override
protected List<MediaType> getMediaTypesForProblemDetail() {
return problemDetailMediaTypes;
}
@Override
protected void writePrefix(JsonGenerator generator, Object object) throws IOException {
if (this.jsonPrefix != null) {

View File

@ -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.
*
* <p>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
* <a href="https://github.com/FasterXML/jackson-dataformat-xml/issues/355">FasterXML/jackson-dataformat-xml#355</a>.
*
* @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<String, Object> getProperties();
}

View File

@ -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<MediaType> 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<MediaType> getMediaTypesForProblemDetail() {
return problemDetailMediaTypes;
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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(
"<problem xmlns=\"urn:ietf:rfc:7807\">" +
"<type>about:blank</type>" +
"<title>Bad Request</title>" +
"<status>400</status>" +
"<detail>Missing header</detail>" +
"<host>abc.org</host>" +
"</problem>", 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);

View File

@ -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(
"<problem xmlns=\"urn:ietf:rfc:7807\">" +
"<type>about:blank</type>" +
"<title>Bad Request</title>" +
"<status>400</status>" +
"<instance>/path</instance>" +
"</problem>");
}
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