Skip to content

Commit 243c578

Browse files
committed
feat: Add addSpec() builder method for multi-spec servers
Introduces two addSpec() overloads on OpenApiServer.Builder: one accepting a Spec, handler map, and security-validator map, and one convenience form with no validators. The build() method is rewritten to either consume the bindings list (addSpec path) or synthesise a single binding from the legacy spec()/handlers()/securityValidator() fields. Mixing the two forms throws IllegalStateException. Cross-binding duplicate-basePath detection added.
1 parent cdd7b0d commit 243c578

3 files changed

Lines changed: 125 additions & 19 deletions

File tree

docs/superpowers/plans/2026-05-22-multiple-specs.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ Committed as: `abe2369` (Google Java Formatter reformatted javadoc wrapping; pre
321321
**Files:**
322322
- Create: `src/test/java/com/retailsvc/http/MultiSpecServerTest.java`
323323

324-
- [ ] **Step 1: Write the failing test**
324+
- [x] **Step 1: Write the failing test**
325325

326326
```java
327327
package com.retailsvc.http;
@@ -383,12 +383,12 @@ class MultiSpecServerTest {
383383

384384
Note: the test uses `useExternalAuthentication()` to side-step per-spec validator wiring — the security branch is covered separately in Task 8. It uses the existing `/ping` operation from `openapi.json` (which is GET-only and unsecured per the fixture). If the fixture's `/ping` requires auth, switch to the first GET-only unsecured operation in the spec — discoverable by inspecting `src/test/resources/openapi.json`.
385385

386-
- [ ] **Step 2: Run the test to verify it fails**
386+
- [x] **Step 2: Run the test to verify it fails**
387387

388388
Run: `mvn -q test -Dtest=MultiSpecServerTest#servesTwoBindingsOnDistinctBasePaths`
389389
Expected: COMPILE FAILURE — `addSpec` does not yet exist on `Builder`.
390390

391-
- [ ] **Step 3: Do NOT commit yet** — proceed to Task 5 to make it pass.
391+
- [x] **Step 3: Do NOT commit yet** — proceed to Task 5 to make it pass.
392392

393393
---
394394

@@ -397,7 +397,7 @@ Expected: COMPILE FAILURE — `addSpec` does not yet exist on `Builder`.
397397
**Files:**
398398
- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java`
399399

400-
- [ ] **Step 1: Add bindings list and `addSpec` methods to `Builder`**
400+
- [x] **Step 1: Add bindings list and `addSpec` methods to `Builder`**
401401

402402
Inside `Builder`, alongside the existing fields, add:
403403

@@ -427,7 +427,7 @@ public Builder addSpec(Spec spec, Map<String, RequestHandler> handlers) {
427427
}
428428
```
429429

430-
- [ ] **Step 2: Update `build()` to consume bindings**
430+
- [x] **Step 2: Update `build()` to consume bindings**
431431

432432
Replace the `build()` method body so it either:
433433
- uses the explicit `bindings` list when `addSpec()` was called, OR
@@ -510,17 +510,17 @@ public OpenApiServer build() throws IOException {
510510
}
511511
```
512512

513-
- [ ] **Step 3: Run the new test**
513+
- [x] **Step 3: Run the new test**
514514

515515
Run: `mvn -q test -Dtest=MultiSpecServerTest#servesTwoBindingsOnDistinctBasePaths`
516516
Expected: PASS.
517517

518-
- [ ] **Step 4: Run the full suite to confirm nothing regressed**
518+
- [x] **Step 4: Run the full suite to confirm nothing regressed**
519519

520520
Run: `mvn -q test`
521521
Expected: BUILD SUCCESS.
522522

523-
- [ ] **Step 5: Commit**
523+
- [x] **Step 5: Commit**
524524

525525
```bash
526526
git add src/main/java/com/retailsvc/http/OpenApiServer.java \

src/main/java/com/retailsvc/http/OpenApiServer.java

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ public static final class Builder {
209209
private final LinkedHashMap<String, RequestHandler> extras = new LinkedHashMap<>();
210210
private final Map<String, SchemeValidator> securityValidators = new LinkedHashMap<>();
211211
private boolean externalAuth = false;
212+
private final List<SpecBinding> bindings = new ArrayList<>();
212213

213214
private Builder() {}
214215

@@ -287,6 +288,28 @@ public Builder useExternalAuthentication() {
287288
return this;
288289
}
289290

291+
/**
292+
* Registers an OpenAPI {@link Spec} with the handlers and security validators that serve it.
293+
* May be called more than once; each binding becomes its own {@link
294+
* com.sun.net.httpserver.HttpContext} at the spec's {@code basePath}. {@code operationId}s and
295+
* security-scheme names only need to be unique within a single spec.
296+
*/
297+
public Builder addSpec(
298+
Spec spec,
299+
Map<String, RequestHandler> handlers,
300+
Map<String, SchemeValidator> securityValidators) {
301+
requireNonNull(spec, "spec must not be null");
302+
requireNonNull(handlers, "handlers must not be null");
303+
requireNonNull(securityValidators, "securityValidators must not be null");
304+
bindings.add(SpecBinding.of(spec, handlers, securityValidators));
305+
return this;
306+
}
307+
308+
/** Convenience overload for specs that declare no security schemes. */
309+
public Builder addSpec(Spec spec, Map<String, RequestHandler> handlers) {
310+
return addSpec(spec, handlers, Map.of());
311+
}
312+
290313
public Builder exceptionHandler(ExceptionHandler exceptionHandler) {
291314
this.exceptionHandler = exceptionHandler;
292315
return this;
@@ -361,19 +384,51 @@ public Builder extraRoute(String path, RequestHandler handler) {
361384
}
362385

363386
public OpenApiServer build() throws IOException {
364-
requireNonNull(spec, "Spec must not be null");
365-
requireNonNull(handlers, "handlers must not be null");
366-
String basePath = Optional.ofNullable(spec.basePath()).orElse("/");
367-
for (String path : extras.keySet()) {
368-
if (path.equals(basePath)) {
387+
boolean usedLegacy = spec != null || handlers != null;
388+
boolean usedAddSpec = !bindings.isEmpty();
389+
if (usedLegacy && usedAddSpec) {
390+
throw new IllegalStateException(
391+
"use either spec()/handler()/securityValidator() or addSpec(), not both");
392+
}
393+
List<SpecBinding> effectiveBindings;
394+
if (usedAddSpec) {
395+
effectiveBindings = List.copyOf(bindings);
396+
} else {
397+
requireNonNull(spec, "Spec must not be null");
398+
requireNonNull(handlers, "handlers must not be null");
399+
effectiveBindings = List.of(SpecBinding.of(spec, handlers, securityValidators));
400+
}
401+
402+
for (SpecBinding b : effectiveBindings) {
403+
if (!externalAuth) {
404+
validateSecurityWiring(b.spec(), b.securityValidators());
405+
}
406+
validateHandlerWiring(b.spec(), b.handlers());
407+
}
408+
409+
Map<String, String> seenBasePaths = new LinkedHashMap<>();
410+
for (SpecBinding b : effectiveBindings) {
411+
String bp = Optional.ofNullable(b.spec().basePath()).orElse("/");
412+
String existingTitle = seenBasePaths.putIfAbsent(bp, b.spec().info().title());
413+
if (existingTitle != null) {
369414
throw new IllegalStateException(
370-
"extra handler path " + path + " conflicts with spec basePath " + basePath);
415+
"duplicate basePath '"
416+
+ bp
417+
+ "' across specs: '"
418+
+ existingTitle
419+
+ "' and '"
420+
+ b.spec().info().title()
421+
+ "'");
371422
}
372423
}
373-
if (!externalAuth) {
374-
validateSecurityWiring(spec, securityValidators);
424+
425+
for (String path : extras.keySet()) {
426+
if (seenBasePaths.containsKey(path)) {
427+
throw new IllegalStateException(
428+
"extra handler path " + path + " conflicts with spec basePath " + path);
429+
}
375430
}
376-
validateHandlerWiring(spec, handlers);
431+
377432
Map<String, TypeMapper> resolved = resolveBodyMappers(bodyMappers);
378433
ExceptionHandler effectiveExceptionHandler =
379434
exceptionHandler != null ? exceptionHandler : Handlers.defaultExceptionHandler();
@@ -385,12 +440,11 @@ public OpenApiServer build() throws IOException {
385440
extras,
386441
externalAuth,
387442
List.copyOf(afterHooks));
388-
SpecBinding binding = SpecBinding.of(spec, handlers, securityValidators);
389443
int resolvedPort = resolvePort();
390444
SSLContext sslContext =
391445
httpsCertChain != null ? PemSslContext.load(httpsCertChain, httpsPrivateKey) : null;
392446
return new OpenApiServer(
393-
List.of(binding),
447+
effectiveBindings,
394448
resolved,
395449
handlerConfig,
396450
resolvedPort,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.retailsvc.http;
2+
3+
import static java.net.HttpURLConnection.HTTP_OK;
4+
import static org.assertj.core.api.Assertions.assertThat;
5+
6+
import com.retailsvc.http.spec.Spec;
7+
import com.retailsvc.http.support.SpecFixtures;
8+
import java.net.URI;
9+
import java.net.http.HttpClient;
10+
import java.net.http.HttpRequest;
11+
import java.net.http.HttpResponse;
12+
import java.util.LinkedHashMap;
13+
import java.util.Map;
14+
import org.junit.jupiter.api.Test;
15+
16+
class MultiSpecServerTest {
17+
18+
@Test
19+
void servesTwoBindingsOnDistinctBasePaths() throws Exception {
20+
Spec v1 = SpecFixtures.specAt("http://localhost/api/v1");
21+
Spec v2 = SpecFixtures.specAt("http://localhost/api/v2");
22+
23+
RequestHandler v1Handler = req -> Response.ok(Map.of("version", "v1"));
24+
RequestHandler v2Handler = req -> Response.ok(Map.of("version", "v2"));
25+
26+
try (OpenApiServer server =
27+
OpenApiServer.builder()
28+
.port(0)
29+
.addSpec(v1, handlersFor(v1, v1Handler))
30+
.addSpec(v2, handlersFor(v2, v2Handler))
31+
.useExternalAuthentication()
32+
.build()) {
33+
34+
int port = server.listenPort();
35+
assertThat(get("http://localhost:" + port + "/api/v1/data").statusCode()).isEqualTo(HTTP_OK);
36+
assertThat(get("http://localhost:" + port + "/api/v2/data").statusCode()).isEqualTo(HTTP_OK);
37+
}
38+
}
39+
40+
private static Map<String, RequestHandler> handlersFor(Spec spec, RequestHandler shared) {
41+
Map<String, RequestHandler> out = new LinkedHashMap<>();
42+
spec.operations().forEach(op -> out.put(op.operationId(), shared));
43+
return out;
44+
}
45+
46+
private static HttpResponse<String> get(String url) throws Exception {
47+
return HttpClient.newHttpClient()
48+
.send(
49+
HttpRequest.newBuilder(URI.create(url)).GET().build(),
50+
HttpResponse.BodyHandlers.ofString());
51+
}
52+
}

0 commit comments

Comments
 (0)