counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
390 lines (389 loc) • 18.7 kB
JavaScript
import { pathJoin } from "../util/forward-slash-path.js";
import { CONTEXT_FILE_TOKEN } from "./context-file-token.js";
import { buildJsDoc } from "./jsdoc.js";
import { ParameterExportTypeCoder } from "./parameter-export-type-coder.js";
import { ParametersTypeCoder } from "./parameters-type-coder.js";
import { READ_ONLY_COMMENTS } from "./read-only-comments.js";
import { RESERVED_WORDS } from "./reserved-words.js";
import { ResponsesTypeCoder } from "./responses-type-coder.js";
import { SchemaTypeCoder } from "./schema-type-coder.js";
import { STREAMING_CONTENT_TYPES } from "./streaming-content-types.js";
import { TypeCoder } from "./type-coder.js";
import { Requirement } from "./requirement.js";
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words
function sanitizeIdentifier(value) {
// Treat any run of non-identifier characters as a camelCase separator
let result = value.replaceAll(/[^\w$]+(?<next>.)/gu, (_, char) => char.toUpperCase());
// Strip any trailing non-identifier characters (no following char to capitalize)
result = result.replaceAll(/[^\w$]/gu, "");
// If the identifier starts with a digit, prefix with an underscore
if (/^\d/u.test(result)) {
result = `_${result}`;
}
// If the identifier is a reserved word, append an underscore
if (RESERVED_WORDS.has(result)) {
result = `${result}_`;
}
return result || "_";
}
/**
* Generates the TypeScript type for a single OpenAPI operation.
*
* The emitted type describes the function signature that a Counterfact route
* handler must satisfy, including strongly-typed `query`, `path`, `headers`,
* `cookie`, `body`, `context`, `response`, and `user` arguments.
*
* Output is written to `types/paths/<route>.types.ts`.
*
* **Versioned APIs**: when `version` is non-empty this coder emits only a
* sentinel `{raw: ""}` export (suppressing the normal flat type) and
* registers a formatter on the shared script so that
* {@link Script.versionsTypeStatements} can later emit the merged
* `HTTP_<METHOD>_$_Versions` map and the `HTTP_<METHOD>` handler type.
* Each version's `$`-argument type is emitted to
* `types/<version>/paths/<path>.types.ts` by {@link VersionedArgTypeCoder}.
*/
export class OperationTypeCoder extends TypeCoder {
requestMethod;
securitySchemes;
constructor(requirement, version = "", requestMethod = "", securitySchemes = []) {
super(requirement, version);
if (requestMethod === "") {
throw new Error("requestMethod is required");
}
this.requestMethod = requestMethod;
this.securitySchemes = securitySchemes;
}
/**
* Returns the base identifier for this operation, derived from its
* `operationId` (sanitised) or falling back to `HTTP_<METHOD>`.
*/
getOperationBaseName() {
const operationId = this.requirement.get("operationId")?.data;
return operationId
? sanitizeIdentifier(operationId)
: `HTTP_${this.requestMethod.toUpperCase()}`;
}
jsdoc() {
return buildJsDoc(this.requirement.data);
}
names() {
return super.names(this.getOperationBaseName());
}
/**
* Generates and exports a named parameter type (e.g. `ListPets_Query`) from
* `modulePath` and returns the exported type name.
*
* Returns `"never"` without creating an export when `inlineType` is
* `"never"`.
*
* @param script - The script being assembled.
* @param parameterKind - `"query"`, `"path"`, `"headers"`, or `"cookie"`.
* @param inlineType - The inline TypeScript type string to export.
* @param baseName - The base identifier prefix for the exported type name.
* @param modulePath - The repository-relative path of the type file.
*/
exportParameterType(script, parameterKind, inlineType, baseName, modulePath) {
if (inlineType === "never") {
return "never";
}
const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
const typeName = `${baseName}_${capitalize(parameterKind)}`;
const coder = new ParameterExportTypeCoder(this.requirement, this.version, typeName, inlineType, parameterKind);
coder._modulePath = modulePath;
return script.export(coder, true);
}
/**
* Returns the union of all possible response type shapes for this operation.
*
* @param script - The script being assembled (used to resolve imports).
*/
responseTypes(script) {
return this.requirement
.get("responses")
.flatMap((response, responseCode) => {
const status = responseCode === "default"
? "number | undefined"
: Number.parseInt(responseCode, 10);
if (response.has("content")) {
return response.get("content").map((content, contentType) => {
let bodyType;
if (content.has("itemSchema") &&
STREAMING_CONTENT_TYPES.has(contentType)) {
bodyType = `AsyncIterable<${new SchemaTypeCoder(content.get("itemSchema"), this.version).write(script)}>`;
}
else {
bodyType = content.has("schema")
? new SchemaTypeCoder(content.get("schema"), this.version).write(script)
: "unknown";
}
return `{
status: ${status},
contentType?: "${contentType}",
body?: ${bodyType}
}`;
});
}
if (response.has("schema")) {
const producesReq = this.requirement?.get("produces") ??
this.requirement.specification?.rootRequirement?.get("produces");
const produces = producesReq?.data;
if (produces) {
return produces
.map((contentType) => `{
status: ${status},
contentType?: "${contentType}",
body?: ${new SchemaTypeCoder(response.get("schema"), this.version).write(script)}
}`)
.join(" | ");
}
}
return `{
status: ${status}
}`;
})
.join(" | ");
}
modulePath() {
const pathString = this.requirement.url
.split("/")
.at(-2)
.replaceAll("~1", "/");
return `${pathJoin("types/paths", pathString === "/" ? "/index" : pathString)}.types.ts`;
}
/**
* Returns the TypeScript type for the `user` argument.
*
* When the operation is protected by HTTP Basic auth, the type is
* `{username?: string, password?: string}`. Otherwise it is `"never"`.
*/
userType() {
if (this.securitySchemes.some(({ scheme, type }) => type === "http" && scheme === "basic")) {
return "{username?: string, password?: string}";
}
return "never";
}
/**
* Returns the TypeScript type for the `auth` argument.
*
* Includes basic-auth credentials when present and `apiKey` when at least one
* apiKey security scheme is configured.
*/
authType() {
const fields = new Set();
if (this.securitySchemes.some(({ scheme, type }) => type === "http" && scheme === "basic")) {
fields.add("username?: string");
fields.add("password?: string");
}
if (this.securitySchemes.some(({ type }) => type === "apiKey")) {
fields.add("apiKey: string");
}
return fields.size === 0 ? "never" : `{${[...fields].join(", ")}}`;
}
/**
* Returns the effective parameters for this operation by merging path-item-level
* parameters with operation-level parameters. Per the OpenAPI specification,
* operation-level parameters override path-item-level parameters that share
* the same `name` and `in` location.
*
* Uses `this.requirement.parent` (the path item requirement) to access
* path-item-level parameters directly, without URL string parsing.
*
* When the parent is not set (e.g. in unit tests that construct requirements
* directly), only the operation-level parameters are returned.
*/
getEffectiveParameters() {
const operationParams = this.requirement.get("parameters");
const pathItemParams = this.requirement.parent?.get("parameters");
const apiKeyParameters = this.securitySchemes
.filter(({ in: location, name, type }) => type === "apiKey" &&
typeof name === "string" &&
(location === "header" ||
location === "query" ||
location === "cookie"))
.map(({ in: location, name }) => ({
in: location,
name,
required: true,
schema: { type: "string" },
}));
if (!pathItemParams && !operationParams && apiKeyParameters.length === 0) {
return undefined;
}
if (!pathItemParams && apiKeyParameters.length === 0) {
return operationParams;
}
if (!operationParams && apiKeyParameters.length === 0) {
return pathItemParams;
}
// Merge using a Map keyed on `${in}:${name}`.
// Path-level params are added first; operation-level and security-level
// params override them.
const pathData = pathItemParams?.data ?? [];
const opData = operationParams?.data ?? [];
const map = new Map();
for (const p of pathData) {
map.set(`${p.in}:${p.name}`, p);
}
for (const p of opData) {
map.set(`${p.in}:${p.name}`, p);
}
for (const p of apiKeyParameters) {
map.set(`${p.in}:${p.name}`, p);
}
return new Requirement([...map.values()], this.requirement.url, this.requirement.specification);
}
/**
* Builds the `OmitValueWhenNever<{…}>` dollar-argument type body and sets
* up all required shared-type imports on `script`.
*
* This helper is reused by both {@link writeCode} (non-versioned) and
* {@link VersionedArgTypeCoder.writeCode} (per-version file).
*
* @param script - The script to write imports and parameter-type exports into.
* @param baseName - Identifier prefix used for named parameter-type exports.
* @param modulePath - Repository-relative path for parameter-type exports.
*/
buildDollarArgType(script, baseName, modulePath) {
const xType = script.importSharedType("WideOperationArgument");
script.importSharedType("OmitValueWhenNever");
script.importSharedType("COUNTERFACT_RESPONSE");
const contextTypeImportName = script.importExternalType("Context", CONTEXT_FILE_TOKEN);
const parameters = this.getEffectiveParameters();
const queryType = new ParametersTypeCoder(parameters, this.version, "query").write(script);
const pathType = new ParametersTypeCoder(parameters, this.version, "path").write(script);
const headersType = new ParametersTypeCoder(parameters, this.version, "header").write(script);
const cookieType = new ParametersTypeCoder(parameters, this.version, "cookie").write(script);
const bodyRequirement = (this.requirement.get("consumes") ??
this.requirement.specification?.rootRequirement?.get("consumes"))
? parameters
?.find((parameter) => ["body", "formData"].includes(parameter.get("in")?.data))
?.get("schema")
: this.requirement.select("requestBody/content/application~1json/schema");
const bodyType = bodyRequirement === undefined
? "never"
: new SchemaTypeCoder(bodyRequirement, this.version).write(script);
const openApi2MediaTypes = (this.requirement.get("produces")?.data ??
this.requirement.specification?.rootRequirement?.get("produces")
?.data);
const responseType = new ResponsesTypeCoder(this.requirement.get("responses"), this.version, openApi2MediaTypes).write(script);
const proxyType = "(url: string) => COUNTERFACT_RESPONSE";
const delayType = "(milliseconds: number, maxMilliseconds?: number) => Promise<void>";
const queryTypeName = this.exportParameterType(script, "query", queryType, baseName, modulePath);
const pathTypeName = this.exportParameterType(script, "path", pathType, baseName, modulePath);
const headersTypeName = this.exportParameterType(script, "headers", headersType, baseName, modulePath);
const cookieTypeName = this.exportParameterType(script, "cookie", cookieType, baseName, modulePath);
// OpenAPI 3.2 querystring parameter: the entire query string treated as a
// single typed object (similar to requestBody for query strings).
const querystringParam = parameters?.find((parameter) => parameter.get("in")?.data === "querystring");
const querystringType = querystringParam?.has("schema") === true
? new SchemaTypeCoder(querystringParam.get("schema"), this.version).write(script)
: "never";
const querystringTypeName = this.exportParameterType(script, "querystring", querystringType, baseName, modulePath);
const versionLiteralType = this.version !== "" ? `"${this.version}"` : "never";
return `OmitValueWhenNever<{ query: ${queryTypeName}, querystring: ${querystringTypeName}, path: ${pathTypeName}, headers: ${headersTypeName}, cookie: ${cookieTypeName}, body: ${bodyType}, context: ${contextTypeImportName}, response: ${responseType}, x: ${xType}, proxy: ${proxyType}, auth: ${this.authType()}, user: ${this.userType()}, delay: ${delayType}, version: ${versionLiteralType} }>`;
}
writeCode(script) {
script.comments = READ_ONLY_COMMENTS;
if (this.version !== "") {
// Versioned case: suppress the normal flat export and register a
// formatter so that Script.versionsTypeStatements() can emit the
// merged HTTP_<METHOD>_$_Versions + HTTP_<METHOD> types after all
// versions have been declared via declareVersion().
const versionedType = script.importVersionsType("Versioned");
const maybePromiseType = script.importSharedType("MaybePromise");
const counterfactResponseType = script.importSharedType("COUNTERFACT_RESPONSE");
const baseName = this.getOperationBaseName();
script.setVersionFormatter(baseName, (versionCodes) => {
const versionsTypeName = `${baseName}_$_Versions`;
const versionMap = Array.from(versionCodes, ([v, code]) => `"${v}": ${code}`).join("; ");
return [
`type ${versionsTypeName} = { ${versionMap} };`,
`export type ${baseName} = ($: ${versionedType}<${versionsTypeName}>) => ${maybePromiseType}<${counterfactResponseType}>;`,
].join("\n");
});
// Return a raw-empty sentinel so exportStatements() emits nothing for
// this export entry. The real export is produced by
// versionsTypeStatements().
return { raw: "" };
}
// Non-versioned case: existing flat-type output.
// Import in the same order as the original writeCode so that the emitted
// import block is identical to the pre-refactor output (snapshot-safe).
script.importSharedType("WideOperationArgument");
script.importSharedType("OmitValueWhenNever");
script.importSharedType("MaybePromise");
script.importSharedType("COUNTERFACT_RESPONSE");
const baseName = this.getOperationBaseName();
const modulePath = this.modulePath();
const dollarArgType = this.buildDollarArgType(script, baseName, modulePath);
return `($: ${dollarArgType}) => MaybePromise<COUNTERFACT_RESPONSE>`;
}
}
/**
* Emits a per-version `$`-argument type to
* `types/<version>/paths/<path>.types.ts`.
*
* When called from a *different* script (e.g. the shared
* `types/paths/…` script via `Script.declareVersion`), `write()` delegates to
* `script.importType(this)` so that the type is written to the per-version
* file and an import is added to the calling script.
*
* Only the `OmitValueWhenNever<{…}>` type body is emitted — the
* function-wrapper `($: Versioned<…>) => MaybePromise<COUNTERFACT_RESPONSE>`
* is assembled by the shared script's `versionsTypeStatements()`.
*/
export class VersionedArgTypeCoder extends OperationTypeCoder {
/**
* Include the version in the cache key so v1 and v2 coders are treated as
* distinct exports even when they share the same requirement URL.
*/
get id() {
return `${super.id}:${this.version}`;
}
/**
* The per-version `$`-argument type is emitted to
* `types/<version>/paths/<path>.types.ts`, not to the shared path.
*/
modulePath() {
const pathString = this.requirement.url
.split("/")
.at(-2)
.replaceAll("~1", "/");
return `${pathJoin(`types/${this.version}/paths`, pathString === "/" ? "/index" : pathString)}.types.ts`;
}
/**
* Names are version-qualified (e.g. `HTTP_GET_$_v1`) so that importing
* multiple versions into the shared script requires no aliasing.
*/
*names() {
const baseName = `${this.getOperationBaseName()}_$_${sanitizeIdentifier(this.version)}`;
yield baseName;
let index = 1;
const MAX = 100;
while (index < MAX) {
index += 1;
yield `${baseName}${index}`;
}
}
/**
* When called from the per-version file itself, generate the actual type.
* When called from any other script (e.g. the shared file), export to the
* per-version file and import the result back into that script.
*/
write(script) {
if (script.path === this.modulePath()) {
return this.writeCode(script);
}
return script.importType(this);
}
/**
* Generates the `OmitValueWhenNever<{…}>` dollar-argument type and writes
* it to the per-version script.
*/
writeCode(script) {
script.comments = READ_ONLY_COMMENTS;
const baseName = this.getOperationBaseName();
return this.buildDollarArgType(script, baseName, this.modulePath());
}
}