UNPKG

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
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); }, }), }); }