|
| 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