Skip to content

Commit

Permalink
ProblemDetail XML support via Jackson
Browse files Browse the repository at this point in the history
Closes gh-29927
  • Loading branch information
rstoyanchev committed Feb 8, 2023
1 parent 9c0b28f commit e5ff549
Show file tree
Hide file tree
Showing 11 changed files with 191 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -186,7 +183,16 @@ protected List<MimeType> 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<MimeType> getMediaTypesForProblemDetail() {
return Collections.emptyList();
}

protected boolean supportsMimeType(@Nullable MimeType mimeType) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;

Expand Down Expand Up @@ -46,6 +47,10 @@
*/
public class Jackson2JsonEncoder extends AbstractJackson2Encoder {

private static final List<MimeType> problemDetailMimeTypes =
Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON);


@Nullable
private final PrettyPrinter ssePrettyPrinter;

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


@Override
protected List<MimeType> getMediaTypesForProblemDetail() {
return problemDetailMimeTypes;
}

@Override
protected ObjectWriter customizeWriter(ObjectWriter writer, @Nullable MimeType mimeType,
ResolvableType elementType, @Nullable Map<String, Object> hints) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -209,13 +206,23 @@ public List<MediaType> 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<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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<?>, Class<?>> mixIns = new LinkedHashMap<>();

private final Map<Class<?>, JsonSerializer<?>> serializers = new LinkedHashMap<>();
Expand Down Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -45,6 +47,10 @@
*/
public class MappingJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {

private static final List<MediaType> problemDetailMediaTypes =
Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON);


@Nullable
private String jsonPrefix;

Expand Down Expand Up @@ -88,6 +94,11 @@ public void setPrefixJson(boolean prefixJson) {
}


@Override
protected List<MediaType> getMediaTypesForProblemDetail() {
return problemDetailMediaTypes;
}

@Override
protected void writePrefix(JsonGenerator generator, Object object) throws IOException {
if (this.jsonPrefix != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();

}
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -42,6 +44,10 @@
*/
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}.
Expand Down Expand Up @@ -74,4 +80,9 @@ public void setObjectMapper(ObjectMapper objectMapper) {
super.setObjectMapper(objectMapper);
}

@Override
protected List<MediaType> getMediaTypesForProblemDetail() {
return problemDetailMediaTypes;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,22 +65,40 @@ 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," +
"\"detail\":\"Missing header\"," +
"\"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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(
"<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
Expand Down

0 comments on commit e5ff549

Please sign in to comment.