Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/parallel-apis-specs-array.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"counterfact": minor
---

Add `specs` array to `counterfact.yaml` for parallel APIs.

A `specs` key can now be added to `counterfact.yaml` to mount multiple OpenAPI documents at distinct URL base paths from a single server instance. When `specs` is present it takes precedence over `spec`.

```yaml
specs:
- source: ./billing.yaml
base: billing

- source: https://example.com/identity.yaml
base: identity
```

Each spec generates route and type files into its own subdirectory under the configured destination (e.g. `billing/routes/` and `identity/routes/`). A separate `Dispatcher`, `Registry`, `CodeGenerator`, and `ModuleLoader` is created per spec, keeping each class focused on a single API.
8 changes: 8 additions & 0 deletions bin/counterfact.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,13 @@ async function main(source, destination) {
source = options.spec;
}

// `specs` in the config file takes precedence over a single `spec`.
// When `specs` is set, no single OpenAPI document is used at the top level.
const specs = Array.isArray(options.specs) ? options.specs : undefined;
if (specs) {
source = "_";
}

const destinationPath = nodePath.resolve(destination).replaceAll("\\", "/");

const basePath = nodePath.resolve(destinationPath).replaceAll("\\", "/");
Expand Down Expand Up @@ -372,6 +379,7 @@ async function main(source, destination) {
proxyPaths: new Map([["", Boolean(options.proxyUrl)]]),
proxyUrl: options.proxyUrl ?? "",
routePrefix: options.prefix,
specs,
startAdminApi: options.adminApi,
startRepl: options.repl,
startServer: options.serve,
Expand Down
32 changes: 32 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,38 @@ Run `npx counterfact@latest --help` for the full list.

---

## `counterfact.yaml` config file

Instead of passing every flag on the command line you can create a `counterfact.yaml` file in the directory where you run Counterfact. All CLI options are supported as YAML keys (kebab-case or camelCase):

```yaml
port: 9000
watch: true
proxy-url: https://api.example.com
```

### Parallel APIs (`specs`)

To mock **multiple OpenAPI documents** from a single server instance, use the `specs` array instead of the top-level `spec` key. Each entry requires a `source` (path or URL to an OpenAPI document) and a `base` (the URL segment the API is mounted under):

```yaml
specs:
- source: ./billing.yaml
base: billing

- source: https://example.com/identity.yaml
base: identity
```

With this config:

- `GET /billing/invoices` is validated against and served by `billing.yaml`.
- `GET /identity/users` is validated against and served by `identity.yaml`.
- Each spec gets its own subdirectory under the output directory (e.g. `billing/routes/`, `identity/routes/`).
- When `specs` is present it takes precedence over a `spec` key or the positional `[openapi.yaml]` argument.

---

## See also

- [Getting started](./getting-started.md)
Expand Down
250 changes: 242 additions & 8 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs, { rm } from "node:fs/promises";
import nodePath from "node:path";

import { createHttpTerminator, type HttpTerminator } from "http-terminator";
import type Koa from "koa";

import { startRepl as startReplServer } from "./repl/repl.js";
import { createRouteFunction } from "./repl/route-builder.js";
Expand Down Expand Up @@ -153,6 +154,121 @@ export async function createMswHandlers(
return handlers;
}

/**
* One per-spec bundle of services created in multi-spec mode.
* Each spec gets its own Registry, Dispatcher, CodeGenerator,
* Transpiler, and ModuleLoader so that the classes themselves
* never need to know about multiple APIs.
*/
interface SpecBundle {
base: string;
registry: Registry;
dispatcher: Dispatcher;
codeGenerator: CodeGenerator;
transpiler: Transpiler;
moduleLoader: ModuleLoader;
openApiDocument: Awaited<ReturnType<typeof loadOpenApiDocument>> | undefined;
compiledPathsDirectory: string;
}

/**
* Creates all per-spec services for one entry in `config.specs`.
*/
async function createSpecBundle(
config: Config,
specSource: string,
specBase: string,
contextRegistry: ContextRegistry,
nativeTs: boolean,
): Promise<SpecBundle> {
const specDest = nodePath
.join(config.basePath, specBase)
.replaceAll("\\", "/");

const compiledPathsDirectory = nodePath
.join(specDest, nativeTs ? "routes" : ".cache")
.replaceAll("\\", "/");

if (!nativeTs) {
await rm(compiledPathsDirectory, { force: true, recursive: true });
}

const registry = new Registry();
const openApiDocument = await loadOpenApiDocument(specSource);

const dispatcher = new Dispatcher(
registry,
contextRegistry,
openApiDocument,
config,
);

const codeGenerator = new CodeGenerator(
specSource,
specDest,
config.generate,
);

const transpiler = new Transpiler(
nodePath.join(specDest, "routes").replaceAll("\\", "/"),
compiledPathsDirectory,
"commonjs",
);

const moduleLoader = new ModuleLoader(
compiledPathsDirectory,
registry,
contextRegistry,
);

return {
base: specBase,
registry,
dispatcher,
codeGenerator,
transpiler,
moduleLoader,
openApiDocument,
compiledPathsDirectory,
};
}

/**
* Builds a single Koa middleware that fans requests out to the correct
* per-spec {@link Dispatcher} based on the URL base-path prefix.
*
* Requests that do not match any spec prefix are forwarded to `next`.
*/
function buildMultiSpecMiddleware(
specBundles: SpecBundle[],
config: Config,
): Koa.Middleware {
const specMiddlewares = specBundles.map((bundle) => ({
prefix: `/${bundle.base}`,
middleware: koaMiddleware(bundle.dispatcher, {
...config,
routePrefix: `/${bundle.base}`,
}),
}));

return async function multiSpecMiddleware(ctx, next) {
for (const { prefix, middleware } of specMiddlewares) {
if (ctx.request.path.startsWith(prefix)) {
// The per-spec koaMiddleware calls `next` only when the path does NOT
// match its routePrefix, which means we should move on to the next spec.
// When it handles the request it does NOT call next, so `calledNext`
// stays false and we return immediately.
let calledNext = false;
await middleware(ctx, async () => {
calledNext = true;
});
if (!calledNext) return;
}
}
await next();
};
}

/**
* Creates and configures a full Counterfact server instance.
*
Expand All @@ -174,6 +290,132 @@ export async function counterfact(config: Config) {

const nativeTs = await runtimeCanExecuteErasableTs();

const contextRegistry = new ContextRegistry();
const scenarioRegistry = new ScenarioRegistry();

contextRegistry.addEventListener("context-changed", () => {
void writeScenarioContextType(modulesPath);
});

// ── Multi-spec mode ────────────────────────────────────────────────────────
if (config.specs && config.specs.length > 0) {
const specBundles = await Promise.all(
config.specs.map((spec) =>
createSpecBundle(
config,
spec.source,
spec.base,
contextRegistry,
nativeTs,
),
),
);

// Guaranteed non-empty: we checked `config.specs.length > 0` above and
// `specBundles` has the same length as `config.specs`.
const primaryBundle = specBundles[0] as SpecBundle;
const primarySpec = config.specs[0] as (typeof config.specs)[number];

const compositeMiddleware = buildMultiSpecMiddleware(specBundles, config);

// Use the first spec's registry for the Koa admin/OpenAPI UI (best effort).
const primaryRegistry = primaryBundle.registry;

const koaApp = createKoaApp(
primaryRegistry,
compositeMiddleware,
{
...config,
openApiPath: primaryBundle.openApiDocument ? primarySpec.source : "_",
},
contextRegistry,
);

async function startMultiSpec(options: Config) {
const { generate, startServer, watch, buildCache } = options;

await Promise.all(
specBundles.map(async (bundle) => {
if (generate.routes || generate.types) {
await bundle.codeGenerator.generate();
}

if (watch.routes || watch.types) {
await bundle.codeGenerator.watch();
}
}),
);

let httpTerminator: HttpTerminator | undefined;

if (startServer) {
await Promise.all(
specBundles.map(async (bundle) => {
await bundle.openApiDocument?.watch();

if (!nativeTs) {
await bundle.transpiler.watch();
}

await bundle.moduleLoader.load();
await bundle.moduleLoader.watch();
}),
);

await runStartupScenario(
scenarioRegistry,
contextRegistry,
config,
primaryBundle.openApiDocument,
);

const server = koaApp.listen({ port: config.port });

httpTerminator = createHttpTerminator({ server });
} else if (buildCache) {
await Promise.all(
specBundles.map(async (bundle) => {
await bundle.transpiler.watch();
await bundle.transpiler.stopWatching();
}),
);
}

return {
async stop() {
await Promise.all(
specBundles.map(async (bundle) => {
await bundle.codeGenerator.stopWatching();
await bundle.transpiler.stopWatching();
await bundle.moduleLoader.stopWatching();
await bundle.openApiDocument?.stopWatching();
}),
);
await httpTerminator?.terminate();
},
};
}

return {
contextRegistry,
koaApp,
koaMiddleware: compositeMiddleware,
registry: primaryRegistry,
start: startMultiSpec,
startRepl: () =>
startReplServer(
contextRegistry,
primaryRegistry,
config,
undefined,
primaryBundle.openApiDocument,
scenarioRegistry,
),
};
}

// ── Single-spec mode (original behaviour) ─────────────────────────────────

const compiledPathsDirectory = nodePath
.join(modulesPath, nativeTs ? "routes" : ".cache")
.replaceAll("\\", "/");
Expand All @@ -184,10 +426,6 @@ export async function counterfact(config: Config) {

const registry = new Registry();

const contextRegistry = new ContextRegistry();

const scenarioRegistry = new ScenarioRegistry();

const codeGenerator = new CodeGenerator(
config.openApiPath,
config.basePath,
Expand Down Expand Up @@ -220,10 +458,6 @@ export async function counterfact(config: Config) {
scenarioRegistry,
);

contextRegistry.addEventListener("context-changed", () => {
void writeScenarioContextType(modulesPath);
});

const middleware = koaMiddleware(dispatcher, config);

const koaApp = createKoaApp(registry, middleware, config, contextRegistry);
Expand Down
13 changes: 13 additions & 0 deletions src/server/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
/** A single OpenAPI spec entry for parallel-API mode. */
export interface SpecEntry {
/** Path or URL to the OpenAPI document. */
source: string;
/** URL base path segment (e.g. `"billing"` mounts routes at `/billing/…`). */
base: string;
}

/** Runtime configuration for a Counterfact server instance. */
export interface Config {
/** Optional bearer token that protects the Admin API endpoints. */
Expand All @@ -19,6 +27,11 @@ export interface Config {
};
/** Path or URL to the OpenAPI document. Use `"_"` to skip spec loading. */
openApiPath: string;
/**
* Multiple OpenAPI specs to mount at distinct URL base paths.
* When present, takes precedence over {@link openApiPath}.
*/
specs?: SpecEntry[];
/** TCP port the HTTP server listens on. */
port: number;
/**
Expand Down
Loading
Loading