|
| 1 | +# Multiple OpenAPI specs per server — design |
| 2 | + |
| 3 | +Status: proposed |
| 4 | +Date: 2026-05-22 |
| 5 | +Branch: `feat/multiple-specs` |
| 6 | + |
| 7 | +## 1. Motivation |
| 8 | + |
| 9 | +`OpenApiServer` today serves exactly one OpenAPI specification. Two real use cases push against that limit: |
| 10 | + |
| 11 | +- **Versioned APIs side-by-side.** During a v1 → v2 migration window the same service may need to expose `/api/v1/*` and `/api/v2/*` simultaneously, each driven by its own spec file. |
| 12 | +- **Distinct APIs in one process.** A public API and an internal admin API (different specs, different base paths) sometimes share a deployable for infrastructure reasons. |
| 13 | + |
| 14 | +Both cases share the same property: each spec has its own base path and is otherwise independent. This document describes the smallest viable change that supports them. |
| 15 | + |
| 16 | +## 2. Goals and non-goals |
| 17 | + |
| 18 | +**Goals** |
| 19 | + |
| 20 | +- Allow callers to register more than one `Spec` on a single `OpenApiServer`. |
| 21 | +- Scope `operationId` and security-scheme names *per spec*, so v1 and v2 can both declare `getCustomer` or `bearerAuth` without colliding. |
| 22 | +- Keep existing single-spec callers source-compatible. |
| 23 | +- Reuse the JDK `HttpServer` longest-prefix-match for routing across base paths. |
| 24 | + |
| 25 | +**Non-goals** |
| 26 | + |
| 27 | +- Merging multiple specs into a single composite document. |
| 28 | +- Per-spec interceptors, response decorators, after-response hooks, or exception handlers. These remain global to the server; callers needing per-spec branching can switch on the request path or resolved `operationId` inside the hook. |
| 29 | +- Cross-spec `$ref` resolution. |
| 30 | +- Hot reload or runtime mutation of bindings. |
| 31 | + |
| 32 | +## 3. Decided constraints |
| 33 | + |
| 34 | +These were settled during brainstorming and frame the design: |
| 35 | + |
| 36 | +1. **Per-spec namespace.** `operationId`s and security-scheme names need only be unique within a single spec. |
| 37 | +2. **Backward-compatible builder.** Existing `Builder.spec(Spec)`, `Builder.handler(opId, h)`, and `Builder.securityValidator(name, v)` keep working. They are sugar for one implicit `addSpec()` bundle. Mixing them with explicit `addSpec()` calls in the same builder is rejected at build time. |
| 38 | +3. **Duplicate basePath is the only path conflict rejected.** Nested prefixes (`/` plus `/api`) are legal — the JDK `HttpServer` resolves them by longest-prefix match. |
| 39 | +4. **Global hooks.** `RequestInterceptor`, `ResponseDecorator`, `AfterResponseHook`, `ExceptionHandler`, and extra `/`-style contexts stay registered once on the `Builder` and apply to every binding. |
| 40 | + |
| 41 | +## 4. Public API |
| 42 | + |
| 43 | +One new builder method, with a convenience overload: |
| 44 | + |
| 45 | +```java |
| 46 | +public Builder addSpec( |
| 47 | + Spec spec, |
| 48 | + Map<String, RequestHandler> handlers, |
| 49 | + Map<String, SchemeValidator> securityValidators); |
| 50 | + |
| 51 | +public Builder addSpec(Spec spec, Map<String, RequestHandler> handlers); |
| 52 | +``` |
| 53 | + |
| 54 | +The two-argument overload is equivalent to passing `Map.of()` for `securityValidators` and is rejected by per-binding validation if the spec actually references any security scheme. `addSpec()` may be called more than once; each call adds one spec binding. |
| 55 | + |
| 56 | +Existing single-spec methods remain on the Builder and behave as before, but at `build()` they are folded into one implicit `addSpec(spec, handlers, validators)` bundle. Mixing the implicit (single-spec) form with explicit `addSpec()` calls fails fast: |
| 57 | + |
| 58 | +``` |
| 59 | +IllegalStateException: use either spec()/handler()/securityValidator() or addSpec(), not both |
| 60 | +``` |
| 61 | + |
| 62 | +Global hooks (`requestInterceptor`, `responseDecorator`, `afterResponseHook`, `exceptionHandler`, `extraHandler`) keep their existing Builder methods unchanged. |
| 63 | + |
| 64 | +### Example — multi-spec |
| 65 | + |
| 66 | +```java |
| 67 | +OpenApiServer server = OpenApiServer.builder() |
| 68 | + .port(8080) |
| 69 | + .addSpec(v1Spec, Map.of( |
| 70 | + "getCustomer", new V1GetCustomer(), |
| 71 | + "listCustomers", new V1ListCustomers())) |
| 72 | + .addSpec(v2Spec, Map.of( |
| 73 | + "getCustomer", new V2GetCustomer(), |
| 74 | + "listCustomers", new V2ListCustomers(), |
| 75 | + "createCustomer", new V2CreateCustomer())) |
| 76 | + .requestInterceptor(loggingInterceptor) |
| 77 | + .build(); |
| 78 | +``` |
| 79 | + |
| 80 | +### Example — single spec (unchanged) |
| 81 | + |
| 82 | +```java |
| 83 | +OpenApiServer server = OpenApiServer.builder() |
| 84 | + .port(8080) |
| 85 | + .spec(spec) |
| 86 | + .handler("getCustomer", new GetCustomer()) |
| 87 | + .handler("listCustomers", new ListCustomers()) |
| 88 | + .build(); |
| 89 | +``` |
| 90 | + |
| 91 | +## 5. Internal architecture |
| 92 | + |
| 93 | +A new package-private record captures everything needed to serve one spec: |
| 94 | + |
| 95 | +```java |
| 96 | +record SpecBinding( |
| 97 | + Spec spec, |
| 98 | + Map<String, RequestHandler> handlers, |
| 99 | + Map<String, SchemeValidator> securityValidators, |
| 100 | + DefaultValidator validator, // built from spec::resolveSchema |
| 101 | + Router router) { // built from spec |
| 102 | +} |
| 103 | +``` |
| 104 | + |
| 105 | +`OpenApiServer` stores `List<SpecBinding> bindings` instead of the current single `spec` / `handlers` / `securityValidators` fields. |
| 106 | + |
| 107 | +### Build-time wiring |
| 108 | + |
| 109 | +At `Builder.build()`: |
| 110 | + |
| 111 | +1. Each `addSpec()` call (or the synthesised single bundle from the legacy methods) becomes one `SpecBinding`. |
| 112 | +2. **Per-binding validation** — identical to today's checks, scoped to each binding: |
| 113 | + - `validateHandlerWiring(binding.spec(), binding.handlers())` — every `operationId` in the spec has a registered handler, and there are no unknown handler keys. |
| 114 | + - `validateSecurityWiring(binding.spec(), binding.securityValidators())` — every security scheme referenced by the spec has a registered validator. |
| 115 | +3. **Cross-binding validation** — reject duplicate `spec.basePath()` across bindings, listing the conflicting spec titles (`spec.info().title()`). |
| 116 | +4. At least one binding must be present. Empty server is rejected with the existing "Spec must not be null" semantics, adapted to mention bindings. |
| 117 | + |
| 118 | +### Runtime wiring |
| 119 | + |
| 120 | +On server start, the server creates **one `HttpContext` per binding** at `binding.spec().basePath()`, with that binding's filter chain attached. The catch-all `/` context for 404s is only registered if no binding owns `/`. JDK `HttpServer` routes incoming requests to the longest-prefix-matching context, so each request lands in exactly one binding's chain. |
| 121 | + |
| 122 | +## 6. Filter chain |
| 123 | + |
| 124 | +The three existing filters become per-binding instances. They close over a `SpecBinding` instead of the top-level builder state: |
| 125 | + |
| 126 | +- **`ExceptionFilter`** — logic unchanged. Continues to use the *global* `ExceptionHandler`. One instance per binding (cheap). |
| 127 | +- **`RequestPreparationFilter`** — uses `binding.validator()` and `binding.router()` directly. Still stores the resolved `operationId` on the exchange as today. |
| 128 | +- **`DispatchHandler`** — looks up the handler in `binding.handlers()`. No global handler map exists. |
| 129 | + |
| 130 | +Global hooks (`RequestInterceptor`, `ResponseDecorator`, `AfterResponseHook`) wrap dispatch the same way they do today; there is still only one instance of each, passed into each binding's filter chain at construction. |
| 131 | + |
| 132 | +## 7. Error handling and edge cases |
| 133 | + |
| 134 | +| Scenario | Outcome | |
| 135 | +| --- | --- | |
| 136 | +| Two bindings with the same `basePath` | `build()` throws `IllegalStateException` listing the conflicting spec titles. | |
| 137 | +| Legacy single-spec methods mixed with `addSpec()` in the same builder | `build()` throws `IllegalStateException` directing the caller to pick one form. | |
| 138 | +| No bindings registered | `build()` throws — adapts the existing "Spec must not be null" message. | |
| 139 | +| Unknown `operationId` (handler missing or extra) | `build()` throws, naming the spec the mismatch belongs to. | |
| 140 | +| Unknown security scheme | `build()` throws, naming the spec the scheme belongs to. | |
| 141 | +| Request to a path no binding covers | Existing catch-all `/` 404 context handles it. | |
| 142 | +| One binding at `/`, another at `/api/v1` | Legal. `/api/v1/*` routes to the v1 binding; everything else to the `/` binding. | |
| 143 | +| Two specs with overlapping path templates inside disjoint base paths | Impossible by construction — JDK `HttpContext` dispatches by base path first, then each binding's `Router` matches templates within its own subtree. | |
| 144 | + |
| 145 | +## 8. Testing |
| 146 | + |
| 147 | +Per project memory, do not add new OpenAPI spec fixture files unless strictly necessary. Reuse `src/test/resources/openapi.json` (and its YAML mirror) by parsing it into a `Map<String,Object>`, deep-cloning, and mutating `servers[0].url` per binding before calling `Spec.from(map)` twice. This naturally gives two bindings with identical `operationId`s, which is exactly the per-spec-namespace property we want to exercise. |
| 148 | + |
| 149 | +**Unit (`OpenApiServerTest`)** — new camelCase methods, static AssertJ imports: |
| 150 | + |
| 151 | +- Two bindings with disjoint base paths both serve traffic correctly. |
| 152 | +- Identical `operationId`s across bindings dispatch to their own handler (per-spec namespace). |
| 153 | +- Duplicate `basePath` across bindings is rejected at `build()`. |
| 154 | +- Mixing legacy single-spec methods with `addSpec()` is rejected at `build()`. |
| 155 | +- Per-binding `validateHandlerWiring` and `validateSecurityWiring` fail independently. |
| 156 | +- Single-spec callers using legacy methods continue to work unchanged (regression net for the "sugar" claim). |
| 157 | + |
| 158 | +**Integration (`MultiSpecServerIT`)** — boots a real `HttpServer`: |
| 159 | + |
| 160 | +- Two bindings derived from the same fixture, mounted at `/api/v1` and `/api/v2`. |
| 161 | +- Each route hits its own handler under live HTTP. |
| 162 | +- A request to an uncovered path returns 404 via the catch-all context. |
| 163 | + |
| 164 | +## 9. Migration |
| 165 | + |
| 166 | +No code change is required for existing single-spec callers. The legacy `spec()` / `handler()` / `securityValidator()` builder methods stay on the Builder, with the same semantics — they now compose into one implicit `addSpec()` bundle inside `build()`. Documentation will introduce `addSpec()` as the canonical form for new code while keeping the single-spec example in the README valid. |
| 167 | + |
| 168 | +## 10. Out of scope (future work) |
| 169 | + |
| 170 | +- Per-spec global hooks (interceptor/decorator/exception handler). |
| 171 | +- Composite spec endpoint (e.g. serving a merged `/openapi.json` listing all bindings). |
| 172 | +- Spec hot reload. |
| 173 | +- Cross-spec `$ref` resolution. |
0 commit comments