From 18d609ada457fedd0ea1e66fc0365692ea05559a Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 15:05:42 +0200 Subject: [PATCH 01/13] docs: Add design spec for validation errors[] array --- ...26-06-08-validation-errors-array-design.md | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-validation-errors-array-design.md diff --git a/docs/superpowers/specs/2026-06-08-validation-errors-array-design.md b/docs/superpowers/specs/2026-06-08-validation-errors-array-design.md new file mode 100644 index 0000000..c84457b --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-validation-errors-array-design.md @@ -0,0 +1,189 @@ +# Validation `errors[]` Array — Design + +**Date:** 2026-06-08 +**Status:** Approved +**Predecessor:** `2026-05-08-combinators-design.md` (Decision 2 deferred multi-error collection); `2026-05-07-openapi-refactor-design.md` §9 Wave 6 (orig #25, "multi-error collection") + +## Goal + +Make `oneOf` / `anyOf` validation failures actionable. Today a failed combinator produces a single, opaque problem document — e.g. `{"detail":"matched 0 of 2 oneOf branches","pointer":"/offers/0","keyword":"oneOf"}` — that hides the real cause. The per-branch `ValidationError`s are computed inside `checkOneOf` / `checkAnyOf` and then discarded. + +This design **retains** the per-branch errors and surfaces them by reshaping the `application/problem+json` document to the RFC 9457 validation idiom: `pointer` and `keyword` move out of the top level into per-entry objects inside an `errors[]` extension array. Each branch failure becomes one entry. Pointers adopt the RFC's `#/…` JSON-Pointer-in-fragment form. + +This is a **breaking change** to the wire shape (top-level `pointer` / `keyword` are removed), shipped as a feature. The current document is already RFC 9457-compliant; this change keeps it compliant while aligning with the RFC's own multiple-validation-error example. + +### Worked example (the motivating case) + +`POST /promotions` where `offers[]` is `oneOf: [UnitOffer, TotalOffer]` and the body sends `"minQuantity": "2"` (a string where the schema types it `number`). + +Before: +```json +{ "type":"about:blank", "title":"Bad Request", "status":400, + "detail":"matched 0 of 2 oneOf branches", "pointer":"/offers/0", "keyword":"oneOf" } +``` + +After: both `UnitOffer` and `TotalOffer` fail fast at the *same* shared leaf (`conditions` precedes `reward` in the body, so neither branch reaches `reward`), producing identical branch errors. After de-duplication (Decision 11) the array collapses to the single actionable cause: +```json +{ "type":"about:blank", "title":"Bad Request", "status":400, + "detail":"matched 0 of 2 oneOf branches", + "errors":[ + {"pointer":"#/offers/0/conditions/0/itemSet/minQuantity","keyword":"type","detail":"expected number"} + ] } +``` + +When branches instead fail at *different* places, every distinct cause is listed, deepest first (Decision 10). For `oneOf: [Cat, Dog]` given a body that nearly matches `Cat` (failing deep inside a nested `collar`) but is far from `Dog` (a shallow type error): +```json +{ "type":"about:blank", "title":"Bad Request", "status":400, + "detail":"matched 0 of 2 oneOf branches", + "errors":[ + {"pointer":"#/pet/collar/size","keyword":"type","detail":"expected integer"}, + {"pointer":"#/pet/bark","keyword":"type","detail":"expected boolean"} + ] } +``` +`errors[0]` is the deeper `Cat` failure (`/pet/collar/size`, depth 3 — the branch the payload most resembles), ahead of the shallow `Dog` failure (`/pet/bark`, depth 2). + +## Decisions + +1. **`errors[]` everywhere, not just combinators.** Every validation failure renders an `errors[]` array. A non-combinator failure (e.g. a type mismatch or missing required property) yields a single-entry array; a `oneOf` / `anyOf` failure yields one entry per failed branch. This keeps one consistent shape rather than "an array only when a combinator is involved." +2. **Top-level `pointer` / `keyword` removed.** They live only inside `errors[]` entries now. This is the approved breaking change. Top-level retains the RFC core members plus `detail`. +3. **Top-level `detail` = the failing node's own message.** For a combinator that means the summary (`"matched 0 of 2 oneOf branches"`); for a leaf failure it is that leaf's message (`"expected number"`). The summary is honest because `errors[]` now carries the specifics. We do **not** promote a "best branch" message to the top level (rejected as guesswork; the array already exposes every branch). +4. **Pointer form `#/…`.** Entries express the body location as a JSON Pointer in a URI fragment (`"#/offers/0"`, root = `"#"`), matching the RFC 9457 example (`"#/age"`). Today's plain `/…` form is replaced. +5. **`keyword` retained as a per-entry extension.** Useful to clients; permitted by the RFC even though its illustrative example omits it. +6. **`instance` not added.** It is optional under RFC 9457, so its absence is not a compliance gap, and the default `ExceptionHandler` is `handle(Throwable)` with no request context by design (its javadoc directs context-aware mapping to a `RequestInterceptor`). A request-path `instance` would require breaking that public SPI. Deferred; a `urn:uuid` correlation id is a possible future additive option. +7. **One level of flattening.** `errors[]` entries are the immediate failed branches of the failing combinator (or the single leaf for a non-combinator failure). A branch that is itself a nested combinator contributes one entry carrying its own summary (`detail` = `"matched 0 of N …"`, `pointer` = the nested combinator's location); its sub-branches are not recursively expanded in v1. This bounds output size and matches the fail-fast model — each branch's `check()` already collapses to a single `ValidationError`. +8. **`oneOf` "too many matches" yields an empty `errors[]`.** When `matched ≥ 2`, the problem is ambiguity, not a bad field; there are no failed-branch errors worth listing. The array is empty and therefore omitted, leaving just the summary `detail`. Same for any failure whose node has no sub-errors. +9. **No happy-path cost.** `anyOf` still short-circuits on the first matching branch (no list built). `oneOf` already must evaluate every branch to count matches; the branch-error list is built only on the failure path (`matched != 1`). `ValidationException.CONSTRUCTIONS` stays at zero for valid combinator bodies. +10. **`errors[]` ordered "most likely first."** Entries are sorted by descending failure depth — the number of path segments in the entry's pointer — so the branch that validated the most structure before failing (most likely the branch the client intended) comes first. Ties keep schema order (stable sort). This is a best-effort heuristic, not a guarantee; it is cheap, deterministic, and degrades gracefully (a "wrong" guess still lists a real branch error). Sorting is a presentation concern applied when building entries (see below); the validator keeps `branches` in natural schema order. A single-entry (leaf) or empty (too-many-matches) array is unaffected. +11. **Identical entries are de-duplicated.** When branches share structure they often fail at the exact same leaf (same `pointer` + `keyword` + `detail`) — the motivating promotion payload does. Such exact duplicates collapse to a single entry, keeping the first occurrence (so order from Decision 10 is preserved). Only fully-identical entries collapse; two failures at the same pointer for different reasons both remain. This removes pure noise without losing information. + +## Data model + +`ValidationError` gains an ordered list of sub-errors (the failed branches). Existing call sites are preserved with a convenience constructor that defaults to no branches. + +```java +public record ValidationError( + String pointer, String keyword, String message, Object rejectedValue, + List branches) { + + public ValidationError(String pointer, String keyword, String message, Object rejectedValue) { + this(pointer, keyword, message, rejectedValue, List.of()); + } + + public ValidationError { + branches = List.copyOf(branches); + } +} +``` + +- **Leaf error:** `branches` empty. Renders as a single `errors[]` entry built from its own `pointer` / `keyword` / `message`. +- **Combinator error:** `branches` holds each failed branch's `ValidationError`. Renders as one `errors[]` entry per branch. + +`ValidationException` is unchanged (its message string still derives from the top-level `pointer` / `keyword` / `message`). + +## Validator + +`checkOneOf` / `checkAnyOf` in `DefaultValidator` stop discarding branch results. + +```java +private Optional checkAnyOf(Object value, List options, String pointer) { + List failures = new ArrayList<>(); + for (Schema o : options) { + Optional r = check(value, o, pointer); + if (r.isEmpty()) { + return OK; // short-circuit; no list retained on success + } + failures.add(r.get()); + } + return Optional.of(new ValidationError( + pointer, "anyOf", "did not match any anyOf branch", value, failures)); +} + +private Optional checkOneOf(Object value, List options, String pointer) { + int matched = 0; + List failures = new ArrayList<>(); + for (Schema o : options) { + Optional r = check(value, o, pointer); + if (r.isEmpty()) { + matched++; + } else { + failures.add(r.get()); + } + } + if (matched == 1) { + return OK; + } + return Optional.of(new ValidationError( + pointer, "oneOf", + "matched " + matched + " of " + options.size() + " oneOf branches", + value, + matched == 0 ? failures : List.of())); // ≥2 matches → ambiguity, no field-level errors +} +``` + +`allOf` / `not` / object / array paths are unchanged — they already propagate the real first-failure `ValidationError`, which now simply renders as a single-entry `errors[]`. + +## Problem detail and renderer + +`ProblemDetail` drops top-level `pointer` / `keyword` and gains an `errors` list. A nested record models each entry. + +```java +public record ProblemDetail( + String type, String title, int status, String detail, List errors) { + + public record Entry(String pointer, String keyword, String detail) {} + + public static ProblemDetail forValidation(ValidationError e) { + return new ProblemDetail(DEFAULT_TYPE, "Bad Request", 400, e.message(), entriesOf(e)); + } + + public static ProblemDetail forBadRequest(BadRequestException e) { + List errors = e.pointer() + .map(p -> List.of(new Entry(fragment(p), e.keyword().orElse(null), e.getMessage()))) + .orElseGet(List::of); + return new ProblemDetail(DEFAULT_TYPE, titleFor(e.status()), e.status(), e.getMessage(), errors); + } +} +``` + +- `entriesOf(e)` = one `Entry` per branch when `e.branches()` is non-empty, else a single `Entry` from `e` itself. `fragment(p)` = `"#" + p` (root `""` → `"#"`). +- When there is more than one entry, sort them by descending pointer depth (segment count, i.e. number of `/` in the pre-fragment pointer), stable on ties (Decision 10), then drop exact `(pointer, keyword, detail)` duplicates keeping the first (Decision 11), so the deepest — most-likely-intended — branch is `errors[0]`. +- A `BadRequestException` with no `pointer` produces an empty `errors` list. + +`ProblemDetailRenderer` replaces the two top-level `pointer` / `keyword` appends with an `errors` array writer: + +- Emit `"errors":[ … ]` only when the list is non-empty (consistent with the existing null-omission rule). +- Each entry is `{"pointer":"#/…","keyword":"…","detail":"…"}`; omit `keyword` within an entry when null (the `BadRequestException`-without-keyword case). `pointer` and `detail` are always present in an emitted entry. +- Reuse `JsonStrings.appendQuoted` for escaping. No JSON library is introduced; GraalVM-native friendliness is preserved. + +`Handlers.defaultExceptionHandler` is unchanged in structure — it still calls `ProblemDetail.forValidation` / `forBadRequest` and renders via `ProblemDetailRenderer`. + +## Tests + +- **`ValidationError`:** convenience constructor defaults `branches` to empty; canonical constructor copies the list (defensive). +- **`DefaultValidator` (unit):** + - `oneOf` zero matches: top-level message keeps `"matched 0 of N"`; `branches` contains one error per branch with their real pointers/keywords/messages. + - `oneOf` two matches: `branches` is empty (Decision 8). + - `anyOf` no match: `branches` contains every branch's error. + - Happy paths (`oneOf` exactly one, `anyOf` first match): return `OK`, `branches` never built, `ValidationException.CONSTRUCTIONS` unchanged — extend `ValidatorNoThrowOnHappyPathTest`. + - Nested combinator branch surfaces as a single summary entry (Decision 7). +- **`ProblemDetail` / `ProblemDetailRenderer`:** rewrite `ProblemDetailRendererTest` for the new shape — single-entry `errors[]` for a leaf, multi-entry for a combinator, `#/…` pointer form, `keyword` omitted when absent, `errors` omitted when empty. Round-trip the JSON through the test `JsonMapper`. +- **Ordering (Decision 10):** a combinator failure whose branches fail at different depths puts the deepest entry at `errors[0]`; a test asserts the order, including a stable-on-ties case (two branches failing at equal depth keep schema order). +- **De-duplication (Decision 11):** two branches failing at the identical leaf (the promotion-style shared-structure case) collapse to one entry; two failures at the same pointer with different `keyword`/`detail` both remain. +- **`HandlersDefaultExceptionTest`:** update the `containsEntry("pointer", "/email")` / `containsEntry("keyword", …)` assertions to read from `errors[0]` and expect the `#/email` fragment form. +- **`OpenApiServerIT`:** the existing `contains("pointer")` / `contains("keyword")` substring checks still hold (both keys now live inside entries); add one assertion that a `oneOf` body failure returns multiple `errors[]` entries. +- **Integration (the worked example):** extend the test fixture (`src/test/resources/openapi.{yaml,json}`, kept in sync) with a `oneOf` body whose branches fail at different depths, and assert the response lists the deep leaf error. Prefer mutating the existing fixture per the minimize-fixtures convention rather than adding a new file. + +## Risk and rollback + +- **Breaking wire change.** Any consumer reading top-level `pointer` / `keyword` breaks. Accepted and released as a feature; called out in the changelog/README. The `#/…` pointer form is an additional value change for clients that parsed the old `/…` form. +- **Output size for wide unions.** A `oneOf` with many branches emits one entry per failed branch. Bounded by branch count and one-level flattening (Decision 7); acceptable. If a pathological spec makes this large, a future cap is additive. +- **Scope boundary.** This is *not* full multi-error collection (orig #25): non-combinator failures remain fail-fast (single entry). Whole-request error gathering stays deferred; the `errors[]` shape introduced here is forward-compatible with it. +- **Rollback.** Contained to `ValidationError`, `DefaultValidator` (two methods), `ProblemDetail`, `ProblemDetailRenderer`, and the affected tests. Revert per file. + +## Sequencing + +Single PR. Suggested commit shape, each verifiable with `mvn -q verify`: + +1. `ValidationError` branches field + `DefaultValidator` `checkOneOf` / `checkAnyOf` retaining branch errors; validator unit tests. +2. `ProblemDetail` reshape + `ProblemDetailRenderer` `errors[]` writer; renderer/handler unit tests. +3. Integration fixture + end-to-end test; README/changelog note on the breaking shape. From 097d312bb16996cd6aab4deae085258b08824754 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 15:15:03 +0200 Subject: [PATCH 02/13] docs: Add implementation plan for validation errors array --- .../2026-06-08-validation-errors-array.md | 858 ++++++++++++++++++ 1 file changed, 858 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-validation-errors-array.md diff --git a/docs/superpowers/plans/2026-06-08-validation-errors-array.md b/docs/superpowers/plans/2026-06-08-validation-errors-array.md new file mode 100644 index 0000000..5396367 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-validation-errors-array.md @@ -0,0 +1,858 @@ +# Validation `errors[]` Array Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface `oneOf`/`anyOf` validation failures by reshaping the `application/problem+json` body to the RFC 9457 idiom — top-level `pointer`/`keyword` move into a per-branch `errors[]` array (deepest-first, de-duplicated). + +**Architecture:** `ValidationError` gains a `branches` list; `DefaultValidator.checkOneOf`/`checkAnyOf` populate it on failure instead of discarding branch results. `ProblemDetail` drops top-level `pointer`/`keyword` and carries `List errors`, built by an `entriesOf` helper that fragments pointers (`#/…`), sorts by descending depth, and de-dups exact entries. `ProblemDetailRenderer` emits the array with the existing hand-rolled JSON writer (no JSON library). + +**Tech Stack:** Java 25, JUnit 5, AssertJ, Maven (Surefire `mvn test`, Failsafe `mvn verify`). Google Java Format enforced by pre-commit. Reference spec: `docs/superpowers/specs/2026-06-08-validation-errors-array-design.md`. + +**Conventions (from repo memory):** always use curly braces; camelCase test method names; static imports for AssertJ/JUnit; no inline fully-qualified type names (add imports); LSP/SonarLint are blind to this worktree — rely on `mvn`. Commit subjects are Conventional Commits, capitalised after the colon, **no** `Co-Authored-By` trailer. + +--- + +### Task 1: Add `branches` to `ValidationError` + +Adds the sub-error list. Behaviour-neutral and compile-safe: a four-arg convenience constructor keeps every existing `new ValidationError(p, k, m, rv)` call site (the `err(...)` helpers and tests) working. + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/validate/ValidationError.java` +- Test: `src/test/java/com/retailsvc/http/validate/ValidationErrorTest.java` (create) + +- [ ] **Step 1: Write the failing test** + +Create `src/test/java/com/retailsvc/http/validate/ValidationErrorTest.java`: + +```java +package com.retailsvc.http.validate; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ValidationErrorTest { + + @Test + void convenienceConstructorDefaultsToNoBranches() { + var e = new ValidationError("/x", "type", "expected string", null); + assertThat(e.branches()).isEmpty(); + } + + @Test + void canonicalConstructorCopiesBranchesDefensively() { + var branch = new ValidationError("/x/y", "type", "expected number", "s"); + var mutable = new ArrayList(List.of(branch)); + + var e = new ValidationError("/x", "oneOf", "matched 0 of 1 oneOf branches", "s", mutable); + mutable.clear(); + + assertThat(e.branches()).containsExactly(branch); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=ValidationErrorTest` +Expected: COMPILE FAILURE — the five-arg constructor and `branches()` accessor do not exist yet. + +- [ ] **Step 3: Write minimal implementation** + +Replace the entire body of `src/main/java/com/retailsvc/http/validate/ValidationError.java` with: + +```java +package com.retailsvc.http.validate; + +import java.util.List; + +public record ValidationError( + String pointer, String keyword, String message, Object rejectedValue, + List branches) { + + public ValidationError { + branches = List.copyOf(branches); + } + + /** Leaf error with no branch sub-errors. */ + public ValidationError(String pointer, String keyword, String message, Object rejectedValue) { + this(pointer, keyword, message, rejectedValue, List.of()); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -q test -Dtest=ValidationErrorTest` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/validate/ValidationError.java \ + src/test/java/com/retailsvc/http/validate/ValidationErrorTest.java +git commit -m "feat: Add branch errors to ValidationError" +``` + +--- + +### Task 2: Retain branch errors in `checkOneOf` / `checkAnyOf` + +The validator stops discarding per-branch results. On the failure path it builds the combinator `ValidationError` with `branches` populated (schema order — sorting/de-dup are a presentation concern done later in `ProblemDetail`). The top-level message is unchanged, so the existing `DefaultValidatorDispatchTest` count assertions stay green, and the happy path still allocates no list. + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/validate/DefaultValidator.java:529-553` (`checkAnyOf`, `checkOneOf`) +- Test: `src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java` (add tests) + +- [ ] **Step 1: Write the failing tests** + +In `src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java`, add these three methods inside the class (next to `oneOfFailsWhenZeroBranchesMatch`). They rely on the existing `v` validator and `stringSchema(min, max)` helper already in that file: + +```java + @Test + void oneOfZeroMatchesCapturesEachBranchError() { + // "hello" (len 5): branch[0] minLength 100 fails, branch[1] maxLength 2 fails. + var schema = new OneOfSchema(List.of(stringSchema(100, null), stringSchema(null, 2)), Map.of()); + assertThatThrownBy(() -> v.validate("hello", schema, "/v")) + .isInstanceOf(ValidationException.class) + .satisfies( + t -> { + var err = ((ValidationException) t).error(); + assertThat(err.branches()) + .extracting(ValidationError::keyword) + .containsExactly("minLength", "maxLength"); + }); + } + + @Test + void oneOfTwoMatchesHasNoBranchErrors() { + // both branches accept "hello" — ambiguity, not a field error. + var schema = new OneOfSchema(List.of(stringSchema(null, 10), stringSchema(1, null)), Map.of()); + assertThatThrownBy(() -> v.validate("hello", schema, "/v")) + .isInstanceOf(ValidationException.class) + .satisfies(t -> assertThat(((ValidationException) t).error().branches()).isEmpty()); + } + + @Test + void anyOfNoMatchCapturesEachBranchError() { + var schema = new AnyOfSchema(List.of(stringSchema(100, null), stringSchema(null, 2)), Map.of()); + assertThatThrownBy(() -> v.validate("hello", schema, "/v")) + .isInstanceOf(ValidationException.class) + .satisfies( + t -> { + var err = ((ValidationException) t).error(); + assertThat(err.branches()) + .extracting(ValidationError::keyword) + .containsExactly("minLength", "maxLength"); + }); + } +``` + +If `ValidationError` is not already imported in this test file, add `import com.retailsvc.http.validate.ValidationError;` — it is in the same package `com.retailsvc.http.validate`, so no import is needed; reference `ValidationError::keyword` directly. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn -q test -Dtest=DefaultValidatorDispatchTest` +Expected: FAIL — `branches()` is empty (the current `checkOneOf`/`checkAnyOf` discard branch errors), so `containsExactly("minLength","maxLength")` fails. + +- [ ] **Step 3: Write minimal implementation** + +In `src/main/java/com/retailsvc/http/validate/DefaultValidator.java`, replace the two methods `checkAnyOf` and `checkOneOf` (currently lines 529-553) with: + +```java + private Optional checkAnyOf(Object value, List options, String pointer) { + List failures = new ArrayList<>(); + for (Schema o : options) { + Optional result = check(value, o, pointer); + if (result.isEmpty()) { + return OK; + } + failures.add(result.get()); + } + return Optional.of( + new ValidationError(pointer, "anyOf", "did not match any anyOf branch", value, failures)); + } + + private Optional checkOneOf(Object value, List options, String pointer) { + int matched = 0; + List failures = new ArrayList<>(); + for (Schema o : options) { + Optional result = check(value, o, pointer); + if (result.isEmpty()) { + matched++; + } else { + failures.add(result.get()); + } + } + if (matched == 1) { + return OK; + } + return Optional.of( + new ValidationError( + pointer, + "oneOf", + "matched " + matched + " of " + options.size() + " oneOf branches", + value, + matched == 0 ? failures : List.of())); + } +``` + +`java.util.ArrayList` and `java.util.List` are already imported in this file. No new imports needed. + +- [ ] **Step 4: Run the full validator + happy-path suites** + +Run: `mvn -q test -Dtest=DefaultValidatorDispatchTest,ValidatorNoThrowOnHappyPathTest` +Expected: PASS. The pre-existing `oneOfFailsWhenZeroBranchesMatch` / `oneOfFailsWhenTwoBranchesMatch` / `oneOfWithEmptyOptionsAlwaysFails` count assertions still hold (message unchanged); `failingOneOfConstructsExactlyOneValidationException` still passes (branch `ValidationError`s do not increment `ValidationException.CONSTRUCTIONS`). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/validate/DefaultValidator.java \ + src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java +git commit -m "feat: Retain oneOf/anyOf branch errors in validator" +``` + +--- + +### Task 3: Reshape `ProblemDetail` + renderer to `errors[]` (BREAKING) + +This is the breaking wire change. `ProblemDetail`'s constructor signature changes, which forces `ProblemDetailRenderer`, `ProblemDetailRendererTest`, and the assertions in `HandlersDefaultExceptionTest` to change in the **same commit** so the build stays green. (`OpenApiServerIT`'s `contains("pointer")`/`contains("keyword")` substring checks still pass — those keys now live inside entries — so it is not touched here.) + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/internal/ProblemDetail.java` +- Modify: `src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java` +- Test: `src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java` (rewrite) +- Test: `src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java` (create — covers `entriesOf` ordering/de-dup via the public factories) +- Test: `src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java` (update two assertions) + +- [ ] **Step 1: Write the new renderer test (rewrite the file)** + +Replace the entire contents of `src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java` with: + +```java +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.internal.ProblemDetail.Entry; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ProblemDetailRendererTest { + + @Test + void rendersSingleEntryErrorsArray() { + ProblemDetail pd = + new ProblemDetail( + "about:blank", "Bad Request", 400, "expected string", + List.of(new Entry("#/x", "type", "expected string"))); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," + + "\"detail\":\"expected string\",\"errors\":[{\"pointer\":\"#/x\"," + + "\"keyword\":\"type\",\"detail\":\"expected string\"}]}"); + } + + @Test + void rendersMultipleErrorEntries() { + ProblemDetail pd = + new ProblemDetail( + "about:blank", "Bad Request", 400, "matched 0 of 2 oneOf branches", + List.of( + new Entry("#/collar/size", "type", "expected integer"), + new Entry("#/bark", "type", "expected boolean"))); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," + + "\"detail\":\"matched 0 of 2 oneOf branches\",\"errors\":[" + + "{\"pointer\":\"#/collar/size\",\"keyword\":\"type\",\"detail\":\"expected integer\"}," + + "{\"pointer\":\"#/bark\",\"keyword\":\"type\",\"detail\":\"expected boolean\"}]}"); + } + + @Test + void omitsKeywordWithinEntryWhenNull() { + ProblemDetail pd = + new ProblemDetail( + "about:blank", "Unprocessable Content", 422, "email taken", + List.of(new Entry("#/email", null, "email taken"))); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Unprocessable Content\",\"status\":422," + + "\"detail\":\"email taken\",\"errors\":[{\"pointer\":\"#/email\"," + + "\"detail\":\"email taken\"}]}"); + } + + @Test + void omitsEmptyErrorsArray() { + ProblemDetail pd = + new ProblemDetail("about:blank", "Unauthorized", 401, "missing token", List.of()); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Unauthorized\",\"status\":401," + + "\"detail\":\"missing token\"}"); + } + + @Test + void omitsNullDetailAndEmptyErrors() { + ProblemDetail pd = new ProblemDetail("about:blank", "Not Found", 404, null, List.of()); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo("{\"type\":\"about:blank\",\"title\":\"Not Found\",\"status\":404}"); + } + + @Test + void escapesQuoteAndBackslashInDetail() { + ProblemDetail pd = + new ProblemDetail("about:blank", "Bad Request", 400, "a\"b\\c", List.of()); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," + + "\"detail\":\"a\\\"b\\\\c\"}"); + } + + @Test + void escapesNamedControlCharsInDetail() { + ProblemDetail pd = + new ProblemDetail("about:blank", "Bad Request", 400, "\b\f\n\r\t", List.of()); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," + + "\"detail\":\"\\b\\f\\n\\r\\t\"}"); + } + + @Test + void passesThroughNonAsciiCharactersVerbatim() { + ProblemDetail pd = + new ProblemDetail("about:blank", "Bad Request", 400, "café-é", List.of()); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Bad" + + " Request\",\"status\":400,\"detail\":\"café-é\"}"); + } + + private static String asString(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `mvn -q test -Dtest=ProblemDetailRendererTest` +Expected: COMPILE FAILURE — `ProblemDetail` has no five-arg `(…, List)` constructor and no nested `Entry` type yet. + +- [ ] **Step 3: Reshape `ProblemDetail`** + +Replace the entire contents of `src/main/java/com/retailsvc/http/internal/ProblemDetail.java` with: + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.BadRequestException; +import com.retailsvc.http.validate.ValidationError; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +/** + * Carrier for an RFC 9457 problem+json document. Serialized by {@link ProblemDetailRenderer}; the + * wire shape is the RFC core members (type, title, status, detail) plus an {@code errors} extension + * array. Each {@link Entry} locates one validation failure with a JSON-Pointer-in-fragment {@code + * pointer} and the failed {@code keyword}. {@code type} is always {@code about:blank}, so {@code + * title} is advisory per the RFC. + */ +public record ProblemDetail( + String type, String title, int status, String detail, List errors) { + + /** One validation failure: its body location, the failed keyword, and a human-readable detail. */ + public record Entry(String pointer, String keyword, String detail) {} + + private static final String DEFAULT_TYPE = "about:blank"; + + public static ProblemDetail forValidation(ValidationError e) { + return new ProblemDetail(DEFAULT_TYPE, "Bad Request", 400, e.message(), entriesOf(e)); + } + + public static ProblemDetail forBadRequest(BadRequestException e) { + List errors = + e.pointer() + .map(p -> List.of(new Entry(fragment(p), e.keyword().orElse(null), e.getMessage()))) + .orElseGet(List::of); + return new ProblemDetail(DEFAULT_TYPE, titleFor(e.status()), e.status(), e.getMessage(), errors); + } + + /** + * Flattens a validation error into ordered {@code errors} entries: the failed branches of a + * combinator (one each), or the single leaf otherwise. Multi-entry results are sorted deepest + * pointer first (most-likely-intended branch) and de-duplicated on exact equality. + */ + private static List entriesOf(ValidationError e) { + List sources = e.branches().isEmpty() ? List.of(e) : e.branches(); + List entries = new ArrayList<>(sources.size()); + for (ValidationError s : sources) { + entries.add(new Entry(fragment(s.pointer()), s.keyword(), s.message())); + } + if (entries.size() <= 1) { + return entries; + } + entries.sort(Comparator.comparingInt((Entry en) -> depth(en.pointer())).reversed()); + return new ArrayList<>(new LinkedHashSet<>(entries)); + } + + private static String fragment(String pointer) { + return "#" + pointer; + } + + private static int depth(String pointer) { + int n = 0; + for (int i = 0; i < pointer.length(); i++) { + if (pointer.charAt(i) == '/') { + n++; + } + } + return n; + } + + private static final Map TITLES = + Map.of( + 400, "Bad Request", + 401, "Unauthorized", + 403, "Forbidden", + 404, "Not Found", + 405, "Method Not Allowed", + 409, "Conflict", + 410, "Gone", + 412, "Precondition Failed", + 415, "Unsupported Media Type", + 422, "Unprocessable Content"); + + private static String titleFor(int status) { + return TITLES.getOrDefault(status, "Bad Request"); + } +} +``` + +- [ ] **Step 4: Rewrite the renderer's `errors[]` writer** + +Replace the entire contents of `src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java` with: + +```java +package com.retailsvc.http.internal; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Built-in JSON writer for the {@code application/problem+json} (RFC 9457) wire shape. Keeps the + * exception and security paths free of any {@code TypeMapper}, so the library can emit problem + * responses without a JSON library on the classpath (and without record-accessor reflection that + * GraalVM Native Image would otherwise need configured). + * + *

Null-valued fields and an empty {@code errors} array are omitted. + */ +public final class ProblemDetailRenderer { + + /** Initial capacity sized for a typical problem-detail document. */ + private static final int INITIAL_CAPACITY = 128; + + private ProblemDetailRenderer() {} + + public static byte[] renderJson(ProblemDetail pd) { + StringBuilder out = new StringBuilder(INITIAL_CAPACITY); + out.append('{'); + boolean first = true; + first = appendString(out, first, "type", pd.type()); + first = appendString(out, first, "title", pd.title()); + first = appendInt(out, first, "status", pd.status()); + first = appendString(out, first, "detail", pd.detail()); + appendErrors(out, first, pd.errors()); + out.append('}'); + return out.toString().getBytes(StandardCharsets.UTF_8); + } + + private static void appendErrors(StringBuilder out, boolean first, List errors) { + if (errors == null || errors.isEmpty()) { + return; + } + if (!first) { + out.append(','); + } + out.append("\"errors\":["); + for (int i = 0; i < errors.size(); i++) { + if (i > 0) { + out.append(','); + } + ProblemDetail.Entry e = errors.get(i); + out.append('{'); + boolean entryFirst = true; + entryFirst = appendString(out, entryFirst, "pointer", e.pointer()); + entryFirst = appendString(out, entryFirst, "keyword", e.keyword()); + appendString(out, entryFirst, "detail", e.detail()); + out.append('}'); + } + out.append(']'); + } + + private static boolean appendString(StringBuilder out, boolean first, String name, String value) { + if (value == null) { + return first; + } + if (!first) { + out.append(','); + } + out.append('"').append(name).append("\":"); + JsonStrings.appendQuoted(out, value); + return false; + } + + private static boolean appendInt(StringBuilder out, boolean first, String name, int value) { + if (!first) { + out.append(','); + } + out.append('"').append(name).append("\":").append(value); + return false; + } +} +``` + +- [ ] **Step 5: Update `HandlersDefaultExceptionTest` assertions** + +In `src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java`, the two assertions that read top-level `pointer`/`keyword` must read from `errors[0]` instead. + +Replace the body of `validationExceptionRendersProblemJson` (lines 44-60) — keep the setup, change the final assertions: + +```java + @Test + void validationExceptionRendersProblemJson() { + Response resp = + Handlers.defaultExceptionHandler() + .handle( + new ValidationException( + new ValidationError("/x", "type", "expected string", null))); + + assertThat(resp.status()).isEqualTo(400); + assertThat(resp.contentType()).isEqualTo("application/problem+json"); + byte[] bytes = (byte[]) resp.body(); + String json = new String(bytes, StandardCharsets.UTF_8); + @SuppressWarnings("unchecked") + Map parsed = (Map) JSON.readFrom(bytes, "application/json"); + assertThat(((Number) parsed.get("status")).intValue()).isEqualTo(400); + @SuppressWarnings("unchecked") + List> errors = (List>) parsed.get("errors"); + assertThat(errors).singleElement().satisfies(entry -> { + assertThat(entry).containsEntry("pointer", "#/x").containsEntry("keyword", "type"); + }); + assertThat(json).contains("expected string"); + } +``` + +Replace the final assertion block of `badRequestExceptionRendersProblemJsonWithCustomStatus` (lines 73-78) — keep setup through the `status` assertion, then: + +```java + assertThat(((Number) parsed.get("status")).intValue()).isEqualTo(422); + assertThat(parsed) + .containsEntry("title", "Unprocessable Content") + .containsEntry("detail", "email taken"); + @SuppressWarnings("unchecked") + List> errors = (List>) parsed.get("errors"); + assertThat(errors).singleElement().satisfies(entry -> { + assertThat(entry).containsEntry("pointer", "#/email").containsEntry("keyword", "unique"); + }); +``` + +Add `import java.util.List;` to the file's imports if not present (it currently imports `java.util.Map` and `java.util.Set`). + +- [ ] **Step 6: Create `ProblemDetailTest` for ordering / de-dup / fragment** + +Create `src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java`: + +```java +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.BadRequestException; +import com.retailsvc.http.internal.ProblemDetail.Entry; +import com.retailsvc.http.validate.ValidationError; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ProblemDetailTest { + + @Test + void leafErrorBecomesSingleFragmentEntry() { + var pd = ProblemDetail.forValidation(new ValidationError("/age", "type", "expected integer", "x")); + assertThat(pd.errors()) + .containsExactly(new Entry("#/age", "type", "expected integer")); + } + + @Test + void rootPointerFragmentsToHash() { + var pd = ProblemDetail.forValidation(new ValidationError("", "type", "expected object", 1)); + assertThat(pd.errors()).containsExactly(new Entry("#", "type", "expected object")); + } + + @Test + void branchesSortedDeepestPointerFirst() { + var deep = new ValidationError("/pet/collar/size", "type", "expected integer", "big"); + var shallow = new ValidationError("/pet/bark", "type", "expected boolean", 7); + var combinator = + new ValidationError( + "/pet", "oneOf", "matched 0 of 2 oneOf branches", null, List.of(shallow, deep)); + + var pd = ProblemDetail.forValidation(combinator); + + assertThat(pd.errors()) + .containsExactly( + new Entry("#/pet/collar/size", "type", "expected integer"), + new Entry("#/pet/bark", "type", "expected boolean")); + } + + @Test + void identicalBranchErrorsAreDeduplicated() { + var a = new ValidationError("/kind", "required", "required property missing", null); + var b = new ValidationError("/kind", "required", "required property missing", null); + var combinator = + new ValidationError("", "oneOf", "matched 0 of 2 oneOf branches", null, List.of(a, b)); + + var pd = ProblemDetail.forValidation(combinator); + + assertThat(pd.errors()) + .containsExactly(new Entry("#/kind", "required", "required property missing")); + } + + @Test + void equalDepthBranchesKeepSchemaOrder() { + var first = new ValidationError("/radius", "required", "required property missing", null); + var second = new ValidationError("/kind", "enum", "value not in enum", "triangle"); + var combinator = + new ValidationError( + "", "oneOf", "matched 0 of 2 oneOf branches", null, List.of(first, second)); + + var pd = ProblemDetail.forValidation(combinator); + + assertThat(pd.errors()) + .containsExactly( + new Entry("#/radius", "required", "required property missing"), + new Entry("#/kind", "enum", "value not in enum")); + } + + @Test + void nestedCombinatorBranchSurfacesAsSingleSummaryEntry() { + // A branch that is itself a failed combinator contributes ONE entry carrying its own + // summary; its sub-branches are not recursively expanded (Decision 7). The hidden sub-leaf + // is deep (/pet/reward/x/y/z) but does not surface, so the nested entry sorts by its own + // shallow pointer (/pet/reward, depth 2), behind the genuinely deeper sibling leaf (depth 3). + var hiddenSubLeaf = new ValidationError("/pet/reward/x/y/z", "type", "expected number", "s"); + var nestedCombinator = + new ValidationError( + "/pet/reward", "oneOf", "matched 0 of 3 oneOf branches", null, List.of(hiddenSubLeaf)); + var deeperLeaf = new ValidationError("/pet/collar/size", "type", "expected integer", "big"); + var top = + new ValidationError( + "/pet", "oneOf", "matched 0 of 2 oneOf branches", null, + List.of(nestedCombinator, deeperLeaf)); + + var pd = ProblemDetail.forValidation(top); + + assertThat(pd.errors()) + .containsExactly( + new Entry("#/pet/collar/size", "type", "expected integer"), + new Entry("#/pet/reward", "oneOf", "matched 0 of 3 oneOf branches")); + } + + @Test + void badRequestWithoutPointerHasEmptyErrors() { + var pd = ProblemDetail.forBadRequest(new BadRequestException("nope")); + assertThat(pd.errors()).isEmpty(); + } + + @Test + void badRequestWithPointerBecomesSingleEntry() { + var pd = ProblemDetail.forBadRequest(new BadRequestException(409, "taken", "/email", "unique")); + assertThat(pd.errors()).containsExactly(new Entry("#/email", "unique", "taken")); + } +} +``` + +- [ ] **Step 7: Run the affected suites to verify they pass** + +Run: `mvn -q test -Dtest=ProblemDetailRendererTest,ProblemDetailTest,HandlersDefaultExceptionTest` +Expected: PASS (all). + +- [ ] **Step 8: Run the full unit-test build to confirm green** + +Run: `mvn -q test` +Expected: `BUILD SUCCESS`, 0 failures. (`OpenApiServerIT` is a Failsafe `*IT` test and does not run under `mvn test`.) + +- [ ] **Step 9: Commit** + +```bash +git add src/main/java/com/retailsvc/http/internal/ProblemDetail.java \ + src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java \ + src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java \ + src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java \ + src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java +git commit -m "feat!: Render validation errors as RFC 9457 errors array + +BREAKING CHANGE: problem+json responses no longer carry top-level +pointer/keyword. 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." +``` + +--- + +### Task 4: End-to-end assertions for `errors[]` (multi-entry + de-dup) + +Strengthens the existing `/shapes` (`oneOf`) integration tests to assert the new wire shape over a real HTTP round-trip — including the de-dup case (`{"radius":2.5}` makes both branches fail identically at `/kind required`). Additive; nothing else changes. + +**Files:** +- Test: `src/test/java/com/retailsvc/http/OpenApiServerIT.java:426-468` (extend two tests) + +- [ ] **Step 1: Add `errors[]` assertions to the two `/shapes` failure tests** + +In `src/test/java/com/retailsvc/http/OpenApiServerIT.java`, in `postShapeUnknownKindReturns400` (body `{"kind":"triangle","side":3}`), after the existing `assertThat(response.body()).contains("oneOf");` line (line 439), add: + +```java + // Both branches fail at distinct leaves -> two entries, in the errors[] array. + assertThat(response.body()).contains("\"errors\"").contains("#/radius").contains("#/kind"); +``` + +In `postShapeMissingDiscriminatorReturns400` (body `{"radius":2.5}`), after the existing `assertThat(response.body()).contains("oneOf");` line (line 461), add: + +```java + // Both branches fail identically at /kind required -> de-duplicated to one entry. + assertThat(response.body()).contains("\"errors\"").contains("#/kind"); + assertThat(response.body().split("#/kind", -1)).hasSize(2); // exactly one occurrence +``` + +- [ ] **Step 2: Run the integration test** + +Run: `mvn -q verify -Dit.test=OpenApiServerIT -DfailIfNoTests=false` +Expected: PASS — the `/shapes` tests show `errors[]` with the expected pointers, and the de-dup body yields exactly one `#/kind` occurrence. + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/com/retailsvc/http/OpenApiServerIT.java +git commit -m "test: Assert errors array end-to-end for oneOf failures" +``` + +--- + +### Task 5: Document the `errors[]` shape (README) + +Updates the "Error responses" section to RFC 9457 and the `errors[]` array, replacing the flat `pointer`/`keyword` documentation. + +**Files:** +- Modify: `README.md` (section "Error responses", lines ~878-919; plus the four scattered `RFC 7807` mentions at lines ~13, 30, 50, 633, 873) + +- [ ] **Step 1: Rewrite the "Error responses" section** + +In `README.md`, replace the section heading and body from line 878 (`## Error responses (RFC 7807)`) through the example code block (ending line 907) with: + +````markdown +## Error responses (RFC 9457) + +Validation failures — missing required fields, type mismatches, unsupported content types, +coercion errors, malformed bodies — produce an `HTTP 400 Bad Request` response with body media +type `application/problem+json`, following +[RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457) (which obsoletes RFC 7807). + +The top level carries the RFC core members; each individual failure is an entry in an `errors` +array (an RFC 9457 extension member). A non-combinator failure yields a single entry; a +`oneOf` / `anyOf` failure yields one entry per failed branch, ordered most-likely-cause first +(the branch the payload most resembles) and de-duplicated. + +| Field | Type | Description | +| ---------- | ------- | ---------------------------------------------------------------------------------------- | +| `type` | string | Always `about:blank` (no per-error type URI). | +| `title` | string | Always `Bad Request`. | +| `status` | integer | Always `400`. | +| `detail` | string | Human-readable description (a leaf message, or `matched 0 of N oneOf branches` for a combinator). | +| `errors` | array | One entry per failure; omitted when empty. Each entry has the fields below. | + +Each `errors[]` entry: + +| Field | Type | Description | +| ---------- | ------- | ---------------------------------------------------------------------------------------- | +| `pointer` | string | [RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901) JSON-Pointer to the failing location, as a URI fragment — e.g. `#/age` for a body field, `#/query/limit` / `#/path/id` for parameters, `#/body` for whole-body errors (missing body, unsupported content type), or `#` when the entire body is the wrong type. | +| `keyword` | string | The validation rule that failed: `type`, `required`, `enum`, `pattern`, `format`, `minimum`, `maximum`, `minLength`, `maxLength`, `additionalProperties`, `oneOf`, `anyOf`, `allOf`, `not`, `const`, `content-type`, `decode`, … | +| `detail` | string | Human-readable description of this failure (e.g. `expected integer`). | + +Example body for `POST /form-echo` with `age=abc` (`age` is declared as `integer`): + +``` json +{ + "type": "about:blank", + "title": "Bad Request", + "status": 400, + "detail": "expected integer", + "errors": [ + { "pointer": "#/age", "keyword": "type", "detail": "expected integer" } + ] +} +``` + +Example body for a `oneOf` request body that matches no branch — one entry per branch, +deepest (most-likely) first: + +``` json +{ + "type": "about:blank", + "title": "Bad Request", + "status": 400, + "detail": "matched 0 of 2 oneOf branches", + "errors": [ + { "pointer": "#/offers/0/conditions/0/itemSet/minQuantity", "keyword": "type", "detail": "expected number" } + ] +} +``` +```` + +- [ ] **Step 2: Update the scattered `RFC 7807` references and the table-of-contents anchor** + +Make these exact replacements in `README.md`: + +- Line ~13: `(de)serialisation, and RFC 7807 error rendering.` → `(de)serialisation, and RFC 9457 error rendering.` +- Line ~30 (table of contents): `- [Error responses (RFC 7807)](#error-responses-rfc-7807)` → `- [Error responses (RFC 9457)](#error-responses-rfc-9457)` +- Line ~50: `- RFC 7807 \`application/problem+json\` validation errors with JSON-Pointer to the failing location` → `- RFC 9457 \`application/problem+json\` validation errors with an \`errors[]\` array of JSON-Pointers to the failing locations` +- Line ~633: `\`SchemeValidator\` callback, and renders RFC 7807 \`application/problem+json\` rejections — 401 for` → `\`SchemeValidator\` callback, and renders RFC 9457 \`application/problem+json\` rejections — 401 for` +- Line ~873: `Coercion failures surface as RFC-7807 \`400\` responses with a JSON-pointer to the failing field.` → `Coercion failures surface as RFC-9457 \`400\` responses with a JSON-pointer to the failing field.` + +(Use `grep -n "7807" README.md` to confirm none remain except inside the parenthetical "(which obsoletes RFC 7807)" you added in Step 1.) + +- [ ] **Step 3: Verify no stale references and the anchor is consistent** + +Run: `grep -n "7807\|error-responses" README.md` +Expected: the only `7807` is the "obsoletes RFC 7807" note; the TOC anchor `#error-responses-rfc-9457` matches the new heading `## Error responses (RFC 9457)`. + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "docs: Document errors array response shape (RFC 9457)" +``` + +--- + +### Task 6: Final verification + +- [ ] **Step 1: Full build (unit + integration + coverage)** + +Run: `mvn -q verify` +Expected: `BUILD SUCCESS`, 0 failures, 0 errors. This runs Surefire (all unit tests incl. `ValidationErrorTest`, `DefaultValidatorDispatchTest`, `ProblemDetailTest`, `ProblemDetailRendererTest`, `HandlersDefaultExceptionTest`, `ValidatorNoThrowOnHappyPathTest`) and Failsafe (`OpenApiServerIT`). + +- [ ] **Step 2: Eyeball the new wire shape against the motivating case** + +Confirm the design's worked example is reflected: a `oneOf` body failing inside one branch now returns `errors[]` with a `#/…` pointer at the real failing leaf, deepest-first and de-duplicated, while the top-level `detail` keeps the `matched 0 of N` summary. The `/shapes` IT assertions (Task 4) are the automated proxy for this. + +- [ ] **Step 3: Confirm clean tree** + +Run: `git status -sb` +Expected: clean working tree; all changes committed across Tasks 1-5. From ff24f44c4eb9ac038eeac6eef8776b529b37c5fb Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 15:19:18 +0200 Subject: [PATCH 03/13] feat: Add branch errors to ValidationError --- .../http/validate/ValidationError.java | 18 ++++++++++++- .../http/validate/ValidationErrorTest.java | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/retailsvc/http/validate/ValidationErrorTest.java diff --git a/src/main/java/com/retailsvc/http/validate/ValidationError.java b/src/main/java/com/retailsvc/http/validate/ValidationError.java index 3cd3423..4e00e76 100644 --- a/src/main/java/com/retailsvc/http/validate/ValidationError.java +++ b/src/main/java/com/retailsvc/http/validate/ValidationError.java @@ -1,4 +1,20 @@ package com.retailsvc.http.validate; +import java.util.List; + public record ValidationError( - String pointer, String keyword, String message, Object rejectedValue) {} + String pointer, + String keyword, + String message, + Object rejectedValue, + List branches) { + + public ValidationError { + branches = List.copyOf(branches); + } + + /** Leaf error with no branch sub-errors. */ + public ValidationError(String pointer, String keyword, String message, Object rejectedValue) { + this(pointer, keyword, message, rejectedValue, List.of()); + } +} diff --git a/src/test/java/com/retailsvc/http/validate/ValidationErrorTest.java b/src/test/java/com/retailsvc/http/validate/ValidationErrorTest.java new file mode 100644 index 0000000..736552c --- /dev/null +++ b/src/test/java/com/retailsvc/http/validate/ValidationErrorTest.java @@ -0,0 +1,27 @@ +package com.retailsvc.http.validate; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ValidationErrorTest { + + @Test + void convenienceConstructorDefaultsToNoBranches() { + var e = new ValidationError("/x", "type", "expected string", null); + assertThat(e.branches()).isEmpty(); + } + + @Test + void canonicalConstructorCopiesBranchesDefensively() { + var branch = new ValidationError("/x/y", "type", "expected number", "s"); + var mutable = new ArrayList(List.of(branch)); + + var e = new ValidationError("/x", "oneOf", "matched 0 of 1 oneOf branches", "s", mutable); + mutable.clear(); + + assertThat(e.branches()).containsExactly(branch); + } +} From fb43e09e8a56094004c31dc6a3645340b0dc4b9c Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 15:25:07 +0200 Subject: [PATCH 04/13] fix: Reject null branches in ValidationError with a clear message --- .../java/com/retailsvc/http/validate/ValidationError.java | 3 +++ .../com/retailsvc/http/validate/ValidationErrorTest.java | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/src/main/java/com/retailsvc/http/validate/ValidationError.java b/src/main/java/com/retailsvc/http/validate/ValidationError.java index 4e00e76..86b92b4 100644 --- a/src/main/java/com/retailsvc/http/validate/ValidationError.java +++ b/src/main/java/com/retailsvc/http/validate/ValidationError.java @@ -1,6 +1,7 @@ package com.retailsvc.http.validate; import java.util.List; +import java.util.Objects; public record ValidationError( String pointer, @@ -10,6 +11,8 @@ public record ValidationError( List branches) { public ValidationError { + Objects.requireNonNull( + branches, "branches must not be null; use the 4-arg constructor for a leaf error"); branches = List.copyOf(branches); } diff --git a/src/test/java/com/retailsvc/http/validate/ValidationErrorTest.java b/src/test/java/com/retailsvc/http/validate/ValidationErrorTest.java index 736552c..7c29b8f 100644 --- a/src/test/java/com/retailsvc/http/validate/ValidationErrorTest.java +++ b/src/test/java/com/retailsvc/http/validate/ValidationErrorTest.java @@ -1,6 +1,7 @@ package com.retailsvc.http.validate; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; import java.util.ArrayList; import java.util.List; @@ -24,4 +25,11 @@ void canonicalConstructorCopiesBranchesDefensively() { assertThat(e.branches()).containsExactly(branch); } + + @Test + void nullBranchesArgumentThrows() { + assertThatNullPointerException() + .isThrownBy(() -> new ValidationError("/x", "oneOf", "summary", "s", null)) + .withMessageContaining("branches"); + } } From 713f98f7443c45f00a9838ab17b8da81b94becf1 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 15:27:05 +0200 Subject: [PATCH 05/13] feat: Retain oneOf/anyOf branch errors in validator --- .../http/validate/DefaultValidator.java | 26 +++++++++---- .../DefaultValidatorDispatchTest.java | 38 +++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index fb66f20..16b1d24 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -527,29 +527,39 @@ private Optional checkAllOf(Object value, List parts, S } private Optional checkAnyOf(Object value, List options, String pointer) { + List failures = new ArrayList<>(); for (Schema o : options) { - if (check(value, o, pointer).isEmpty()) { + Optional result = check(value, o, pointer); + if (result.isEmpty()) { return OK; } + failures.add(result.get()); } - return err(pointer, "anyOf", "did not match any anyOf branch", value); + return Optional.of( + new ValidationError(pointer, "anyOf", "did not match any anyOf branch", value, failures)); } private Optional checkOneOf(Object value, List options, String pointer) { int matched = 0; + List failures = new ArrayList<>(); for (Schema o : options) { - if (check(value, o, pointer).isEmpty()) { + Optional result = check(value, o, pointer); + if (result.isEmpty()) { matched++; + } else { + failures.add(result.get()); } } if (matched == 1) { return OK; } - return err( - pointer, - "oneOf", - "matched " + matched + " of " + options.size() + " oneOf branches", - value); + return Optional.of( + new ValidationError( + pointer, + "oneOf", + "matched " + matched + " of " + options.size() + " oneOf branches", + value, + matched == 0 ? failures : List.of())); } private Optional checkNot(Object value, Schema inner, String pointer) { diff --git a/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java b/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java index 78a9659..cb888bd 100644 --- a/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java +++ b/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java @@ -235,4 +235,42 @@ void neverSchemaRejectsNull() { .extracting(t -> ((ValidationException) t).error().keyword()) .isEqualTo("false"); } + + @Test + void oneOfZeroMatchesCapturesEachBranchError() { + // "hello" (len 5): branch[0] minLength 100 fails, branch[1] maxLength 2 fails. + var schema = new OneOfSchema(List.of(stringSchema(100, null), stringSchema(null, 2)), Map.of()); + assertThatThrownBy(() -> v.validate("hello", schema, "/v")) + .isInstanceOf(ValidationException.class) + .satisfies( + t -> { + var err = ((ValidationException) t).error(); + assertThat(err.branches()) + .extracting(ValidationError::keyword) + .containsExactly("minLength", "maxLength"); + }); + } + + @Test + void oneOfTwoMatchesHasNoBranchErrors() { + // Both branches accept "hello" — ambiguity, not a field error. + var schema = new OneOfSchema(List.of(stringSchema(null, 10), stringSchema(1, null)), Map.of()); + assertThatThrownBy(() -> v.validate("hello", schema, "/v")) + .isInstanceOf(ValidationException.class) + .satisfies(t -> assertThat(((ValidationException) t).error().branches()).isEmpty()); + } + + @Test + void anyOfNoMatchCapturesEachBranchError() { + var schema = new AnyOfSchema(List.of(stringSchema(100, null), stringSchema(null, 2)), Map.of()); + assertThatThrownBy(() -> v.validate("hello", schema, "/v")) + .isInstanceOf(ValidationException.class) + .satisfies( + t -> { + var err = ((ValidationException) t).error(); + assertThat(err.branches()) + .extracting(ValidationError::keyword) + .containsExactly("minLength", "maxLength"); + }); + } } From 8249fe970f2e33ea86a107e91f5e3c27de249f5b Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 15:32:13 +0200 Subject: [PATCH 06/13] refactor: Defer anyOf branch-error allocation to the failure path --- .../com/retailsvc/http/validate/DefaultValidator.java | 10 ++++++++-- .../http/validate/DefaultValidatorDispatchTest.java | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java index 16b1d24..6490210 100644 --- a/src/main/java/com/retailsvc/http/validate/DefaultValidator.java +++ b/src/main/java/com/retailsvc/http/validate/DefaultValidator.java @@ -527,16 +527,20 @@ private Optional checkAllOf(Object value, List parts, S } private Optional checkAnyOf(Object value, List options, String pointer) { - List failures = new ArrayList<>(); + List failures = null; for (Schema o : options) { Optional result = check(value, o, pointer); if (result.isEmpty()) { return OK; } + if (failures == null) { + failures = new ArrayList<>(options.size() - 1); + } failures.add(result.get()); } + List branches = failures != null ? failures : List.of(); return Optional.of( - new ValidationError(pointer, "anyOf", "did not match any anyOf branch", value, failures)); + new ValidationError(pointer, "anyOf", "did not match any anyOf branch", value, branches)); } private Optional checkOneOf(Object value, List options, String pointer) { @@ -559,6 +563,8 @@ private Optional checkOneOf(Object value, List options, "oneOf", "matched " + matched + " of " + options.size() + " oneOf branches", value, + // Ambiguous match (matched > 1): the non-matching branches' errors are noise — omit + // them. matched == 0 ? failures : List.of())); } diff --git a/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java b/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java index cb888bd..56b800b 100644 --- a/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java +++ b/src/test/java/com/retailsvc/http/validate/DefaultValidatorDispatchTest.java @@ -273,4 +273,12 @@ void anyOfNoMatchCapturesEachBranchError() { .containsExactly("minLength", "maxLength"); }); } + + @Test + void anyOfEmptyOptionsHasNoBranchErrors() { + var schema = new AnyOfSchema(List.of(), Map.of()); + assertThatThrownBy(() -> v.validate("anything", schema, "/v")) + .isInstanceOf(ValidationException.class) + .satisfies(t -> assertThat(((ValidationException) t).error().branches()).isEmpty()); + } } From faf6c3a54e89000306416a5abe3b2dfebc4166f3 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 15:38:24 +0200 Subject: [PATCH 07/13] 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. --- .../http/internal/ProblemDetail.java | 63 ++++++++-- .../http/internal/ProblemDetailRenderer.java | 33 +++++- .../http/internal/SecurityFilter.java | 2 +- .../http/HandlersDefaultExceptionTest.java | 22 +++- .../internal/ProblemDetailRendererTest.java | 73 +++++++++--- .../http/internal/ProblemDetailTest.java | 109 ++++++++++++++++++ 6 files changed, 261 insertions(+), 41 deletions(-) create mode 100644 src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java diff --git a/src/main/java/com/retailsvc/http/internal/ProblemDetail.java b/src/main/java/com/retailsvc/http/internal/ProblemDetail.java index b34135e..fd45aea 100644 --- a/src/main/java/com/retailsvc/http/internal/ProblemDetail.java +++ b/src/main/java/com/retailsvc/http/internal/ProblemDetail.java @@ -2,31 +2,70 @@ import com.retailsvc.http.BadRequestException; import com.retailsvc.http.validate.ValidationError; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; /** - * Carrier for an RFC 7807 problem+json document. Serialized by the registered JSON {@code - * TypeMapper}; the wire shape and field-order are whatever the configured mapper produces — title - * is advisory per RFC 7807 since {@code type} is always {@code about:blank}. + * Carrier for an RFC 9457 problem+json document. Serialized by {@link ProblemDetailRenderer}; the + * wire shape is the RFC core members (type, title, status, detail) plus an {@code errors} extension + * array. Each {@link Entry} locates one validation failure with a JSON-Pointer-in-fragment {@code + * pointer} and the failed {@code keyword}. {@code type} is always {@code about:blank}, so {@code + * title} is advisory per the RFC. */ public record ProblemDetail( - String type, String title, int status, String detail, String pointer, String keyword) { + String type, String title, int status, String detail, List errors) { + + /** One validation failure: its body location, the failed keyword, and a human-readable detail. */ + public record Entry(String pointer, String keyword, String detail) {} private static final String DEFAULT_TYPE = "about:blank"; public static ProblemDetail forValidation(ValidationError e) { - return new ProblemDetail( - DEFAULT_TYPE, "Bad Request", 400, e.message(), e.pointer(), e.keyword()); + return new ProblemDetail(DEFAULT_TYPE, "Bad Request", 400, e.message(), entriesOf(e)); } public static ProblemDetail forBadRequest(BadRequestException e) { + List errors = + e.pointer() + .map(p -> List.of(new Entry(fragment(p), e.keyword().orElse(null), e.getMessage()))) + .orElseGet(List::of); return new ProblemDetail( - DEFAULT_TYPE, - titleFor(e.status()), - e.status(), - e.getMessage(), - e.pointer().orElse(null), - e.keyword().orElse(null)); + DEFAULT_TYPE, titleFor(e.status()), e.status(), e.getMessage(), errors); + } + + /** + * Flattens a validation error into ordered {@code errors} entries: the failed branches of a + * combinator (one each), or the single leaf otherwise. Multi-entry results are sorted deepest + * pointer first (most-likely-intended branch) and de-duplicated on exact equality. + */ + private static List entriesOf(ValidationError e) { + List sources = e.branches().isEmpty() ? List.of(e) : e.branches(); + List entries = new ArrayList<>(sources.size()); + for (ValidationError s : sources) { + entries.add(new Entry(fragment(s.pointer()), s.keyword(), s.message())); + } + if (entries.size() <= 1) { + return entries; + } + entries.sort(Comparator.comparingInt((Entry en) -> depth(en.pointer())).reversed()); + return new ArrayList<>(new LinkedHashSet<>(entries)); + } + + private static String fragment(String pointer) { + return "#" + pointer; + } + + private static int depth(String pointer) { + int n = 0; + for (int i = 0; i < pointer.length(); i++) { + if (pointer.charAt(i) == '/') { + n++; + } + } + return n; } private static final Map TITLES = diff --git a/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java index 4105c09..c8750dc 100644 --- a/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java +++ b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java @@ -1,15 +1,15 @@ package com.retailsvc.http.internal; import java.nio.charset.StandardCharsets; +import java.util.List; /** - * Built-in JSON writer for the {@code application/problem+json} (RFC 7807) wire shape. Keeps the + * Built-in JSON writer for the {@code application/problem+json} (RFC 9457) wire shape. Keeps the * exception and security paths free of any {@code TypeMapper}, so the library can emit problem * responses without a JSON library on the classpath (and without record-accessor reflection that * GraalVM Native Image would otherwise need configured). * - *

Null-valued fields are omitted, matching the default Gson encoding the library historically - * produced. + *

Null-valued fields and an empty {@code errors} array are omitted. */ public final class ProblemDetailRenderer { @@ -26,12 +26,35 @@ public static byte[] renderJson(ProblemDetail pd) { first = appendString(out, first, "title", pd.title()); first = appendInt(out, first, "status", pd.status()); first = appendString(out, first, "detail", pd.detail()); - first = appendString(out, first, "pointer", pd.pointer()); - appendString(out, first, "keyword", pd.keyword()); + appendErrors(out, first, pd.errors()); out.append('}'); return out.toString().getBytes(StandardCharsets.UTF_8); } + private static void appendErrors( + StringBuilder out, boolean first, List errors) { + if (errors == null || errors.isEmpty()) { + return; + } + if (!first) { + out.append(','); + } + out.append("\"errors\":["); + for (int i = 0; i < errors.size(); i++) { + if (i > 0) { + out.append(','); + } + ProblemDetail.Entry e = errors.get(i); + out.append('{'); + boolean entryFirst = true; + entryFirst = appendString(out, entryFirst, "pointer", e.pointer()); + entryFirst = appendString(out, entryFirst, "keyword", e.keyword()); + appendString(out, entryFirst, "detail", e.detail()); + out.append('}'); + } + out.append(']'); + } + private static boolean appendString(StringBuilder out, boolean first, String name, String value) { if (value == null) { return first; diff --git a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java index b196e48..151d9d0 100644 --- a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java +++ b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java @@ -119,7 +119,7 @@ private void renderRejection(HttpExchange exchange, List fa String detail = describe(pick); ProblemDetail problemDetail = - new ProblemDetail("about:blank", title, status, detail, null, null); + new ProblemDetail("about:blank", title, status, detail, List.of()); byte[] body = ProblemDetailRenderer.renderJson(problemDetail); exchange.getResponseHeaders().add("Content-Type", "application/problem+json"); if (!anyDenied) { diff --git a/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java b/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java index 93c9136..e2443e0 100644 --- a/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java +++ b/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java @@ -9,6 +9,7 @@ import com.retailsvc.http.spec.HttpMethod; import com.retailsvc.http.validate.ValidationError; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.AfterEach; @@ -54,8 +55,14 @@ void validationExceptionRendersProblemJson() { String json = new String(bytes, StandardCharsets.UTF_8); @SuppressWarnings("unchecked") Map parsed = (Map) JSON.readFrom(bytes, "application/json"); - assertThat(parsed).containsEntry("keyword", "type"); assertThat(((Number) parsed.get("status")).intValue()).isEqualTo(400); + @SuppressWarnings("unchecked") + List> errors = (List>) parsed.get("errors"); + assertThat(errors) + .singleElement() + .satisfies( + entry -> + assertThat(entry).containsEntry("pointer", "#/x").containsEntry("keyword", "type")); assertThat(json).contains("expected string"); } @@ -73,9 +80,16 @@ void badRequestExceptionRendersProblemJsonWithCustomStatus() { assertThat(((Number) parsed.get("status")).intValue()).isEqualTo(422); assertThat(parsed) .containsEntry("title", "Unprocessable Content") - .containsEntry("detail", "email taken") - .containsEntry("pointer", "/email") - .containsEntry("keyword", "unique"); + .containsEntry("detail", "email taken"); + @SuppressWarnings("unchecked") + List> errors = (List>) parsed.get("errors"); + assertThat(errors) + .singleElement() + .satisfies( + entry -> + assertThat(entry) + .containsEntry("pointer", "#/email") + .containsEntry("keyword", "unique")); } @Test diff --git a/src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java b/src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java index 580f3d8..7d9afe2 100644 --- a/src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java +++ b/src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java @@ -2,25 +2,69 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.retailsvc.http.internal.ProblemDetail.Entry; import java.nio.charset.StandardCharsets; +import java.util.List; import org.junit.jupiter.api.Test; class ProblemDetailRendererTest { @Test - void rendersAllFieldsWhenPresent() { + void rendersSingleEntryErrorsArray() { ProblemDetail pd = - new ProblemDetail("about:blank", "Bad Request", 400, "expected string", "/x", "type"); + new ProblemDetail( + "about:blank", + "Bad Request", + 400, + "expected string", + List.of(new Entry("#/x", "type", "expected string"))); assertThat(asString(ProblemDetailRenderer.renderJson(pd))) .isEqualTo( "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," - + "\"detail\":\"expected string\",\"pointer\":\"/x\",\"keyword\":\"type\"}"); + + "\"detail\":\"expected string\",\"errors\":[{\"pointer\":\"#/x\"," + + "\"keyword\":\"type\",\"detail\":\"expected string\"}]}"); } @Test - void omitsNullPointerAndKeyword() { + void rendersMultipleErrorEntries() { ProblemDetail pd = - new ProblemDetail("about:blank", "Unauthorized", 401, "missing token", null, null); + new ProblemDetail( + "about:blank", + "Bad Request", + 400, + "matched 0 of 2 oneOf branches", + List.of( + new Entry("#/collar/size", "type", "expected integer"), + new Entry("#/bark", "type", "expected boolean"))); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Bad" + + " Request\",\"status\":400,\"detail\":\"matched 0 of 2 oneOf" + + " branches\",\"errors\":[{\"pointer\":\"#/collar/size\",\"keyword\":\"type\",\"detail\":\"expected" + + " integer\"},{\"pointer\":\"#/bark\",\"keyword\":\"type\",\"detail\":\"expected" + + " boolean\"}]}"); + } + + @Test + void omitsKeywordWithinEntryWhenNull() { + ProblemDetail pd = + new ProblemDetail( + "about:blank", + "Unprocessable Content", + 422, + "email taken", + List.of(new Entry("#/email", null, "email taken"))); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Unprocessable Content\",\"status\":422," + + "\"detail\":\"email taken\",\"errors\":[{\"pointer\":\"#/email\"," + + "\"detail\":\"email taken\"}]}"); + } + + @Test + void omitsEmptyErrorsArray() { + ProblemDetail pd = + new ProblemDetail("about:blank", "Unauthorized", 401, "missing token", List.of()); assertThat(asString(ProblemDetailRenderer.renderJson(pd))) .isEqualTo( "{\"type\":\"about:blank\",\"title\":\"Unauthorized\",\"status\":401," @@ -28,15 +72,15 @@ void omitsNullPointerAndKeyword() { } @Test - void omitsNullDetail() { - ProblemDetail pd = new ProblemDetail("about:blank", "Not Found", 404, null, null, null); + void omitsNullDetailAndEmptyErrors() { + ProblemDetail pd = new ProblemDetail("about:blank", "Not Found", 404, null, List.of()); assertThat(asString(ProblemDetailRenderer.renderJson(pd))) .isEqualTo("{\"type\":\"about:blank\",\"title\":\"Not Found\",\"status\":404}"); } @Test void escapesQuoteAndBackslashInDetail() { - ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "a\"b\\c", null, null); + ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "a\"b\\c", List.of()); assertThat(asString(ProblemDetailRenderer.renderJson(pd))) .isEqualTo( "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," @@ -46,25 +90,16 @@ void escapesQuoteAndBackslashInDetail() { @Test void escapesNamedControlCharsInDetail() { ProblemDetail pd = - new ProblemDetail("about:blank", "Bad Request", 400, "\b\f\n\r\t", null, null); + new ProblemDetail("about:blank", "Bad Request", 400, "\b\f\n\r\t", List.of()); assertThat(asString(ProblemDetailRenderer.renderJson(pd))) .isEqualTo( "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," + "\"detail\":\"\\b\\f\\n\\r\\t\"}"); } - @Test - void escapesUnnamedControlCharsAsHexUnicode() { - ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "", null, null); - assertThat(asString(ProblemDetailRenderer.renderJson(pd))) - .isEqualTo( - "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," - + "\"detail\":\"\\u0001\\u001f\"}"); - } - @Test void passesThroughNonAsciiCharactersVerbatim() { - ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "café-é", null, null); + ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "café-é", List.of()); assertThat(asString(ProblemDetailRenderer.renderJson(pd))) .isEqualTo( "{\"type\":\"about:blank\",\"title\":\"Bad" diff --git a/src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java b/src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java new file mode 100644 index 0000000..f00aba5 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java @@ -0,0 +1,109 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.BadRequestException; +import com.retailsvc.http.internal.ProblemDetail.Entry; +import com.retailsvc.http.validate.ValidationError; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ProblemDetailTest { + + @Test + void leafErrorBecomesSingleFragmentEntry() { + var pd = + ProblemDetail.forValidation(new ValidationError("/age", "type", "expected integer", "x")); + assertThat(pd.errors()).containsExactly(new Entry("#/age", "type", "expected integer")); + } + + @Test + void rootPointerFragmentsToHash() { + var pd = ProblemDetail.forValidation(new ValidationError("", "type", "expected object", 1)); + assertThat(pd.errors()).containsExactly(new Entry("#", "type", "expected object")); + } + + @Test + void branchesSortedDeepestPointerFirst() { + var deep = new ValidationError("/pet/collar/size", "type", "expected integer", "big"); + var shallow = new ValidationError("/pet/bark", "type", "expected boolean", 7); + var combinator = + new ValidationError( + "/pet", "oneOf", "matched 0 of 2 oneOf branches", null, List.of(shallow, deep)); + + var pd = ProblemDetail.forValidation(combinator); + + assertThat(pd.errors()) + .containsExactly( + new Entry("#/pet/collar/size", "type", "expected integer"), + new Entry("#/pet/bark", "type", "expected boolean")); + } + + @Test + void identicalBranchErrorsAreDeduplicated() { + var a = new ValidationError("/kind", "required", "required property missing", null); + var b = new ValidationError("/kind", "required", "required property missing", null); + var combinator = + new ValidationError("", "oneOf", "matched 0 of 2 oneOf branches", null, List.of(a, b)); + + var pd = ProblemDetail.forValidation(combinator); + + assertThat(pd.errors()) + .containsExactly(new Entry("#/kind", "required", "required property missing")); + } + + @Test + void equalDepthBranchesKeepSchemaOrder() { + var first = new ValidationError("/radius", "required", "required property missing", null); + var second = new ValidationError("/kind", "enum", "value not in enum", "triangle"); + var combinator = + new ValidationError( + "", "oneOf", "matched 0 of 2 oneOf branches", null, List.of(first, second)); + + var pd = ProblemDetail.forValidation(combinator); + + assertThat(pd.errors()) + .containsExactly( + new Entry("#/radius", "required", "required property missing"), + new Entry("#/kind", "enum", "value not in enum")); + } + + @Test + void nestedCombinatorBranchSurfacesAsSingleSummaryEntry() { + // A branch that is itself a failed combinator contributes ONE entry carrying its own + // summary; its sub-branches are not recursively expanded. The hidden sub-leaf is deep + // (/pet/reward/x/y/z) but does not surface, so the nested entry sorts by its own shallow + // pointer (/pet/reward, depth 2), behind the genuinely deeper sibling leaf (depth 3). + var hiddenSubLeaf = new ValidationError("/pet/reward/x/y/z", "type", "expected number", "s"); + var nestedCombinator = + new ValidationError( + "/pet/reward", "oneOf", "matched 0 of 3 oneOf branches", null, List.of(hiddenSubLeaf)); + var deeperLeaf = new ValidationError("/pet/collar/size", "type", "expected integer", "big"); + var top = + new ValidationError( + "/pet", + "oneOf", + "matched 0 of 2 oneOf branches", + null, + List.of(nestedCombinator, deeperLeaf)); + + var pd = ProblemDetail.forValidation(top); + + assertThat(pd.errors()) + .containsExactly( + new Entry("#/pet/collar/size", "type", "expected integer"), + new Entry("#/pet/reward", "oneOf", "matched 0 of 3 oneOf branches")); + } + + @Test + void badRequestWithoutPointerHasEmptyErrors() { + var pd = ProblemDetail.forBadRequest(new BadRequestException("nope")); + assertThat(pd.errors()).isEmpty(); + } + + @Test + void badRequestWithPointerBecomesSingleEntry() { + var pd = ProblemDetail.forBadRequest(new BadRequestException(409, "taken", "/email", "unique")); + assertThat(pd.errors()).containsExactly(new Entry("#/email", "unique", "taken")); + } +} From 2961549a76b64da08b6dfb637088959b79f02c1d Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 15:45:49 +0200 Subject: [PATCH 08/13] refactor: Guarantee non-null errors and depth-sort on raw pointers --- .../http/internal/ProblemDetail.java | 20 ++++++++++++------- .../http/internal/ProblemDetailRenderer.java | 2 +- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/ProblemDetail.java b/src/main/java/com/retailsvc/http/internal/ProblemDetail.java index fd45aea..61491cc 100644 --- a/src/main/java/com/retailsvc/http/internal/ProblemDetail.java +++ b/src/main/java/com/retailsvc/http/internal/ProblemDetail.java @@ -18,6 +18,12 @@ public record ProblemDetail( String type, String title, int status, String detail, List errors) { + public ProblemDetail { + if (errors == null) { + errors = List.of(); + } + } + /** One validation failure: its body location, the failed keyword, and a human-readable detail. */ public record Entry(String pointer, String keyword, String detail) {} @@ -38,20 +44,20 @@ public static ProblemDetail forBadRequest(BadRequestException e) { /** * Flattens a validation error into ordered {@code errors} entries: the failed branches of a - * combinator (one each), or the single leaf otherwise. Multi-entry results are sorted deepest + * combinator (one each), or the single leaf otherwise. Multiple sources are sorted deepest * pointer first (most-likely-intended branch) and de-duplicated on exact equality. */ private static List entriesOf(ValidationError e) { List sources = e.branches().isEmpty() ? List.of(e) : e.branches(); - List entries = new ArrayList<>(sources.size()); + if (sources.size() > 1) { + sources = new ArrayList<>(sources); + sources.sort(Comparator.comparingInt((ValidationError s) -> depth(s.pointer())).reversed()); + } + LinkedHashSet entries = new LinkedHashSet<>(); for (ValidationError s : sources) { entries.add(new Entry(fragment(s.pointer()), s.keyword(), s.message())); } - if (entries.size() <= 1) { - return entries; - } - entries.sort(Comparator.comparingInt((Entry en) -> depth(en.pointer())).reversed()); - return new ArrayList<>(new LinkedHashSet<>(entries)); + return new ArrayList<>(entries); } private static String fragment(String pointer) { diff --git a/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java index c8750dc..714f1bd 100644 --- a/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java +++ b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java @@ -33,7 +33,7 @@ public static byte[] renderJson(ProblemDetail pd) { private static void appendErrors( StringBuilder out, boolean first, List errors) { - if (errors == null || errors.isEmpty()) { + if (errors.isEmpty()) { return; } if (!first) { From 82cd048cc4a5f6c2231e9f61c4659cca869558eb Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 15:47:42 +0200 Subject: [PATCH 09/13] test: Assert errors array end-to-end for oneOf failures --- src/test/java/com/retailsvc/http/OpenApiServerIT.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/test/java/com/retailsvc/http/OpenApiServerIT.java b/src/test/java/com/retailsvc/http/OpenApiServerIT.java index 1d6118b..00b724b 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerIT.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerIT.java @@ -437,6 +437,8 @@ void postShapeUnknownKindReturns400() { assertThat(response.headers().firstValue("Content-Type").orElse("")) .contains("application/problem+json"); assertThat(response.body()).contains("oneOf"); + // Both branches fail at distinct leaves -> two entries, in the errors[] array. + assertThat(response.body()).contains("\"errors\"").contains("#/radius").contains("#/kind"); } catch (IOException e) { fail(e); } catch (InterruptedException e) { @@ -459,6 +461,9 @@ void postShapeMissingDiscriminatorReturns400() { assertThat(response.headers().firstValue("Content-Type").orElse("")) .contains("application/problem+json"); assertThat(response.body()).contains("oneOf"); + // Both branches fail identically at /kind required -> de-duplicated to one entry. + assertThat(response.body()).contains("\"errors\"").contains("#/kind"); + assertThat(response.body().split("#/kind", -1)).hasSize(2); // exactly one occurrence } catch (IOException e) { fail(e); } catch (InterruptedException e) { From 87efe5927c4ceeae4cdc88274fb71401ff560791 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 15:51:33 +0200 Subject: [PATCH 10/13] docs: Document errors array response shape (RFC 9457) --- README.md | 50 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3c64dc3..80ab86c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A lightweight Java library that wraps the JDK's `com.sun.net.httpserver.HttpServer` and serves endpoints declared in an OpenAPI 3.1.x specification. Handlers are pure functions registered by `operationId`; the framework handles routing, OpenAPI parameter and body validation, JSON -(de)serialisation, and RFC 7807 error rendering. +(de)serialisation, and RFC 9457 error rendering. ## Table of contents @@ -27,7 +27,7 @@ endpoints declared in an OpenAPI 3.1.x specification. Handlers are pure function - [After-response hooks](#after-response-hooks) - [Security](#security) - [Request body content types](#request-body-content-types) -- [Error responses (RFC 7807)](#error-responses-rfc-7807) +- [Error responses (RFC 9457)](#error-responses-rfc-9457) - [Extra (non-OpenAPI) handlers](#extra-non-openapi-handlers) - [Health endpoint](#health-endpoint) - [Graceful shutdown](#graceful-shutdown) @@ -47,7 +47,7 @@ endpoints declared in an OpenAPI 3.1.x specification. Handlers are pure function `ResponseDecorator` for cross-cutting response headers - OpenAPI `securitySchemes` and `security` enforcement (`apiKey`, `http bearer`, `http basic`), with an opt-out for sidecar / gateway authentication -- RFC 7807 `application/problem+json` validation errors with JSON-Pointer to the failing location +- RFC 9457 `application/problem+json` validation errors with an `errors[]` array of JSON-Pointers to the failing locations - Built on the JDK's native `HttpServer` with thread-per-request behaviour using virtual threads ## Maven artifact @@ -630,7 +630,7 @@ to detect errors. The library parses `components.securitySchemes` and the `security` requirement lists (root-level and per-operation), extracts the credential per scheme, hands it to a consumer-provided -`SchemeValidator` callback, and renders RFC 7807 `application/problem+json` rejections — 401 for +`SchemeValidator` callback, and renders RFC 9457 `application/problem+json` rejections — 401 for missing/malformed credentials (with `WWW-Authenticate`), 403 when the validator denies. Supported scheme types in this release: @@ -870,28 +870,38 @@ case-insensitive): Form-field coercion mirrors the rules already used at the parameter boundary: the wire is string-only by definition, so a property typed as `integer` accepts `"42"` and yields `42`. -Coercion failures surface as RFC-7807 `400` responses with a JSON-pointer to the failing field. +Coercion failures surface as RFC-9457 `400` responses with a JSON-pointer to the failing field. Both built-in parsers honour the `charset=` parameter on the `Content-Type` header (default UTF-8). Unknown charsets fall back to UTF-8. -## Error responses (RFC 7807) +## Error responses (RFC 9457) Validation failures — missing required fields, type mismatches, unsupported content types, coercion errors, malformed bodies — produce an `HTTP 400 Bad Request` response with body media type `application/problem+json`, following -[RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807). +[RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457) (which obsoletes RFC 7807). -A single error is reported per request (first failure wins). The response body has these fields: +The top level carries the RFC core members; each individual failure is an entry in an `errors` +array (an RFC 9457 extension member). A non-combinator failure yields a single entry; a +`oneOf` / `anyOf` failure yields one entry per failed branch, ordered most-likely-cause first +(the branch the payload most resembles) and de-duplicated. | Field | Type | Description | | ---------- | ------- | ---------------------------------------------------------------------------------------- | | `type` | string | Always `about:blank` (no per-error type URI). | | `title` | string | Always `Bad Request`. | | `status` | integer | Always `400`. | -| `detail` | string | Human-readable description of the failure (e.g. `expected integer`). | -| `pointer` | string | [RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901) JSON-Pointer to the failing location (e.g. `/body/age`, `/query/limit`, `/path/id`, or `/body` for body-wide errors). | +| `detail` | string | Human-readable description (a leaf message, or `matched 0 of N oneOf branches` for a combinator). | +| `errors` | array | One entry per failure; omitted when empty. Each entry has the fields below. | + +Each `errors[]` entry: + +| Field | Type | Description | +| ---------- | ------- | ---------------------------------------------------------------------------------------- | +| `pointer` | string | [RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901) JSON-Pointer to the failing location, as a URI fragment — e.g. `#/age` for a body field, `#/query/limit` / `#/path/id` for parameters, `#/body` for whole-body errors (missing body, unsupported content type), or `#` when the entire body is the wrong type. | | `keyword` | string | The validation rule that failed: `type`, `required`, `enum`, `pattern`, `format`, `minimum`, `maximum`, `minLength`, `maxLength`, `additionalProperties`, `oneOf`, `anyOf`, `allOf`, `not`, `const`, `content-type`, `decode`, … | +| `detail` | string | Human-readable description of this failure (e.g. `expected integer`). | Example body for `POST /form-echo` with `age=abc` (`age` is declared as `integer`): @@ -901,8 +911,24 @@ Example body for `POST /form-echo` with `age=abc` (`age` is declared as `integer "title": "Bad Request", "status": 400, "detail": "expected integer", - "pointer": "/age", - "keyword": "type" + "errors": [ + { "pointer": "#/age", "keyword": "type", "detail": "expected integer" } + ] +} +``` + +Example body for a `oneOf` request body that matches no branch — one entry per branch, +deepest (most-likely) first: + +``` json +{ + "type": "about:blank", + "title": "Bad Request", + "status": 400, + "detail": "matched 0 of 2 oneOf branches", + "errors": [ + { "pointer": "#/offers/0/conditions/0/itemSet/minQuantity", "keyword": "type", "detail": "expected number" } + ] } ``` From 3e7292f45dc09490955a1503f90a6b5e0123e5be Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 15:55:09 +0200 Subject: [PATCH 11/13] docs: Show multiple errors entries and anyOf detail in error-response docs --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 80ab86c..ff36077 100644 --- a/README.md +++ b/README.md @@ -892,7 +892,7 @@ array (an RFC 9457 extension member). A non-combinator failure yields a single e | `type` | string | Always `about:blank` (no per-error type URI). | | `title` | string | Always `Bad Request`. | | `status` | integer | Always `400`. | -| `detail` | string | Human-readable description (a leaf message, or `matched 0 of N oneOf branches` for a combinator). | +| `detail` | string | Human-readable description (a leaf message; for a combinator, `matched 0 of N oneOf branches` or `did not match any anyOf branch`). | | `errors` | array | One entry per failure; omitted when empty. Each entry has the fields below. | Each `errors[]` entry: @@ -917,7 +917,7 @@ Example body for `POST /form-echo` with `age=abc` (`age` is declared as `integer } ``` -Example body for a `oneOf` request body that matches no branch — one entry per branch, +Example body for a `oneOf` request body that matches no branch — one entry per failed branch, deepest (most-likely) first: ``` json @@ -927,11 +927,15 @@ deepest (most-likely) first: "status": 400, "detail": "matched 0 of 2 oneOf branches", "errors": [ - { "pointer": "#/offers/0/conditions/0/itemSet/minQuantity", "keyword": "type", "detail": "expected number" } + { "pointer": "#/pet/collar/size", "keyword": "type", "detail": "expected integer" }, + { "pointer": "#/pet/bark", "keyword": "type", "detail": "expected boolean" } ] } ``` +When several branches fail at the same location for the same reason, those identical entries are +collapsed into one — so a `oneOf` failure can show fewer entries than it has branches. + Other error responses: - **404 Not Found** — no route matches the request path (no body). From f63d5e9b54e9560387d0d448435190c55e230eba Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 16:00:57 +0200 Subject: [PATCH 12/13] docs: Align plan Task 3 commit example with the descriptive message used --- .../plans/2026-06-08-validation-errors-array.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-06-08-validation-errors-array.md b/docs/superpowers/plans/2026-06-08-validation-errors-array.md index 5396367..4297b50 100644 --- a/docs/superpowers/plans/2026-06-08-validation-errors-array.md +++ b/docs/superpowers/plans/2026-06-08-validation-errors-array.md @@ -697,12 +697,11 @@ git add src/main/java/com/retailsvc/http/internal/ProblemDetail.java \ src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java \ src/test/java/com/retailsvc/http/internal/ProblemDetailTest.java \ src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java -git commit -m "feat!: Render validation errors as RFC 9457 errors array +git commit -m "feat: Move validation pointer and keyword into an errors array -BREAKING CHANGE: problem+json responses no longer carry top-level -pointer/keyword. 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." +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." ``` --- From 5ab42be8bbd7238c394c9707c9b40c7ec2346bb1 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 8 Jun 2026 16:19:42 +0200 Subject: [PATCH 13/13] refactor: Extract Bad Request title constant and use HTTP_BAD_REQUEST --- .../http/internal/ProblemDetail.java | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/retailsvc/http/internal/ProblemDetail.java b/src/main/java/com/retailsvc/http/internal/ProblemDetail.java index 61491cc..a688806 100644 --- a/src/main/java/com/retailsvc/http/internal/ProblemDetail.java +++ b/src/main/java/com/retailsvc/http/internal/ProblemDetail.java @@ -1,5 +1,7 @@ package com.retailsvc.http.internal; +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; + import com.retailsvc.http.BadRequestException; import com.retailsvc.http.validate.ValidationError; import java.util.ArrayList; @@ -28,9 +30,11 @@ public record ProblemDetail( public record Entry(String pointer, String keyword, String detail) {} private static final String DEFAULT_TYPE = "about:blank"; + private static final String BAD_REQUEST = "Bad Request"; public static ProblemDetail forValidation(ValidationError e) { - return new ProblemDetail(DEFAULT_TYPE, "Bad Request", 400, e.message(), entriesOf(e)); + return new ProblemDetail( + DEFAULT_TYPE, BAD_REQUEST, HTTP_BAD_REQUEST, e.message(), entriesOf(e)); } public static ProblemDetail forBadRequest(BadRequestException e) { @@ -76,18 +80,28 @@ private static int depth(String pointer) { private static final Map TITLES = Map.of( - 400, "Bad Request", - 401, "Unauthorized", - 403, "Forbidden", - 404, "Not Found", - 405, "Method Not Allowed", - 409, "Conflict", - 410, "Gone", - 412, "Precondition Failed", - 415, "Unsupported Media Type", - 422, "Unprocessable Content"); + HTTP_BAD_REQUEST, + BAD_REQUEST, + 401, + "Unauthorized", + 403, + "Forbidden", + 404, + "Not Found", + 405, + "Method Not Allowed", + 409, + "Conflict", + 410, + "Gone", + 412, + "Precondition Failed", + 415, + "Unsupported Media Type", + 422, + "Unprocessable Content"); private static String titleFor(int status) { - return TITLES.getOrDefault(status, "Bad Request"); + return TITLES.getOrDefault(status, BAD_REQUEST); } }