Skip to content

Commit 380aedb

Browse files
committed
Add ProblemDetailJacksonMixin
Closes gh-28665
1 parent 1c03aaa commit 380aedb

File tree

9 files changed

+179
-30
lines changed

9 files changed

+179
-30
lines changed

spring-web/src/main/java/org/springframework/http/ProblemDetail.java

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,20 @@
2424
import org.springframework.util.Assert;
2525

2626
/**
27-
* Representation of an RFC 7807 problem detail, including all RFC-defined
28-
* properties.
27+
* Representation for an RFC 7807 problem detail. Includes spec-defined
28+
* properties, and a {@link #getProperties() properties} map for additional,
29+
* non-standard properties.
2930
*
30-
* <p>For an extended response, create a subclass with additional properties.
31-
* A subclass can use the {@link ProblemDetail#ProblemDetail(ProblemDetail)}
32-
* copy constructor to extend an existing {@code ProblemDetail} instance.
31+
* <p>For an extended response, an application can add to the
32+
* {@link #getProperties() properties} map. When using the Jackson library, the
33+
* {@code properties} map is expanded as top level JSON properties through the
34+
* {@link org.springframework.http.converter.json.ProblemDetailJacksonMixin}.
35+
*
36+
* <p>For an extended response, an application can also create a subclass with
37+
* additional properties. Subclasses can use the protected copy constructor to
38+
* re-create an existing {@code ProblemDetail} instance as the subclass, e.g.
39+
* from an {@code @ControllerAdvice} such as
40+
* {@link org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler}.
3341
*
3442
* @author Rossen Stoyanchev
3543
* @since 6.0
@@ -203,7 +211,9 @@ public URI getInstance() {
203211
}
204212

205213
/**
206-
* Return a generic map of properties that are not known ahead of time.
214+
* Return a generic map of properties that are not known ahead of time,
215+
* possibly {@code null} if no properties have been added. To add a property,
216+
* use {@link #setProperty(String, Object)}.
207217
*/
208218
@Nullable
209219
public Map<String, Object> getProperties() {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2022 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.
@@ -60,6 +60,7 @@
6060
import org.springframework.beans.FatalBeanException;
6161
import org.springframework.context.ApplicationContext;
6262
import org.springframework.core.KotlinDetector;
63+
import org.springframework.http.ProblemDetail;
6364
import org.springframework.lang.Nullable;
6465
import org.springframework.util.Assert;
6566
import org.springframework.util.ClassUtils;
@@ -730,6 +731,7 @@ else if (this.findWellKnownModules) {
730731
objectMapper.setFilterProvider(this.filters);
731732
}
732733

734+
objectMapper.addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class);
733735
this.mixIns.forEach(objectMapper::addMixIn);
734736

735737
if (!this.serializers.isEmpty() || !this.deserializers.isEmpty()) {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2002-2022 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.util.Map;
20+
21+
import com.fasterxml.jackson.annotation.JsonAnyGetter;
22+
import com.fasterxml.jackson.annotation.JsonAnySetter;
23+
import com.fasterxml.jackson.annotation.JsonInclude;
24+
25+
import org.springframework.http.ProblemDetail;
26+
27+
import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY;
28+
29+
/**
30+
* An interface to associate Jackson annotations with
31+
* {@link org.springframework.http.ProblemDetail} to avoid a hard dependency on
32+
* the Jackson library.
33+
*
34+
* <p>The annotations ensure the {@link ProblemDetail#getProperties() properties}
35+
* map is unwrapped and rendered as top level JSON properties, and likewise that
36+
* the {@code properties} map contains unknown properties from the JSON.
37+
*
38+
* <p>{@link Jackson2ObjectMapperBuilder} automatically registers this as a
39+
* "mix-in" for {@link ProblemDetail}, which means it always applies, unless
40+
* an {@code ObjectMapper} is instantiated directly and configured for use.
41+
*
42+
* @author Rossen Stoyanchev
43+
* @since 6.0
44+
*/
45+
@JsonInclude(NON_EMPTY)
46+
public interface ProblemDetailJacksonMixin {
47+
48+
@JsonAnySetter
49+
void setProperty(String name, Object value);
50+
51+
@JsonAnyGetter
52+
Map<String, Object> getProperties();
53+
54+
}

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2022 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.
@@ -82,6 +82,7 @@
8282
import org.junit.jupiter.api.Test;
8383

8484
import org.springframework.beans.FatalBeanException;
85+
import org.springframework.http.ProblemDetail;
8586
import org.springframework.util.StringUtils;
8687

8788
import static org.assertj.core.api.Assertions.assertThat;
@@ -348,12 +349,13 @@ void mixIn() {
348349
Class<?> target = String.class;
349350
Class<?> mixInSource = Object.class;
350351

351-
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
352+
ObjectMapper mapper = Jackson2ObjectMapperBuilder.json()
352353
.modules().mixIn(target, mixInSource)
353354
.build();
354355

355-
assertThat(objectMapper.mixInCount()).isEqualTo(1);
356-
assertThat(objectMapper.findMixInClassFor(target)).isSameAs(mixInSource);
356+
assertThat(mapper.mixInCount()).isEqualTo(2);
357+
assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class);
358+
assertThat(mapper.findMixInClassFor(target)).isSameAs(mixInSource);
357359
}
358360

359361
@Test
@@ -363,12 +365,13 @@ void mixIns() {
363365
Map<Class<?>, Class<?>> mixIns = new HashMap<>();
364366
mixIns.put(target, mixInSource);
365367

366-
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
368+
ObjectMapper mapper = Jackson2ObjectMapperBuilder.json()
367369
.modules().mixIns(mixIns)
368370
.build();
369371

370-
assertThat(objectMapper.mixInCount()).isEqualTo(1);
371-
assertThat(objectMapper.findMixInClassFor(target)).isSameAs(mixInSource);
372+
assertThat(mapper.mixInCount()).isEqualTo(2);
373+
assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class);
374+
assertThat(mapper.findMixInClassFor(target)).isSameAs(mixInSource);
372375
}
373376

374377
@Test

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2022 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.
@@ -60,6 +60,7 @@
6060
import org.junit.jupiter.api.Test;
6161

6262
import org.springframework.beans.FatalBeanException;
63+
import org.springframework.http.ProblemDetail;
6364

6465
import static org.assertj.core.api.Assertions.assertThat;
6566
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -241,10 +242,11 @@ public void setMixIns() {
241242
this.factory.setModules(Collections.emptyList());
242243
this.factory.setMixIns(mixIns);
243244
this.factory.afterPropertiesSet();
244-
ObjectMapper objectMapper = this.factory.getObject();
245+
ObjectMapper mapper = this.factory.getObject();
245246

246-
assertThat(objectMapper.mixInCount()).isEqualTo(1);
247-
assertThat(objectMapper.findMixInClassFor(target)).isSameAs(mixinSource);
247+
assertThat(mapper.mixInCount()).isEqualTo(2);
248+
assertThat(mapper.findMixInClassFor(ProblemDetail.class)).isAssignableFrom(ProblemDetailJacksonMixin.class);
249+
assertThat(mapper.findMixInClassFor(target)).isSameAs(mixinSource);
248250
}
249251

250252
@Test
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2002-2022 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+
20+
import java.net.URI;
21+
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import org.junit.jupiter.api.Test;
24+
25+
import org.springframework.http.HttpStatus;
26+
import org.springframework.http.ProblemDetail;
27+
28+
import static org.assertj.core.api.Assertions.assertThat;
29+
30+
/**
31+
* Tests for serializing a {@link org.springframework.http.ProblemDetail} through
32+
* the Jackson library.
33+
*
34+
* @author Rossen Stoyanchev
35+
* @since 6.0
36+
*/
37+
public class ProblemDetailJacksonMixinTests {
38+
39+
private final ObjectMapper mapper = new Jackson2ObjectMapperBuilder().build();
40+
41+
@Test
42+
void writeStatusAndHeaders() throws Exception {
43+
testWrite(
44+
ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Missing header"),
45+
"{\"type\":\"about:blank\"," +
46+
"\"title\":\"Bad Request\"," +
47+
"\"status\":400," +
48+
"\"detail\":\"Missing header\"}");
49+
}
50+
51+
@Test
52+
void writeCustomProperty() throws Exception {
53+
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Missing header");
54+
problemDetail.setProperty("host", "abc.org");
55+
56+
testWrite(problemDetail,
57+
"{\"type\":\"about:blank\"," +
58+
"\"title\":\"Bad Request\"," +
59+
"\"status\":400," +
60+
"\"detail\":\"Missing header\"," +
61+
"\"host\":\"abc.org\"}");
62+
}
63+
64+
@Test
65+
void readCustomProperty() throws Exception {
66+
ProblemDetail problemDetail = this.mapper.readValue(
67+
"{\"type\":\"about:blank\"," +
68+
"\"title\":\"Bad Request\"," +
69+
"\"status\":400," +
70+
"\"detail\":\"Missing header\"," +
71+
"\"host\":\"abc.org\"}", ProblemDetail.class);
72+
73+
assertThat(problemDetail.getType()).isEqualTo(URI.create("about:blank"));
74+
assertThat(problemDetail.getTitle()).isEqualTo("Bad Request");
75+
assertThat(problemDetail.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
76+
assertThat(problemDetail.getDetail()).isEqualTo("Missing header");
77+
assertThat(problemDetail.getProperties()).containsEntry("host", "abc.org");
78+
}
79+
80+
81+
private void testWrite(ProblemDetail problemDetail, String expected) throws Exception {
82+
String output = this.mapper.writeValueAsString(problemDetail);
83+
assertThat(output).isEqualTo(expected);
84+
}
85+
86+
}

spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,7 @@ private void testProblemDetailMediaType(MockServerWebExchange exchange, MediaTyp
148148
"{\"type\":\"about:blank\"," +
149149
"\"title\":\"Bad Request\"," +
150150
"\"status\":400," +
151-
"\"detail\":null," +
152-
"\"instance\":\"/path\"," +
153-
"\"properties\":null}");
151+
"\"instance\":\"/path\"}");
154152
}
155153

156154
@Test

spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,7 @@ public void handleErrorResponse() {
259259
"{\"type\":\"about:blank\"," +
260260
"\"title\":\"Bad Request\"," +
261261
"\"status\":400," +
262-
"\"detail\":null," +
263-
"\"instance\":\"/path\"," +
264-
"\"properties\":null}");
262+
"\"instance\":\"/path\"}");
265263
}
266264

267265
@Test
@@ -280,9 +278,7 @@ public void handleProblemDetail() {
280278
"{\"type\":\"about:blank\"," +
281279
"\"title\":\"Bad Request\"," +
282280
"\"status\":400," +
283-
"\"detail\":null," +
284-
"\"instance\":\"/path\"," +
285-
"\"properties\":null}");
281+
"\"instance\":\"/path\"}");
286282
}
287283

288284
@Test

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -433,9 +433,7 @@ private void testProblemDetailMediaType(String expectedContentType) throws Excep
433433
"{\"type\":\"about:blank\"," +
434434
"\"title\":\"Bad Request\"," +
435435
"\"status\":400," +
436-
"\"detail\":null," +
437-
"\"instance\":\"/path\"," +
438-
"\"properties\":null}");
436+
"\"instance\":\"/path\"}");
439437
}
440438

441439
@Test // SPR-13135

0 commit comments

Comments
 (0)