Skip to content

Commit d5a3e31

Browse files
committed
docs: Design spec for multiple OpenAPI specs per server
1 parent a1f1d76 commit d5a3e31

1 file changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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

Comments
 (0)