diff --git a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java
index d78a4fc13b..a454c38d0c 100644
--- a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java
+++ b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java
@@ -24,12 +24,20 @@ import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
- * Representation of an RFC 7807 problem detail, including all RFC-defined
- * properties.
+ * Representation for an RFC 7807 problem detail. Includes spec-defined
+ * properties, and a {@link #getProperties() properties} map for additional,
+ * non-standard properties.
*
- *
For an extended response, create a subclass with additional properties.
- * A subclass can use the {@link ProblemDetail#ProblemDetail(ProblemDetail)}
- * copy constructor to extend an existing {@code ProblemDetail} instance.
+ *
For an extended response, an application can add to the
+ * {@link #getProperties() properties} map. When using the Jackson library, the
+ * {@code properties} map is expanded as top level JSON properties through the
+ * {@link org.springframework.http.converter.json.ProblemDetailJacksonMixin}.
+ *
+ *
For an extended response, an application can also create a subclass with
+ * additional properties. Subclasses can use the protected copy constructor to
+ * re-create an existing {@code ProblemDetail} instance as the subclass, e.g.
+ * from an {@code @ControllerAdvice} such as
+ * {@link org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler}.
*
* @author Rossen Stoyanchev
* @since 6.0
@@ -203,7 +211,9 @@ public class ProblemDetail {
}
/**
- * Return a generic map of properties that are not known ahead of time.
+ * Return a generic map of properties that are not known ahead of time,
+ * possibly {@code null} if no properties have been added. To add a property,
+ * use {@link #setProperty(String, Object)}.
*/
@Nullable
public Map getProperties() {
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 5cae8ff06d..4581158f85 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
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2021 the original author or authors.
+ * Copyright 2002-2022 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.
@@ -60,6 +60,7 @@ import org.springframework.beans.BeanUtils;
import org.springframework.beans.FatalBeanException;
import org.springframework.context.ApplicationContext;
import org.springframework.core.KotlinDetector;
+import org.springframework.http.ProblemDetail;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
@@ -730,6 +731,7 @@ public class Jackson2ObjectMapperBuilder {
objectMapper.setFilterProvider(this.filters);
}
+ 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/ProblemDetailJacksonMixin.java b/spring-web/src/main/java/org/springframework/http/converter/json/ProblemDetailJacksonMixin.java
new file mode 100644
index 0000000000..abae30512b
--- /dev/null
+++ b/spring-web/src/main/java/org/springframework/http/converter/json/ProblemDetailJacksonMixin.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2002-2022 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.util.Map;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import org.springframework.http.ProblemDetail;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY;
+
+/**
+ * An interface to associate Jackson annotations with
+ * {@link org.springframework.http.ProblemDetail} to avoid a hard dependency on
+ * the Jackson library.
+ *
+ * The annotations ensure the {@link ProblemDetail#getProperties() properties}
+ * map is unwrapped and rendered as top level JSON properties, and likewise that
+ * the {@code properties} map contains unknown properties from the JSON.
+ *
+ *
{@link Jackson2ObjectMapperBuilder} automatically registers this as a
+ * "mix-in" for {@link ProblemDetail}, which means it always applies, unless
+ * an {@code ObjectMapper} is instantiated directly and configured for use.
+ *
+ * @author Rossen Stoyanchev
+ * @since 6.0
+ */
+@JsonInclude(NON_EMPTY)
+public interface ProblemDetailJacksonMixin {
+
+ @JsonAnySetter
+ void setProperty(String name, Object value);
+
+ @JsonAnyGetter
+ Map getProperties();
+
+}
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 eb4c321a1e..24f00cb81b 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
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2021 the original author or authors.
+ * Copyright 2002-2022 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.
@@ -82,6 +82,7 @@ import kotlin.ranges.IntRange;
import org.junit.jupiter.api.Test;
import org.springframework.beans.FatalBeanException;
+import org.springframework.http.ProblemDetail;
import org.springframework.util.StringUtils;
import static org.assertj.core.api.Assertions.assertThat;
@@ -348,12 +349,13 @@ class Jackson2ObjectMapperBuilderTests {
Class> target = String.class;
Class> mixInSource = Object.class;
- ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
+ ObjectMapper mapper = Jackson2ObjectMapperBuilder.json()
.modules().mixIn(target, mixInSource)
.build();
- assertThat(objectMapper.mixInCount()).isEqualTo(1);
- assertThat(objectMapper.findMixInClassFor(target)).isSameAs(mixInSource);
+ assertThat(mapper.mixInCount()).isEqualTo(2);
+ assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class);
+ assertThat(mapper.findMixInClassFor(target)).isSameAs(mixInSource);
}
@Test
@@ -363,12 +365,13 @@ class Jackson2ObjectMapperBuilderTests {
Map, Class>> mixIns = new HashMap<>();
mixIns.put(target, mixInSource);
- ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
+ ObjectMapper mapper = Jackson2ObjectMapperBuilder.json()
.modules().mixIns(mixIns)
.build();
- assertThat(objectMapper.mixInCount()).isEqualTo(1);
- assertThat(objectMapper.findMixInClassFor(target)).isSameAs(mixInSource);
+ assertThat(mapper.mixInCount()).isEqualTo(2);
+ assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class);
+ assertThat(mapper.findMixInClassFor(target)).isSameAs(mixInSource);
}
@Test
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 d7a49c18ee..92a89e8fe0 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
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2021 the original author or authors.
+ * Copyright 2002-2022 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.
@@ -60,6 +60,7 @@ import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.FatalBeanException;
+import org.springframework.http.ProblemDetail;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -241,10 +242,11 @@ public class Jackson2ObjectMapperFactoryBeanTests {
this.factory.setModules(Collections.emptyList());
this.factory.setMixIns(mixIns);
this.factory.afterPropertiesSet();
- ObjectMapper objectMapper = this.factory.getObject();
+ ObjectMapper mapper = this.factory.getObject();
- assertThat(objectMapper.mixInCount()).isEqualTo(1);
- assertThat(objectMapper.findMixInClassFor(target)).isSameAs(mixinSource);
+ assertThat(mapper.mixInCount()).isEqualTo(2);
+ assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class);
+ assertThat(mapper.findMixInClassFor(target)).isSameAs(mixinSource);
}
@Test
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
new file mode 100644
index 0000000000..8632d66e72
--- /dev/null
+++ b/spring-web/src/test/java/org/springframework/http/converter/json/ProblemDetailJacksonMixinTests.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2002-2022 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 com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ProblemDetail;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for serializing a {@link org.springframework.http.ProblemDetail} through
+ * the Jackson library.
+ *
+ * @author Rossen Stoyanchev
+ * @since 6.0
+ */
+public class ProblemDetailJacksonMixinTests {
+
+ private final ObjectMapper mapper = new Jackson2ObjectMapperBuilder().build();
+
+ @Test
+ void writeStatusAndHeaders() throws Exception {
+ testWrite(
+ ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Missing header"),
+ "{\"type\":\"about:blank\"," +
+ "\"title\":\"Bad Request\"," +
+ "\"status\":400," +
+ "\"detail\":\"Missing header\"}");
+ }
+
+ @Test
+ void writeCustomProperty() throws Exception {
+ ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Missing header");
+ problemDetail.setProperty("host", "abc.org");
+
+ testWrite(problemDetail,
+ "{\"type\":\"about:blank\"," +
+ "\"title\":\"Bad Request\"," +
+ "\"status\":400," +
+ "\"detail\":\"Missing header\"," +
+ "\"host\":\"abc.org\"}");
+ }
+
+ @Test
+ void readCustomProperty() throws Exception {
+ ProblemDetail problemDetail = this.mapper.readValue(
+ "{\"type\":\"about:blank\"," +
+ "\"title\":\"Bad Request\"," +
+ "\"status\":400," +
+ "\"detail\":\"Missing header\"," +
+ "\"host\":\"abc.org\"}", 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");
+ }
+
+
+ private void testWrite(ProblemDetail problemDetail, String expected) throws Exception {
+ String output = this.mapper.writeValueAsString(problemDetail);
+ assertThat(output).isEqualTo(expected);
+ }
+
+}
diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java
index 9e58b32cdf..79ecdb84dc 100644
--- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java
+++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java
@@ -148,9 +148,7 @@ public class ResponseBodyResultHandlerTests {
"{\"type\":\"about:blank\"," +
"\"title\":\"Bad Request\"," +
"\"status\":400," +
- "\"detail\":null," +
- "\"instance\":\"/path\"," +
- "\"properties\":null}");
+ "\"instance\":\"/path\"}");
}
@Test
diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java
index 30b779bf2b..0eac1a5230 100644
--- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java
+++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java
@@ -259,9 +259,7 @@ public class ResponseEntityResultHandlerTests {
"{\"type\":\"about:blank\"," +
"\"title\":\"Bad Request\"," +
"\"status\":400," +
- "\"detail\":null," +
- "\"instance\":\"/path\"," +
- "\"properties\":null}");
+ "\"instance\":\"/path\"}");
}
@Test
@@ -280,9 +278,7 @@ public class ResponseEntityResultHandlerTests {
"{\"type\":\"about:blank\"," +
"\"title\":\"Bad Request\"," +
"\"status\":400," +
- "\"detail\":null," +
- "\"instance\":\"/path\"," +
- "\"properties\":null}");
+ "\"instance\":\"/path\"}");
}
@Test
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 58e7be0df6..e7018c11e1 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
@@ -433,9 +433,7 @@ public class RequestResponseBodyMethodProcessorTests {
"{\"type\":\"about:blank\"," +
"\"title\":\"Bad Request\"," +
"\"status\":400," +
- "\"detail\":null," +
- "\"instance\":\"/path\"," +
- "\"properties\":null}");
+ "\"instance\":\"/path\"}");
}
@Test // SPR-13135