Skip to content

Commit faf6c3a

Browse files
committed
feat: Move validation pointer and keyword into an errors array
Each failure is now an entry in an errors[] array with a #/... JSON-Pointer-in-fragment pointer. oneOf/anyOf failures list one entry per branch, deepest-first and de-duplicated.
1 parent 8249fe9 commit faf6c3a

6 files changed

Lines changed: 261 additions & 41 deletions

File tree

src/main/java/com/retailsvc/http/internal/ProblemDetail.java

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,70 @@
22

33
import com.retailsvc.http.BadRequestException;
44
import com.retailsvc.http.validate.ValidationError;
5+
import java.util.ArrayList;
6+
import java.util.Comparator;
7+
import java.util.LinkedHashSet;
8+
import java.util.List;
59
import java.util.Map;
610

711
/**
8-
* Carrier for an RFC 7807 problem+json document. Serialized by the registered JSON {@code
9-
* TypeMapper}; the wire shape and field-order are whatever the configured mapper produces — title
10-
* is advisory per RFC 7807 since {@code type} is always {@code about:blank}.
12+
* Carrier for an RFC 9457 problem+json document. Serialized by {@link ProblemDetailRenderer}; the
13+
* wire shape is the RFC core members (type, title, status, detail) plus an {@code errors} extension
14+
* array. Each {@link Entry} locates one validation failure with a JSON-Pointer-in-fragment {@code
15+
* pointer} and the failed {@code keyword}. {@code type} is always {@code about:blank}, so {@code
16+
* title} is advisory per the RFC.
1117
*/
1218
public record ProblemDetail(
13-
String type, String title, int status, String detail, String pointer, String keyword) {
19+
String type, String title, int status, String detail, List<Entry> errors) {
20+
21+
/** One validation failure: its body location, the failed keyword, and a human-readable detail. */
22+
public record Entry(String pointer, String keyword, String detail) {}
1423

1524
private static final String DEFAULT_TYPE = "about:blank";
1625

1726
public static ProblemDetail forValidation(ValidationError e) {
18-
return new ProblemDetail(
19-
DEFAULT_TYPE, "Bad Request", 400, e.message(), e.pointer(), e.keyword());
27+
return new ProblemDetail(DEFAULT_TYPE, "Bad Request", 400, e.message(), entriesOf(e));
2028
}
2129

2230
public static ProblemDetail forBadRequest(BadRequestException e) {
31+
List<Entry> errors =
32+
e.pointer()
33+
.map(p -> List.of(new Entry(fragment(p), e.keyword().orElse(null), e.getMessage())))
34+
.orElseGet(List::of);
2335
return new ProblemDetail(
24-
DEFAULT_TYPE,
25-
titleFor(e.status()),
26-
e.status(),
27-
e.getMessage(),
28-
e.pointer().orElse(null),
29-
e.keyword().orElse(null));
36+
DEFAULT_TYPE, titleFor(e.status()), e.status(), e.getMessage(), errors);
37+
}
38+
39+
/**
40+
* Flattens a validation error into ordered {@code errors} entries: the failed branches of a
41+
* combinator (one each), or the single leaf otherwise. Multi-entry results are sorted deepest
42+
* pointer first (most-likely-intended branch) and de-duplicated on exact equality.
43+
*/
44+
private static List<Entry> entriesOf(ValidationError e) {
45+
List<ValidationError> sources = e.branches().isEmpty() ? List.of(e) : e.branches();
46+
List<Entry> entries = new ArrayList<>(sources.size());
47+
for (ValidationError s : sources) {
48+
entries.add(new Entry(fragment(s.pointer()), s.keyword(), s.message()));
49+
}
50+
if (entries.size() <= 1) {
51+
return entries;
52+
}
53+
entries.sort(Comparator.comparingInt((Entry en) -> depth(en.pointer())).reversed());
54+
return new ArrayList<>(new LinkedHashSet<>(entries));
55+
}
56+
57+
private static String fragment(String pointer) {
58+
return "#" + pointer;
59+
}
60+
61+
private static int depth(String pointer) {
62+
int n = 0;
63+
for (int i = 0; i < pointer.length(); i++) {
64+
if (pointer.charAt(i) == '/') {
65+
n++;
66+
}
67+
}
68+
return n;
3069
}
3170

3271
private static final Map<Integer, String> TITLES =

src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
package com.retailsvc.http.internal;
22

33
import java.nio.charset.StandardCharsets;
4+
import java.util.List;
45

56
/**
6-
* Built-in JSON writer for the {@code application/problem+json} (RFC 7807) wire shape. Keeps the
7+
* Built-in JSON writer for the {@code application/problem+json} (RFC 9457) wire shape. Keeps the
78
* exception and security paths free of any {@code TypeMapper}, so the library can emit problem
89
* responses without a JSON library on the classpath (and without record-accessor reflection that
910
* GraalVM Native Image would otherwise need configured).
1011
*
11-
* <p>Null-valued fields are omitted, matching the default Gson encoding the library historically
12-
* produced.
12+
* <p>Null-valued fields and an empty {@code errors} array are omitted.
1313
*/
1414
public final class ProblemDetailRenderer {
1515

@@ -26,12 +26,35 @@ public static byte[] renderJson(ProblemDetail pd) {
2626
first = appendString(out, first, "title", pd.title());
2727
first = appendInt(out, first, "status", pd.status());
2828
first = appendString(out, first, "detail", pd.detail());
29-
first = appendString(out, first, "pointer", pd.pointer());
30-
appendString(out, first, "keyword", pd.keyword());
29+
appendErrors(out, first, pd.errors());
3130
out.append('}');
3231
return out.toString().getBytes(StandardCharsets.UTF_8);
3332
}
3433

34+
private static void appendErrors(
35+
StringBuilder out, boolean first, List<ProblemDetail.Entry> errors) {
36+
if (errors == null || errors.isEmpty()) {
37+
return;
38+
}
39+
if (!first) {
40+
out.append(',');
41+
}
42+
out.append("\"errors\":[");
43+
for (int i = 0; i < errors.size(); i++) {
44+
if (i > 0) {
45+
out.append(',');
46+
}
47+
ProblemDetail.Entry e = errors.get(i);
48+
out.append('{');
49+
boolean entryFirst = true;
50+
entryFirst = appendString(out, entryFirst, "pointer", e.pointer());
51+
entryFirst = appendString(out, entryFirst, "keyword", e.keyword());
52+
appendString(out, entryFirst, "detail", e.detail());
53+
out.append('}');
54+
}
55+
out.append(']');
56+
}
57+
3558
private static boolean appendString(StringBuilder out, boolean first, String name, String value) {
3659
if (value == null) {
3760
return first;

src/main/java/com/retailsvc/http/internal/SecurityFilter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ private void renderRejection(HttpExchange exchange, List<GroupOutcome.Failed> fa
119119
String detail = describe(pick);
120120

121121
ProblemDetail problemDetail =
122-
new ProblemDetail("about:blank", title, status, detail, null, null);
122+
new ProblemDetail("about:blank", title, status, detail, List.of());
123123
byte[] body = ProblemDetailRenderer.renderJson(problemDetail);
124124
exchange.getResponseHeaders().add("Content-Type", "application/problem+json");
125125
if (!anyDenied) {

src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.retailsvc.http.spec.HttpMethod;
1010
import com.retailsvc.http.validate.ValidationError;
1111
import java.nio.charset.StandardCharsets;
12+
import java.util.List;
1213
import java.util.Map;
1314
import java.util.Set;
1415
import org.junit.jupiter.api.AfterEach;
@@ -54,8 +55,14 @@ void validationExceptionRendersProblemJson() {
5455
String json = new String(bytes, StandardCharsets.UTF_8);
5556
@SuppressWarnings("unchecked")
5657
Map<String, Object> parsed = (Map<String, Object>) JSON.readFrom(bytes, "application/json");
57-
assertThat(parsed).containsEntry("keyword", "type");
5858
assertThat(((Number) parsed.get("status")).intValue()).isEqualTo(400);
59+
@SuppressWarnings("unchecked")
60+
List<Map<String, Object>> errors = (List<Map<String, Object>>) parsed.get("errors");
61+
assertThat(errors)
62+
.singleElement()
63+
.satisfies(
64+
entry ->
65+
assertThat(entry).containsEntry("pointer", "#/x").containsEntry("keyword", "type"));
5966
assertThat(json).contains("expected string");
6067
}
6168

@@ -73,9 +80,16 @@ void badRequestExceptionRendersProblemJsonWithCustomStatus() {
7380
assertThat(((Number) parsed.get("status")).intValue()).isEqualTo(422);
7481
assertThat(parsed)
7582
.containsEntry("title", "Unprocessable Content")
76-
.containsEntry("detail", "email taken")
77-
.containsEntry("pointer", "/email")
78-
.containsEntry("keyword", "unique");
83+
.containsEntry("detail", "email taken");
84+
@SuppressWarnings("unchecked")
85+
List<Map<String, Object>> errors = (List<Map<String, Object>>) parsed.get("errors");
86+
assertThat(errors)
87+
.singleElement()
88+
.satisfies(
89+
entry ->
90+
assertThat(entry)
91+
.containsEntry("pointer", "#/email")
92+
.containsEntry("keyword", "unique"));
7993
}
8094

8195
@Test

src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,85 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44

5+
import com.retailsvc.http.internal.ProblemDetail.Entry;
56
import java.nio.charset.StandardCharsets;
7+
import java.util.List;
68
import org.junit.jupiter.api.Test;
79

810
class ProblemDetailRendererTest {
911

1012
@Test
11-
void rendersAllFieldsWhenPresent() {
13+
void rendersSingleEntryErrorsArray() {
1214
ProblemDetail pd =
13-
new ProblemDetail("about:blank", "Bad Request", 400, "expected string", "/x", "type");
15+
new ProblemDetail(
16+
"about:blank",
17+
"Bad Request",
18+
400,
19+
"expected string",
20+
List.of(new Entry("#/x", "type", "expected string")));
1421
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
1522
.isEqualTo(
1623
"{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400,"
17-
+ "\"detail\":\"expected string\",\"pointer\":\"/x\",\"keyword\":\"type\"}");
24+
+ "\"detail\":\"expected string\",\"errors\":[{\"pointer\":\"#/x\","
25+
+ "\"keyword\":\"type\",\"detail\":\"expected string\"}]}");
1826
}
1927

2028
@Test
21-
void omitsNullPointerAndKeyword() {
29+
void rendersMultipleErrorEntries() {
2230
ProblemDetail pd =
23-
new ProblemDetail("about:blank", "Unauthorized", 401, "missing token", null, null);
31+
new ProblemDetail(
32+
"about:blank",
33+
"Bad Request",
34+
400,
35+
"matched 0 of 2 oneOf branches",
36+
List.of(
37+
new Entry("#/collar/size", "type", "expected integer"),
38+
new Entry("#/bark", "type", "expected boolean")));
39+
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
40+
.isEqualTo(
41+
"{\"type\":\"about:blank\",\"title\":\"Bad"
42+
+ " Request\",\"status\":400,\"detail\":\"matched 0 of 2 oneOf"
43+
+ " branches\",\"errors\":[{\"pointer\":\"#/collar/size\",\"keyword\":\"type\",\"detail\":\"expected"
44+
+ " integer\"},{\"pointer\":\"#/bark\",\"keyword\":\"type\",\"detail\":\"expected"
45+
+ " boolean\"}]}");
46+
}
47+
48+
@Test
49+
void omitsKeywordWithinEntryWhenNull() {
50+
ProblemDetail pd =
51+
new ProblemDetail(
52+
"about:blank",
53+
"Unprocessable Content",
54+
422,
55+
"email taken",
56+
List.of(new Entry("#/email", null, "email taken")));
57+
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
58+
.isEqualTo(
59+
"{\"type\":\"about:blank\",\"title\":\"Unprocessable Content\",\"status\":422,"
60+
+ "\"detail\":\"email taken\",\"errors\":[{\"pointer\":\"#/email\","
61+
+ "\"detail\":\"email taken\"}]}");
62+
}
63+
64+
@Test
65+
void omitsEmptyErrorsArray() {
66+
ProblemDetail pd =
67+
new ProblemDetail("about:blank", "Unauthorized", 401, "missing token", List.of());
2468
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
2569
.isEqualTo(
2670
"{\"type\":\"about:blank\",\"title\":\"Unauthorized\",\"status\":401,"
2771
+ "\"detail\":\"missing token\"}");
2872
}
2973

3074
@Test
31-
void omitsNullDetail() {
32-
ProblemDetail pd = new ProblemDetail("about:blank", "Not Found", 404, null, null, null);
75+
void omitsNullDetailAndEmptyErrors() {
76+
ProblemDetail pd = new ProblemDetail("about:blank", "Not Found", 404, null, List.of());
3377
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
3478
.isEqualTo("{\"type\":\"about:blank\",\"title\":\"Not Found\",\"status\":404}");
3579
}
3680

3781
@Test
3882
void escapesQuoteAndBackslashInDetail() {
39-
ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "a\"b\\c", null, null);
83+
ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "a\"b\\c", List.of());
4084
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
4185
.isEqualTo(
4286
"{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400,"
@@ -46,25 +90,16 @@ void escapesQuoteAndBackslashInDetail() {
4690
@Test
4791
void escapesNamedControlCharsInDetail() {
4892
ProblemDetail pd =
49-
new ProblemDetail("about:blank", "Bad Request", 400, "\b\f\n\r\t", null, null);
93+
new ProblemDetail("about:blank", "Bad Request", 400, "\b\f\n\r\t", List.of());
5094
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
5195
.isEqualTo(
5296
"{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400,"
5397
+ "\"detail\":\"\\b\\f\\n\\r\\t\"}");
5498
}
5599

56-
@Test
57-
void escapesUnnamedControlCharsAsHexUnicode() {
58-
ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "", null, null);
59-
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
60-
.isEqualTo(
61-
"{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400,"
62-
+ "\"detail\":\"\\u0001\\u001f\"}");
63-
}
64-
65100
@Test
66101
void passesThroughNonAsciiCharactersVerbatim() {
67-
ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "café-é", null, null);
102+
ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "café-é", List.of());
68103
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
69104
.isEqualTo(
70105
"{\"type\":\"about:blank\",\"title\":\"Bad"

0 commit comments

Comments
 (0)