counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
253 lines (252 loc) • 10.2 kB
JavaScript
import { generate } from "json-schema-faker";
/* eslint-disable security/detect-object-injection -- OpenAPI response/content maps are spec-defined dictionaries accessed by status code, media type, and example name. */
import { jsonToXml } from "./json-to-xml.js";
import { STREAMING_CONTENT_TYPES } from "../typescript-generator/streaming-content-types.js";
const DEFAULT_GENERATE_OPTIONS = {
useExamplesValue: true,
minItems: 0,
maxItems: 20,
failOnInvalidTypes: false,
fillProperties: false,
};
function convertToXmlIfNecessary(type, body, schema) {
if (type.endsWith("/xml")) {
return jsonToXml(body, schema, "root");
}
return body;
}
function oneOf(items) {
if (Array.isArray(items)) {
return items[Math.floor(Math.random() * items.length)];
}
return oneOf(Object.values(items));
}
function serializeCookie(name, value, options = {}) {
const parts = [`${name}=${value}`];
if (options.path !== undefined) {
parts.push(`Path=${options.path}`);
}
if (options.domain !== undefined) {
parts.push(`Domain=${options.domain}`);
}
if (options.maxAge !== undefined) {
parts.push(`Max-Age=${options.maxAge}`);
}
if (options.expires !== undefined) {
parts.push(`Expires=${options.expires.toUTCString()}`);
}
if (options.httpOnly) {
parts.push("HttpOnly");
}
if (options.secure) {
parts.push("Secure");
}
if (options.sameSite !== undefined) {
const sameSiteMap = { lax: "Lax", none: "None", strict: "Strict" };
parts.push(`SameSite=${sameSiteMap[options.sameSite]}`);
}
return parts.join("; ");
}
function unknownStatusCodeResponse(statusCode) {
return {
content: [
{
body: `The Open API document does not specify a response for status code ${statusCode ?? '""'}`,
type: "text/plain",
},
],
status: 500,
};
}
/**
* Creates the `$.response` builder proxy made available to route handlers.
*
* The proxy maps HTTP status codes to a fluent builder object. Example usage
* in a route handler:
*
* ```ts
* return $.response[200].json({ id: 1, name: "Fluffy" });
* ```
*
* @param operation - The OpenAPI operation descriptor used for schema-driven
* random generation and example resolution.
* @param config - Server configuration (e.g. `alwaysFakeOptionals`).
* @returns A {@link ResponseBuilder} proxy keyed by HTTP status code.
*/
export function createResponseBuilder(operation, config) {
return new Proxy({}, {
get: (target, statusCode) => ({
header(name, value) {
return {
...this,
headers: {
...this.headers,
[name]: value,
},
};
},
binary(body) {
const buffer = typeof body === "string"
? Buffer.from(body, "base64")
: Buffer.from(body);
return this.match("application/octet-stream", buffer);
},
cookie(name, value, options = {}) {
const cookieString = serializeCookie(name, value, options);
const existing = this.headers?.["set-cookie"];
const existingArray = Array.isArray(existing)
? existing
: existing !== undefined
? [existing]
: [];
return {
...this,
headers: {
...this.headers,
"set-cookie": [...existingArray, cookieString],
},
};
},
html(body) {
return this.match("text/html", body);
},
json(body) {
return this.match("application/json", body)
.match("text/json", body)
.match("text/x-json", body)
.match("application/xml", body)
.match("text/xml", body);
},
match(contentType, body) {
return {
...this,
content: [
...(this.content ?? []).filter((response) => response.type !== contentType),
{
body: convertToXmlIfNecessary(contentType, body, operation.responses[this.status ?? "default"]?.content?.[contentType]?.schema),
type: contentType,
},
],
};
},
empty() {
return { ...this, content: undefined };
},
example(name) {
if (operation.produces) {
return unknownStatusCodeResponse(this.status);
}
const response = operation.responses[this.status ?? "default"] ??
operation.responses.default;
if (response?.content === undefined) {
return unknownStatusCodeResponse(this.status);
}
const { content } = response;
const exampleExists = Object.values(content).some((contentType) => contentType?.examples?.[name] !== undefined);
if (!exampleExists) {
return {
content: [
{
body: `The OpenAPI document does not define an example named "${name}" for status code ${this.status ?? "unknown"}`,
type: "text/plain",
},
],
status: 500,
};
}
return {
...this,
content: Object.keys(content).map((type) => {
const example = content[type]?.examples?.[name];
const body = example !== undefined && "dataValue" in example
? example.dataValue
: example?.value;
return {
body: convertToXmlIfNecessary(type, body, content[type]?.schema),
type,
};
}),
};
},
async random() {
const generateOptions = config?.alwaysFakeOptionals
? {
...DEFAULT_GENERATE_OPTIONS,
alwaysFakeOptionals: true,
fixedProbabilities: true,
optionalsProbability: 1.0,
}
: DEFAULT_GENERATE_OPTIONS;
if (operation.produces) {
return this.randomLegacy();
}
const response = operation.responses[this.status ?? "default"] ??
operation.responses.default;
if (response?.content === undefined) {
return unknownStatusCodeResponse(this.status);
}
const { content } = response;
const generatedHeaders = {};
for (const [name, header] of Object.entries(response.headers ?? {})) {
if (header.required && !(name in (this.headers ?? {}))) {
generatedHeaders[name] = (await generate((header.schema ?? { type: "string" }), generateOptions));
}
}
return {
...this,
content: await Promise.all(Object.keys(content).map(async (type) => ({
body: convertToXmlIfNecessary(type, content[type]?.examples
? oneOf(Object.values(content[type]?.examples ?? []).map((example) => "dataValue" in example
? example.dataValue
: example.value))
: await generate((content[type]?.schema ?? {
type: "object",
}), generateOptions), content[type]?.schema),
type,
}))),
headers: {
...generatedHeaders,
...this.headers,
},
};
},
async randomLegacy() {
const response = operation.responses[this.status ?? "default"] ??
operation.responses.default;
if (response === undefined) {
return unknownStatusCodeResponse(this.status);
}
const body = response.examples
? oneOf(response.examples)
: await generate((response.schema ?? { type: "object" }), DEFAULT_GENERATE_OPTIONS);
return {
...this,
content: operation.produces?.map((type) => ({
body,
type,
})),
};
},
stream(iterable) {
const response = operation.responses[this.status ?? "default"] ??
operation.responses.default;
const contentTypes = Object.keys(response?.content ?? {});
const contentType = contentTypes.find((ct) => STREAMING_CONTENT_TYPES.has(ct)) ??
"text/event-stream";
return {
body: iterable,
contentType,
headers: this.headers,
status: this.status,
};
},
status: Number.parseInt(statusCode, 10),
text(body) {
return this.match("text/plain", body);
},
xml(body) {
return this.match("application/xml", body).match("text/xml", body);
},
}),
});
}