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