UNPKG

counterfact

Version:

Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.

242 lines (241 loc) 11 kB
import fs from "node:fs/promises"; import nodePath from "node:path"; import { createHttpTerminator } from "http-terminator"; import { ApiRunner } from "./api-runner.js"; import { startRepl as startReplServer } from "./repl/repl.js"; import { createRouteFunction } from "./repl/route-builder.js"; import { createKoaApp } from "./server/web-server/create-koa-app.js"; import { Repository } from "./typescript-generator/repository.js"; import { ensureDirectoryExists } from "./util/ensure-directory-exists.js"; import { generateVersionsTsContent } from "./typescript-generator/versions-ts-generator.js"; export { loadOpenApiDocument } from "./server/load-openapi-document.js"; export { createMswHandlers, handleMswRequest, } from "./msw.js"; export async function runStartupScenario(scenarioRegistry, contextRegistry, config, openApiDocument) { const indexModule = scenarioRegistry.getModule("index"); if (!indexModule || typeof indexModule["startup"] !== "function") { return; } const scenario$ = { context: contextRegistry.find("/"), loadContext: (path) => contextRegistry.find(path), route: createRouteFunction(config.port, "localhost", openApiDocument), routes: {}, }; await indexModule["startup"](scenario$); } /** * Derives the URL prefix for a spec entry. * * Applies the following precedence rules: * 1. Explicit `prefix` (even `""`) → returned as-is. * 2. `group` + `version` both present → `/<group>/<version>`. * 3. `group` present (no `version`) → `/<group>`. * 4. Neither → `""` (root). */ function derivePrefix(spec) { if (spec.prefix !== undefined) { return spec.prefix; } if (spec.group && spec.version) { return `/${spec.group}/${spec.version}`; } if (spec.group) { return `/${spec.group}`; } return ""; } /** * Normalises the spec configuration to an array. * * When `specs` is provided, each entry's `prefix` is resolved via * {@link derivePrefix} so the rest of the code can assume `prefix` is always * a string. When `specs` is omitted, a single-entry array is constructed from * `config.openApiPath`, `config.prefix`, and `group = ""`. */ function normalizeSpecs(config, specs) { if (specs !== undefined) { return specs.map((spec) => ({ ...spec, prefix: derivePrefix(spec) })); } return [ { source: config.openApiPath, prefix: config.prefix, group: "", version: "", }, ]; } function validateSpecGroups(specs) { if (specs.length <= 1) { return; } const invalidSpecNumbers = specs .map((spec, index) => ({ group: spec.group.trim(), index })) .filter(({ group }) => group === "") .map(({ index }) => String(index + 1)); if (invalidSpecNumbers.length === 0) { const seenKeys = new Set(); const duplicateKeys = new Set(); for (const spec of specs) { const group = spec.group.trim(); const version = spec.version?.trim() ?? ""; // Use group@version as the uniqueness key so that the same group can // appear with different versions (e.g. v1 and v2 of the same API). // The empty-group case is already rejected above, so `group` is always // non-empty here and the `@version` suffix remains unambiguous. const key = version ? `${group}@${version}` : group; if (seenKeys.has(key)) { duplicateKeys.add(key); continue; } seenKeys.add(key); } if (duplicateKeys.size === 0) { return; } throw new Error(`Each spec must define a unique group (and version) when multiple APIs are configured (duplicates: ${[...duplicateKeys].join(", ")}).`); } throw new Error(`Each spec must define a non-empty group when multiple APIs are configured (invalid spec entries: ${invalidSpecNumbers.join(", ")}).`); } /** * Creates and configures a full Counterfact server instance. * * Supports one or more API specifications. Each spec produces its own * {@link ApiRunner}. When `specs` is omitted a single runner is created from * `config.openApiPath` and `config.prefix`. * * The returned object exposes handles for starting the server, stopping it, * and launching the interactive REPL. * * @param config - Runtime configuration (port, paths, feature flags, etc.). * @param specs - Optional array of spec entries. Omit to use a single spec * derived from `config.openApiPath` and `config.prefix`. * @returns An object containing the configured sub-systems and two entry-point * functions: * - `start(options)` — generates/watches code and optionally starts the HTTP * server; returns a `stop()` handle. * - `startRepl()` — launches the interactive Node.js REPL connected to the * live server state. */ export async function counterfact(config, specs) { const normalizedSpecs = normalizeSpecs({ openApiPath: config.openApiPath, prefix: config.prefix }, specs); validateSpecGroups(normalizedSpecs); // Compute the ordered versions per group (oldest first, as declared in specs). // This list is passed to each runner so that $.minVersion() can compare // version positions at runtime. const versionsByGroup = new Map(); for (const spec of normalizedSpecs) { const version = spec.version ?? ""; if (version) { const existing = versionsByGroup.get(spec.group) ?? []; versionsByGroup.set(spec.group, [...existing, version]); } } const runners = await Promise.all(normalizedSpecs.map((spec) => ApiRunner.create({ ...config, openApiPath: spec.source, // Per-spec overlays take precedence; fall back to config-level overlays // so that the --overlay CLI flag works in single-spec mode. overlays: spec.overlays ?? config.overlays ?? [], prefix: spec.prefix, }, spec.group, spec.version ?? "", versionsByGroup.get(spec.group) ?? []))); const koaApp = createKoaApp({ runners, config, }); // The REPL is configured using the first runner. const primaryRunner = runners[0]; async function start(options) { // Serialize generate() calls within each group to avoid concurrent writes // to the same output directory. Runners that share a group share the same // basePath subdirectory (and therefore the same counterfact-types // destination), so running them in parallel would cause a race when both // try to create that directory at startup. Different groups are still // generated in parallel. // // When multiple versioned specs share the same group, they also share a // single Repository instance so that the shared `types/paths/…` files // accumulate all versions into a merged Versioned<…> type instead of each // overwriting the previous version's types. const runnersByGroup = new Map(); for (const runner of runners) { const bucket = runnersByGroup.get(runner.group) ?? []; bucket.push(runner); runnersByGroup.set(runner.group, bucket); } await Promise.all(Array.from(runnersByGroup.values()).map(async (bucket) => { const sharedRepository = bucket.length > 1 ? new Repository() : undefined; for (const runner of bucket) { await runner.generate(sharedRepository); } })); if (options.generate?.types) { // Build a per-group map of unique non-empty version strings in // declaration order. new Set() preserves insertion order so the first // occurrence of each version is kept and duplicates are dropped without // reordering. const versionsByGroup = new Map(); for (const spec of normalizedSpecs) { const group = spec.group; const version = (spec.version ?? "").trim(); if (version === "") { continue; } const existing = versionsByGroup.get(group) ?? []; if (!existing.includes(version)) { existing.push(version); } versionsByGroup.set(group, existing); } // Write <basePath>/<group>/types/versions.ts for every group that has // at least one versioned spec. When the group is empty the path // collapses to <basePath>/types/versions.ts (the single-spec case). await Promise.all(Array.from(versionsByGroup.entries()).map(async ([group, versions]) => { const content = await generateVersionsTsContent(versions); const versionsFilePath = group ? nodePath.join(config.basePath, group, "types", "versions.ts") : nodePath.join(config.basePath, "types", "versions.ts"); /* eslint-disable security/detect-non-literal-fs-filename -- path is derived from the caller-supplied basePath and fixed suffixes. */ await ensureDirectoryExists(versionsFilePath); await fs.writeFile(versionsFilePath, content, "utf8"); /* eslint-enable security/detect-non-literal-fs-filename */ })); } await Promise.all(runners.map((runner) => runner.watch())); await Promise.all(runners.map((runner) => runner.start(options))); let httpTerminator; if (options.startServer) { await runStartupScenario(primaryRunner.scenarioRegistry, primaryRunner.contextRegistry, { port: config.port }, primaryRunner.openApiDocument); const server = koaApp.listen({ port: config.port, }); httpTerminator = createHttpTerminator({ server, }); } return { async stop() { await Promise.all(runners.map((runner) => runner.stopWatching())); await httpTerminator?.terminate(); }, }; } return { contextRegistry: primaryRunner.contextRegistry, koaApp, registry: primaryRunner.registry, start, startRepl: () => startReplServer(primaryRunner.contextRegistry, primaryRunner.registry, { port: config.port, proxyPaths: config.proxyPaths, proxyUrl: config.proxyUrl, }, undefined, // use the default print function (stdout) primaryRunner.openApiDocument, primaryRunner.scenarioRegistry, runners.map((runner) => ({ contextRegistry: runner.contextRegistry, group: runner.group, openApiDocument: runner.openApiDocument, registry: runner.registry, scenarioRegistry: runner.scenarioRegistry, }))), }; }