Skip to content

Commit 5ba93fb

Browse files
committed
docs: Design for extra (non-OpenAPI) handlers + builder
1 parent b091058 commit 5ba93fb

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Extra (non-OpenAPI) handlers + builder
2+
3+
**Date:** 2026-05-12
4+
**Status:** Design — ready for implementation plan
5+
6+
## Problem
7+
8+
`OpenApiServer` mounts handlers only by OpenAPI `operationId`. Everything outside the spec falls through to a catch-all `/` 404. Consumers need a way to expose operational endpoints that are not part of the API contract — for example `/alive` for liveness probes, or a spec-accessor route that serves the OpenAPI document itself at a stable URL.
9+
10+
These endpoints must bypass OpenAPI parameter / body validation entirely — they have no `operationId` and no schema.
11+
12+
## Goals
13+
14+
1. Allow callers to register extra `HttpHandler` instances at arbitrary URL paths, outside the OpenAPI spec.
15+
2. Provide a small set of built-in helpers in `Handlers` for the most common cases (liveness, classpath-resource serving).
16+
3. Replace the constructor sprawl on `OpenApiServer` with a builder, since this change adds a fifth parameter and more are likely in future waves.
17+
4. Keep the existing public constructors for source/binary back-compat.
18+
19+
## Non-goals
20+
21+
- Routing by HTTP method on extra paths beyond what the helpers do internally (callers can compose their own `HttpHandler` if they need richer dispatch).
22+
- Hot-mounting / hot-unmounting handlers after `build()` — registration is build-time only.
23+
- Built-in readiness probes or metrics endpoints — out of scope; callers can supply their own `HttpHandler`.
24+
25+
## Design
26+
27+
### Public API — builder
28+
29+
```java
30+
OpenApiServer server = OpenApiServer.builder()
31+
.spec(spec)
32+
.jsonMapper(mapper)
33+
.handlers(operationHandlers)
34+
.exceptionHandler(exceptionHandler) // optional, defaults to Handlers.defaultExceptionHandler()
35+
.port(8080) // optional, default 8080
36+
.addHandler("/alive", Handlers.aliveHandler())
37+
.addHandler("/schemas/v1/openapi.yaml",
38+
Handlers.specHandler("/schemas/v1/openapi.yaml"))
39+
.build(); // throws IOException, starts the server
40+
```
41+
42+
Rules:
43+
44+
- `OpenApiServer.builder()` returns a fresh `OpenApiServer.Builder`.
45+
- `spec`, `jsonMapper`, `handlers` are required. `build()` throws `NullPointerException` if any is missing — matches current constructor behavior.
46+
- `exceptionHandler` is optional. If null/unset, defaults to `Handlers.defaultExceptionHandler()` (current behavior).
47+
- `port` defaults to `8080` (current behavior).
48+
- `addHandler(String path, HttpHandler handler)` adds one entry. `path` is the URL path; `handler` is the user's `HttpHandler`. Both non-null.
49+
- Calling `addHandler` twice with the same `path``IllegalStateException` from the second `addHandler` call (fail fast, not deferred to `build()`).
50+
- An extra path equal to `spec.basePath()` is detected at `build()` time and rejected with `IllegalStateException` before `HttpServer.createContext` is called, with a clear message naming both the extra path and the OpenAPI base path.
51+
- Existing two `OpenApiServer` constructors stay as thin delegators that call `builder()...build()`, for back-compat.
52+
53+
### Wiring inside `OpenApiServer`
54+
55+
For each `addHandler(path, handler)` entry, after the OpenAPI context is created and before the catch-all `/` 404 is registered:
56+
57+
```java
58+
HttpContext extraCtx = httpServer.createContext(path);
59+
extraCtx.getFilters().add(new ExceptionFilter(exceptionHandler));
60+
extraCtx.setHandler(handler);
61+
```
62+
63+
Order of context creation inside `OpenApiServer`:
64+
65+
1. OpenAPI context at `spec.basePath()` (full validation pipeline).
66+
2. Each `addHandler` path (extras), each with `ExceptionFilter` only.
67+
3. Catch-all `/``Handlers.notFoundHandler()`.
68+
69+
`HttpServer` resolves contexts by longest-prefix match, so creation order does not affect correctness — but two contexts at the same path is undefined behavior. Duplicate extras are caught by `addHandler` itself (see API rules above). An extra path equal to `spec.basePath()` is caught at the start of `build()` and rejected with `IllegalStateException` before any `HttpServer.createContext` call.
70+
71+
Extra handlers do **not** receive `RequestPreparationFilter` (no body read, no validation, no `operationId` resolution) and are not dispatched through `DispatchHandler`. They are mounted directly. `ExceptionFilter` wraps them so any uncaught exception flows through the user-supplied `ExceptionHandler`, giving operational endpoints the same RFC-7807 error envelope as API routes.
72+
73+
### Built-in helpers in `Handlers`
74+
75+
```java
76+
/** 204 No Content on GET/HEAD; 405 with Allow: GET, HEAD on other methods. */
77+
public static HttpHandler aliveHandler();
78+
79+
/**
80+
* Serves a classpath resource. Content-Type is inferred from the file extension:
81+
* .json → application/json
82+
* .yaml | .yml → application/yaml
83+
* .txt → text/plain; charset=utf-8
84+
* anything else → application/octet-stream
85+
*
86+
* The resource is loaded eagerly when this method is called and cached in memory.
87+
* If the resource cannot be found on the classpath, this method throws
88+
* IllegalArgumentException — so misconfiguration fails at server build, not at
89+
* first request.
90+
*
91+
* Responds 200 on GET/HEAD; 405 with Allow: GET, HEAD on other methods.
92+
*
93+
* @param classpathResource absolute classpath path, e.g. "/schemas/v1/openapi.yaml"
94+
*/
95+
public static HttpHandler specHandler(String classpathResource);
96+
```
97+
98+
Notes:
99+
100+
- `aliveHandler` sends `sendResponseHeaders(204, -1)` (no body).
101+
- `specHandler` reads bytes via `Handlers.class.getResourceAsStream(classpathResource)`. Null → `IllegalArgumentException("classpath resource not found: " + classpathResource)`. Bytes are held in the closure for the handler's lifetime.
102+
- Content-Length is set to the cached byte count; HEAD requests get headers only with the same Content-Length.
103+
- No caching headers (no `ETag`, no `Cache-Control`). Callers who need them wrap their own handler.
104+
105+
### Testing
106+
107+
Unit tests (additions to `HandlersTest`):
108+
109+
- `aliveHandler` returns 204 with no body on GET.
110+
- `aliveHandler` returns 204 with no body on HEAD.
111+
- `aliveHandler` returns 405 with `Allow: GET, HEAD` on POST, PUT, DELETE.
112+
- `specHandler` returns the resource bytes verbatim with inferred content type for `.json`, `.yaml`, `.yml`, `.txt`, and an unknown extension.
113+
- `specHandler` throws `IllegalArgumentException` at construction when the classpath resource is missing.
114+
- `specHandler` returns 405 with `Allow: GET, HEAD` on non-GET/HEAD methods.
115+
116+
Integration tests (additions, in a new `OpenApiServerBuilderIT` or extending `OpenApiServerIT`):
117+
118+
- Minimal builder smoke test: only required fields → server starts, OpenAPI route + at least one extra reachable.
119+
- Extra handler bypasses validation: `addHandler("/alive", Handlers.aliveHandler())` is reachable and returns 204 even though `/alive` is not in the OpenAPI spec.
120+
- Extra handler exception is delivered to `ExceptionHandler`: register a handler that throws `RuntimeException`, assert the configured `ExceptionHandler` writes the RFC-7807 envelope.
121+
- Duplicate `addHandler` path → `IllegalStateException` thrown from the second `addHandler` call.
122+
- Extra path equal to `spec.basePath()``IllegalStateException` from `build()` with a message naming both paths.
123+
- Existing `OpenApiServer` constructors still work (back-compat smoke test).
124+
125+
## Out of scope
126+
127+
- HTTP method-aware routing for extras beyond what the helpers implement.
128+
- Readiness probes, metrics, or any other built-in operational endpoint past `aliveHandler` and `specHandler`.
129+
- Per-handler filter customization (e.g. attaching custom filters to one extra and not another).
130+
- Dynamic registration after `build()`.

0 commit comments

Comments
 (0)