Skip to content

Commit 5c9d2a9

Browse files
committed
docs: Add BadRequestException to design spec
1 parent b3ec991 commit 5c9d2a9

1 file changed

Lines changed: 75 additions & 6 deletions

File tree

docs/superpowers/specs/2026-05-20-extra-route-interface-design.md

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ public static ExceptionHandler defaultExceptionHandler(TypeMapper jsonMapper) {
110110
HTTP_BAD_REQUEST,
111111
jsonMapper.writeTo(ProblemDetail.forValidation(ve.error())),
112112
"application/problem+json");
113+
case BadRequestException bre -> Response.bytes(
114+
bre.status(),
115+
jsonMapper.writeTo(ProblemDetail.forBadRequest(bre)),
116+
"application/problem+json");
113117
case NotFoundException _ -> Response.notFound();
114118
case MethodNotAllowedException mna -> Response.status(HTTP_BAD_METHOD)
115119
.withHeader("Allow", mna.allowed().stream()
@@ -126,18 +130,82 @@ public static ExceptionHandler defaultExceptionHandler(TypeMapper jsonMapper) {
126130

127131
Rationale: the exception path runs before a `Request` is necessarily built (e.g. a malformed URI in `RequestPreparationFilter` itself), so the handler signature cannot take `Request` — the simplest pre-`Request` signature is the right one.
128132

129-
### 4a. `ProblemDetail` record replaces `ProblemDetailRenderer`
133+
### 4a. New public `BadRequestException`
134+
135+
User handlers need a way to reject a syntactically-valid-but-semantically-wrong request (e.g. "email already taken" → 422 Unprocessable Content, "stale ETag" → 412 Precondition Failed) and have the framework render a problem+json response — without each handler re-implementing the problem-detail wire shape.
136+
137+
New public class:
138+
139+
```java
140+
package com.retailsvc.http;
141+
142+
public final class BadRequestException extends RuntimeException {
143+
144+
private static final int DEFAULT_STATUS = 400;
145+
146+
private final int status;
147+
private final String pointer; // nullable
148+
private final String keyword; // nullable
149+
150+
public BadRequestException(String detail) {
151+
this(DEFAULT_STATUS, detail, null, null);
152+
}
153+
154+
public BadRequestException(int status, String detail) {
155+
this(status, detail, null, null);
156+
}
157+
158+
public BadRequestException(int status, String detail, String pointer, String keyword) {
159+
super(Objects.requireNonNull(detail, "detail must not be null"));
160+
if (status < 400 || status > 499) {
161+
throw new IllegalArgumentException("status must be 4xx, got " + status);
162+
}
163+
this.status = status;
164+
this.pointer = pointer;
165+
this.keyword = keyword;
166+
}
167+
168+
public int status() { return status; }
169+
public Optional<String> pointer() { return Optional.ofNullable(pointer); }
170+
public Optional<String> keyword() { return Optional.ofNullable(keyword); }
171+
}
172+
```
173+
174+
Design points:
175+
176+
- **Optional status, default 400.** The no-status constructor preserves the common "client sent something bad" case at zero ceremony; the overload accepts any 4xx for 409/412/422/429 etc.
177+
- **4xx range enforced.** Throwing at construction prevents accidentally surfacing a 500 through a "bad request" path. 5xx errors should propagate as ordinary `RuntimeException` and hit the default 500 branch.
178+
- **Optional pointer/keyword** mirror `ValidationError` so the problem+json document shape is consistent whether the body was rejected by the OpenAPI validator (`ValidationException`) or by handler-level domain rules (`BadRequestException`).
179+
- **`detail`** uses `super(message)` so standard exception machinery (logging, stack traces) sees the human-readable reason.
180+
181+
### 4b. `ProblemDetail` record replaces `ProblemDetailRenderer`
130182

131183
New `internal/ProblemDetail` record (or public if we want users to be able to return problem+json from handlers — TBD; start internal):
132184

133185
```java
134186
record ProblemDetail(
135187
String type, String title, int status, String detail,
136188
String pointer, String keyword) {
189+
137190
static ProblemDetail forValidation(ValidationError e) {
138191
return new ProblemDetail(
139192
"about:blank", "Bad Request", 400, e.message(), e.pointer(), e.keyword());
140193
}
194+
195+
static ProblemDetail forBadRequest(BadRequestException e) {
196+
return new ProblemDetail(
197+
"about:blank",
198+
titleFor(e.status()),
199+
e.status(),
200+
e.getMessage(),
201+
e.pointer().orElse(null),
202+
e.keyword().orElse(null));
203+
}
204+
205+
// Small map of common 4xx codes to RFC-7231 reason phrases.
206+
// Unknown 4xx falls back to "Bad Request" — the type field is "about:blank",
207+
// so per RFC 7807 the title is advisory; the precise meaning rides on status.
208+
private static String titleFor(int status) { ... }
141209
}
142210
```
143211

@@ -182,11 +250,12 @@ returns no results.
182250
2. Introduce `ExtraRouteAdapter`; switch `OpenApiServer` extras wiring to it.
183251
3. Change `Builder.extraRoute` signature; update `HandlerConfig` and tests.
184252
4. Change `ExceptionHandler` signature; update `ExceptionFilter` to accept a `ResponseRenderer`; rewrite `Handlers.defaultExceptionHandler(TypeMapper)` and wire it from `Builder.build()`.
185-
5. Add `internal/ProblemDetail`; delete `internal/ProblemDetailRenderer`.
186-
6. Migrate `Handlers.aliveHandler/healthHandler/specHandler` to `RequestHandler` with inline 405 checks.
187-
7. Move `Handlers.notFoundHandler` to `internal/NotFoundHandler`.
188-
8. Delete `MethodLimitedHandler`.
189-
9. Update tests: `OpenApiServerBuilderTest`, `ExtraHandlersIT`, `HandlersTest` (if present), exception-handler tests (problem+json wire-shape now asserted as parsed JSON, not byte-equality), integration tests.
253+
5. Add public `BadRequestException`; wire a `case` for it into the default exception handler.
254+
6. Add `internal/ProblemDetail`; delete `internal/ProblemDetailRenderer`.
255+
7. Migrate `Handlers.aliveHandler/healthHandler/specHandler` to `RequestHandler` with inline 405 checks.
256+
8. Move `Handlers.notFoundHandler` to `internal/NotFoundHandler`.
257+
9. Delete `MethodLimitedHandler`.
258+
10. Update tests: `OpenApiServerBuilderTest`, `ExtraHandlersIT`, `HandlersTest` (if present), exception-handler tests (problem+json wire-shape now asserted as parsed JSON, not byte-equality), integration tests, plus new tests for `BadRequestException` (default 400, custom status, 4xx-range guard, pointer/keyword propagation to problem+json).
190259

191260
## Test plan
192261

0 commit comments

Comments
 (0)