counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
339 lines (338 loc) • 13.4 kB
JavaScript
import { RawHttpClient } from "./raw-http-client.js";
/**
* Immutable fluent builder for constructing and sending HTTP requests from the
* Counterfact REPL.
*
* Each builder method returns a **new** `RouteBuilder` instance with the
* updated field — the original is never mutated. When all required parameters
* are set, call {@link send} to execute the request.
*
* ```ts
* // Inside the REPL:
* route("/pets/{petId}").method("get").path({ petId: 1 }).send();
* ```
*/
export class RouteBuilder {
routePath;
_body;
_headerParams;
_host;
_method;
_openApiDocument;
_pathParams;
_port;
_queryParams;
_operation;
constructor(routePath, options) {
this.routePath = routePath;
this._method = options.method;
this._pathParams = options.pathParams ?? {};
this._queryParams = options.queryParams ?? {};
this._headerParams = options.headerParams ?? {};
this._body = options.body;
this._port = options.port;
this._host = options.host ?? "localhost";
this._openApiDocument = options.openApiDocument;
this._operation = this._resolveOperation();
}
_resolveOperation() {
if (!this._openApiDocument || !this._method)
return undefined;
const method = this._method.toLowerCase();
const normalizedPath = this.routePath.toLowerCase();
for (const key of Object.keys(this._openApiDocument.paths)) {
if (key.toLowerCase() === normalizedPath) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return this._openApiDocument.paths[key][method];
}
}
return undefined;
}
clone(overrides) {
return new RouteBuilder(this.routePath, {
body: "body" in overrides ? overrides.body : this._body,
headerParams: overrides.headerParams ?? this._headerParams,
host: this._host,
method: overrides.method ?? this._method,
openApiDocument: this._openApiDocument,
pathParams: overrides.pathParams ?? this._pathParams,
port: this._port,
queryParams: overrides.queryParams ?? this._queryParams,
});
}
/**
* Returns a new builder with the HTTP method set.
*
* @param method - HTTP method name (case-insensitive, e.g. `"get"`, `"POST"`).
*/
method(method) {
return this.clone({ method: method.toUpperCase() });
}
/**
* Returns a new builder with additional path parameters merged in.
*
* @param params - Key/value map of path variable names to values.
*/
path(params) {
return this.clone({ pathParams: { ...this._pathParams, ...params } });
}
/**
* Returns a new builder with additional query parameters merged in.
*
* @param params - Key/value map of query parameter names to values.
*/
query(params) {
return this.clone({ queryParams: { ...this._queryParams, ...params } });
}
/**
* Returns a new builder with additional request headers merged in.
*
* @param params - Key/value map of header names to values.
*/
headers(params) {
return this.clone({ headerParams: { ...this._headerParams, ...params } });
}
/**
* Returns a new builder with the request body set.
*
* @param body - The request body (will be serialised to JSON or sent as-is).
*/
body(body) {
return this.clone({ body });
}
getOperation() {
return this._operation;
}
/**
* Returns `true` when a method is set and no required parameters are
* missing.
*/
ready() {
if (!this._method)
return false;
return this.missing() === undefined;
}
/**
* Returns a {@link MissingParams} object describing all required parameters
* that have not yet been set, or `undefined` when nothing is missing (or
* when the operation has no parameters).
*/
missing() {
const operation = this.getOperation();
if (!operation?.parameters)
return undefined;
const missingParams = {};
for (const param of operation.parameters) {
if (!param.required)
continue;
const paramType = param.type ?? param.schema?.type ?? "string";
const paramInfo = {
description: param.description,
name: param.name,
type: paramType,
};
if (param.in === "path" && !(param.name in this._pathParams)) {
missingParams.path = [...(missingParams.path ?? []), paramInfo];
}
else if (param.in === "query" && !(param.name in this._queryParams)) {
missingParams.query = [...(missingParams.query ?? []), paramInfo];
}
else if (param.in === "header" && !(param.name in this._headerParams)) {
missingParams.header = [...(missingParams.header ?? []), paramInfo];
}
}
if (Object.keys(missingParams).length === 0)
return undefined;
return missingParams;
}
/**
* Returns a human-readable help string describing the operation, its
* parameters, and the expected responses.
*/
help() {
const method = this._method ?? "[no method set]";
const operation = this.getOperation();
const lines = [];
lines.push(`${method} ${this.routePath}`);
if (operation?.summary) {
lines.push("");
lines.push("Summary:");
lines.push(` ${operation.summary}`);
}
if (operation?.description) {
lines.push("");
lines.push("Description:");
lines.push(` ${operation.description}`);
}
if (operation?.parameters && operation.parameters.length > 0) {
const pathParams = operation.parameters.filter((p) => p.in === "path");
const queryParams = operation.parameters.filter((p) => p.in === "query");
const headerParams = operation.parameters.filter((p) => p.in === "header");
if (pathParams.length > 0) {
lines.push("");
lines.push("Path Parameters:");
for (const p of pathParams) {
const paramType = p.type ?? p.schema?.type ?? "string";
const required = p.required ? "required" : "optional";
lines.push(` ${p.name} (${paramType}, ${required})`);
if (p.description)
lines.push(` Description: ${p.description}`);
if (p.enum)
lines.push(` Allowed values: ${p.enum.join(" | ")}`);
}
}
if (queryParams.length > 0) {
lines.push("");
lines.push("Query Parameters:");
for (const p of queryParams) {
const paramType = p.type ?? p.schema?.type ?? "string";
const required = p.required ? "required" : "optional";
lines.push(` ${p.name} (${paramType}, ${required})`);
if (p.description)
lines.push(` Description: ${p.description}`);
const enumValues = p.enum ?? p.schema?.enum;
if (enumValues)
lines.push(` Allowed values: ${enumValues.join(" | ")}`);
}
}
if (headerParams.length > 0) {
lines.push("");
lines.push("Headers:");
for (const p of headerParams) {
const paramType = p.type ?? p.schema?.type ?? "string";
const required = p.required ? "required" : "optional";
lines.push(` ${p.name} (${paramType}, ${required})`);
if (p.description)
lines.push(` Description: ${p.description}`);
}
}
}
if (operation?.responses) {
lines.push("");
lines.push("Responses:");
for (const [status, response] of Object.entries(operation.responses)) {
lines.push(` ${status}`);
if (response.description) {
lines.push(` Description: ${response.description}`);
}
}
}
return lines.join("\n");
}
/**
* Executes the HTTP request and returns the parsed response body.
*
* @throws When no HTTP method has been set.
* @throws When required parameters are missing.
* @throws When an unsupported HTTP method is used.
*/
async send() {
if (!this._method) {
throw new Error('No HTTP method set. Use .method("get") to set the method.');
}
const missing = this.missing();
if (missing) {
const lines = [
"Cannot execute request.",
"",
"Missing required parameters:",
];
if (missing.path) {
lines.push(" path:");
for (const p of missing.path) {
lines.push(` - ${p.name} (${p.type ?? "string"})`);
}
}
if (missing.query) {
lines.push(" query:");
for (const p of missing.query) {
lines.push(` - ${p.name} (${p.type ?? "string"})`);
}
}
if (missing.header) {
lines.push(" header:");
for (const p of missing.header) {
lines.push(` - ${p.name} (${p.type ?? "string"})`);
}
}
throw new Error(lines.join("\n"));
}
// Build URL with path parameters substituted
let url = this.routePath;
for (const [key, value] of Object.entries(this._pathParams)) {
url = url.replaceAll(`{${key}}`, String(value));
}
// Append query string
const queryEntries = Object.entries(this._queryParams);
if (queryEntries.length > 0) {
const qs = new URLSearchParams(queryEntries.map(([k, v]) => [k, String(v)])).toString();
url = `${url}?${qs}`;
}
const client = new RawHttpClient(this._host, this._port);
const headers = Object.fromEntries(Object.entries(this._headerParams).map(([k, v]) => [k, String(v)]));
const method = this._method.toLowerCase();
switch (method) {
case "get":
return client.get(url, headers);
case "head":
return client.head(url, headers);
case "delete":
return client.delete(url, headers);
case "options":
return client.options(url, headers);
case "trace":
return client.trace(url, headers);
case "post":
return client.post(url, this._body, headers);
case "put":
return client.put(url, this._body, headers);
case "patch":
return client.patch(url, this._body, headers);
default:
throw new Error(`Unsupported HTTP method: ${this._method}`);
}
}
[Symbol.for("nodejs.util.inspect.custom")]() {
const method = this._method ?? "[no method set]";
const operation = this.getOperation();
const lines = [];
lines.push(`${method} ${this.routePath}`);
if (operation?.parameters) {
const pathParams = operation.parameters.filter((p) => p.in === "path");
const queryParams = operation.parameters.filter((p) => p.in === "query");
if (pathParams.length > 0) {
lines.push("");
lines.push("Path:");
for (const p of pathParams) {
const value = this._pathParams[p.name];
lines.push(` ${p.name}: ${value !== undefined ? String(value) : "[missing]"}`);
}
}
if (queryParams.length > 0) {
lines.push("");
lines.push("Query:");
for (const p of queryParams) {
const value = this._queryParams[p.name];
const label = p.required ? "[missing]" : "[optional]";
lines.push(` ${p.name}: ${value !== undefined ? String(value) : label}`);
}
}
}
lines.push("");
lines.push(`Ready: ${this.ready()}`);
return lines.join("\n");
}
}
/**
* Creates a factory function that constructs a {@link RouteBuilder} for a
* given route path, pre-configured with the server's host, port, and OpenAPI
* document.
*
* @param port - The port the Counterfact server is listening on.
* @param host - The server hostname (default `"localhost"`).
* @param openApiDocument - Optional OpenAPI document for parameter introspection.
* @returns A function `(routePath: string) => RouteBuilder`.
*/
export function createRouteFunction(port, host, openApiDocument) {
return (routePath) => new RouteBuilder(routePath, { host, openApiDocument, port });
}