Skip to content

Commit edce6b0

Browse files
committed
refactor: Drive OpenApiServer from a list of SpecBinding
The constructor now takes List<SpecBinding> instead of Spec; the Builder synthesises a single-element list so the public API is unchanged. HandlerConfig drops the handlers and securityValidators fields (moved to SpecBinding). The not-found catch-all context is registered only when no binding has a root base path.
1 parent 2bbf9be commit edce6b0

2 files changed

Lines changed: 51 additions & 50 deletions

File tree

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ git commit -m "refactor: Add SpecBinding record for per-spec server state"
111111

112112
The goal here is purely structural: the constructor stops taking a `Spec` and instead takes `List<SpecBinding>`. The Builder still produces a list of size 1. No public API change yet.
113113

114-
- [ ] **Step 1: Update `OpenApiServer` constructor signature and body**
114+
- [x] **Step 1: Update `OpenApiServer` constructor signature and body**
115115

116116
Replace the constructor (currently lines 73–157) with a version that loops over bindings:
117117

@@ -207,7 +207,7 @@ OpenApiServer(
207207
}
208208
```
209209

210-
- [ ] **Step 2: Drop the now-unused `handlers` field from `HandlerConfig`**
210+
- [x] **Step 2: Drop the now-unused `handlers` field from `HandlerConfig`**
211211

212212
In `OpenApiServer.java`, change the `HandlerConfig` record to remove `handlers` (it's per-binding now):
213213

@@ -223,7 +223,7 @@ record HandlerConfig(
223223

224224
Also drop the per-binding `securityValidators` from `HandlerConfig` (they live on each `SpecBinding` now). Update every callsite of `HandlerConfig` accordingly.
225225

226-
- [ ] **Step 3: Update `Builder.build()` to construct one binding and pass `List.of(binding)`**
226+
- [x] **Step 3: Update `Builder.build()` to construct one binding and pass `List.of(binding)`**
227227

228228
In `Builder.build()` (currently lines 364–402), replace the construction of `HandlerConfig` and the call to `new OpenApiServer(...)` with:
229229

@@ -268,17 +268,17 @@ public OpenApiServer build() throws IOException {
268268
}
269269
```
270270

271-
- [ ] **Step 4: Run the full unit test suite**
271+
- [x] **Step 4: Run the full unit test suite**
272272

273273
Run: `mvn -q test`
274274
Expected: BUILD SUCCESS. All 440 existing tests pass with no edits. (If anything fails, it's a refactoring mistake — fix before moving on.)
275275

276-
- [ ] **Step 5: Run the integration test suite**
276+
- [x] **Step 5: Run the integration test suite**
277277

278278
Run: `mvn -q verify -DskipITs=false`
279279
Expected: BUILD SUCCESS.
280280

281-
- [ ] **Step 6: Commit**
281+
- [x] **Step 6: Commit**
282282

283283
```bash
284284
git add src/main/java/com/retailsvc/http/OpenApiServer.java

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

Lines changed: 45 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,15 @@
1111
import com.retailsvc.http.internal.PemSslContext;
1212
import com.retailsvc.http.internal.RequestPreparationFilter;
1313
import com.retailsvc.http.internal.ResponseRenderer;
14-
import com.retailsvc.http.internal.Router;
1514
import com.retailsvc.http.internal.SecurityFilter;
15+
import com.retailsvc.http.internal.SpecBinding;
1616
import com.retailsvc.http.internal.TextTypeMapper;
1717
import com.retailsvc.http.internal.TlsHttpsConfigurator;
1818
import com.retailsvc.http.internal.gson.GsonJsonMapper;
1919
import com.retailsvc.http.spec.Operation;
2020
import com.retailsvc.http.spec.Spec;
2121
import com.retailsvc.http.spec.security.SecurityRequirement;
2222
import com.retailsvc.http.spec.security.SecurityScheme;
23-
import com.retailsvc.http.validate.DefaultValidator;
2423
import com.sun.net.httpserver.HttpContext;
2524
import com.sun.net.httpserver.HttpServer;
2625
import com.sun.net.httpserver.HttpsServer;
@@ -60,17 +59,15 @@ public class OpenApiServer implements AutoCloseable {
6059

6160
/** Internal grouping of handler-related configuration to keep the constructor signature small. */
6261
record HandlerConfig(
63-
Map<String, RequestHandler> handlers,
6462
List<RequestInterceptor> interceptors,
6563
List<ResponseDecorator> decorators,
6664
ExceptionHandler exceptionHandler,
6765
Map<String, RequestHandler> extras,
68-
Map<String, SchemeValidator> securityValidators,
6966
boolean externalAuth,
7067
List<AfterResponseHook> afterHooks) {}
7168

7269
OpenApiServer(
73-
Spec spec,
70+
java.util.List<SpecBinding> bindings,
7471
Map<String, TypeMapper> bodyMappers,
7572
HandlerConfig handlerConfig,
7673
int port,
@@ -79,17 +76,14 @@ record HandlerConfig(
7976
SSLContext sslContext)
8077
throws IOException {
8178

82-
requireNonNull(spec, "Spec must not be null");
79+
requireNonNull(bindings, "bindings must not be null");
80+
if (bindings.isEmpty()) {
81+
throw new IllegalStateException("at least one spec binding is required");
82+
}
8383
requireNonNull(bodyMappers, "bodyMappers must not be null");
84-
requireNonNull(handlerConfig.handlers(), "handlers must not be null");
8584
ExceptionHandler exceptionHandler = handlerConfig.exceptionHandler();
8685

8786
long t0 = System.currentTimeMillis();
88-
Router router = new Router(spec.operations());
89-
Map<String, Operation> operationsById =
90-
spec.operations().stream()
91-
.collect(Collectors.toUnmodifiableMap(Operation::operationId, op -> op));
92-
DefaultValidator validator = new DefaultValidator(spec::resolveSchema);
9387

9488
InetSocketAddress socketAddress =
9589
(bindAddress == null)
@@ -106,39 +100,47 @@ record HandlerConfig(
106100

107101
ResponseRenderer renderer = new ResponseRenderer(bodyMappers);
108102

109-
String basePath = Optional.ofNullable(spec.basePath()).orElse("/");
110-
HttpContext ctx = httpServer.createContext(basePath);
111-
ctx.getFilters()
112-
.add(
113-
new RequestPreparationFilter(
114-
spec,
115-
router,
116-
validator,
117-
bodyMappers,
118-
exceptionHandler,
119-
renderer,
120-
handlerConfig.afterHooks()));
121-
ctx.getFilters()
122-
.add(
123-
new SecurityFilter(
124-
operationsById,
125-
spec.securitySchemes(),
126-
spec.security(),
127-
handlerConfig.securityValidators(),
128-
handlerConfig.externalAuth()));
129-
ctx.setHandler(
130-
new DispatchHandler(
131-
handlerConfig.handlers(),
132-
handlerConfig.interceptors(),
133-
handlerConfig.decorators(),
134-
renderer));
135-
136-
if (!"/".equals(basePath)) {
103+
boolean anyBindingAtRoot = false;
104+
for (SpecBinding binding : bindings) {
105+
String basePath = Optional.ofNullable(binding.spec().basePath()).orElse("/");
106+
anyBindingAtRoot |= "/".equals(basePath);
107+
Map<String, Operation> operationsById =
108+
binding.spec().operations().stream()
109+
.collect(Collectors.toUnmodifiableMap(Operation::operationId, op -> op));
110+
HttpContext ctx = httpServer.createContext(basePath);
111+
ctx.getFilters()
112+
.add(
113+
new RequestPreparationFilter(
114+
binding.spec(),
115+
binding.router(),
116+
binding.validator(),
117+
bodyMappers,
118+
exceptionHandler,
119+
renderer,
120+
handlerConfig.afterHooks()));
121+
ctx.getFilters()
122+
.add(
123+
new SecurityFilter(
124+
operationsById,
125+
binding.spec().securitySchemes(),
126+
binding.spec().security(),
127+
binding.securityValidators(),
128+
handlerConfig.externalAuth()));
129+
ctx.setHandler(
130+
new DispatchHandler(
131+
binding.handlers(),
132+
handlerConfig.interceptors(),
133+
handlerConfig.decorators(),
134+
renderer));
135+
}
136+
137+
if (!anyBindingAtRoot) {
137138
ExtrasRouter extrasRouter = new ExtrasRouter(handlerConfig.extras(), renderer);
138139
HttpContext extrasCtx = httpServer.createContext("/", extrasRouter);
139140
extrasCtx.getFilters().add(new ExceptionFilter(exceptionHandler, renderer));
140141
} else if (!handlerConfig.extras().isEmpty()) {
141-
throw new IllegalStateException("extras cannot be registered when basePath is '/'");
142+
throw new IllegalStateException(
143+
"extras cannot be registered when a binding owns basePath '/'");
142144
}
143145
httpServer.start();
144146

@@ -377,19 +379,18 @@ public OpenApiServer build() throws IOException {
377379
exceptionHandler != null ? exceptionHandler : Handlers.defaultExceptionHandler();
378380
HandlerConfig handlerConfig =
379381
new HandlerConfig(
380-
handlers,
381382
interceptors,
382383
decorators,
383384
effectiveExceptionHandler,
384385
extras,
385-
Map.copyOf(securityValidators),
386386
externalAuth,
387387
List.copyOf(afterHooks));
388+
SpecBinding binding = SpecBinding.of(spec, handlers, securityValidators);
388389
int resolvedPort = resolvePort();
389390
SSLContext sslContext =
390391
httpsCertChain != null ? PemSslContext.load(httpsCertChain, httpsPrivateKey) : null;
391392
return new OpenApiServer(
392-
spec,
393+
java.util.List.of(binding),
393394
resolved,
394395
handlerConfig,
395396
resolvedPort,

0 commit comments

Comments
 (0)