UNPKG

counterfact

Version:

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

163 lines (162 loc) 6.46 kB
import { Readable } from "node:stream"; import createDebug from "debug"; import koaProxy from "koa-proxies"; import { isProxyEnabledForPath } from "../is-proxy-enabled-for-path.js"; const debug = createDebug("counterfact:server:create-koa-app"); const HTTP_STATUS_CODE_OK = 200; const HEADERS_TO_DROP = new Set([ // body may not be gzip anymore "content-encoding", // length can change when Koa serializes "content-length", // hop-by-hop "transfer-encoding", "connection", "keep-alive", "upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailer", "trailers", ]); /** * SSE/JSONL/JSON-seq formatter map. Each entry maps a content-type to the * function that serialises a single stream item into the wire format. */ const STREAMING_FORMATTERS = new Map([ ["text/event-stream", (item) => `data: ${JSON.stringify(item)}\n\n`], ["application/json-seq", (item) => `\x1e${JSON.stringify(item)}\n`], ]); function defaultStreamFormatter(item) { return `${JSON.stringify(item)}\n`; } function isAsyncIterable(value) { return (value !== null && value !== undefined && typeof value === "object" && Symbol.asyncIterator in value); } /** * Converts an `AsyncIterable` to a Node.js `Readable` stream, serialising * each item according to the given content type. */ function asyncIterableToReadable(iterable, contentType) { const formatter = STREAMING_FORMATTERS.get(contentType) ?? defaultStreamFormatter; async function* generate() { for await (const item of iterable) { yield formatter(item); } } return Readable.from(generate()); } function addCors(ctx, allowedMethods, headers) { // Always append CORS headers, reflecting back the headers requested if any ctx.set("Access-Control-Allow-Origin", headers?.origin ?? "*"); ctx.set("Access-Control-Allow-Methods", allowedMethods); ctx.set("Access-Control-Allow-Headers", headers?.["access-control-request-headers"] ?? []); ctx.set("Access-Control-Expose-Headers", headers?.["access-control-request-headers"] ?? []); ctx.set("Access-Control-Allow-Credentials", "true"); } function getAuthObject(ctx) { const authHeader = ctx.request.headers.authorization; if (authHeader === undefined) { return undefined; } const [, base64Credentials] = authHeader.split(" "); if (base64Credentials === undefined) { return undefined; } const user = Buffer.from(base64Credentials, "base64").toString("utf8"); const [username, password] = user.split(":"); return { password, username }; } /** * Builds the Koa middleware function that bridges Koa's request context with * the Counterfact {@link Dispatcher}. * * Responsibilities: * - Respects `prefix` — requests outside the prefix are passed to `next`. * - Adds CORS headers to every response. * - Handles `OPTIONS` pre-flight requests (200 with CORS headers, no body). * - Proxies the request upstream when proxy is enabled for the path. * - Forwards the request to the dispatcher and maps the response back onto * the Koa context. * * @param prefix - The URL path prefix that this middleware handles, e.g. * `"/api/v1"`. Requests to paths that do not start with this prefix fall * through to the next middleware. * @param dispatcher - The {@link Dispatcher} instance that handles requests. * @param config - Server configuration (proxy settings, etc.). * @param proxy - Proxy factory; injectable for testing. * @returns A Koa middleware function. */ export function routesMiddleware(prefix, dispatcher, config, proxy = koaProxy) { return async function middleware(ctx, next) { const { proxyUrl } = config; debug("middleware running for path: %s", ctx.request.path); debug("prefix: %s", prefix); if (!ctx.request.path.startsWith(prefix)) { return await next(); } const auth = getAuthObject(ctx); const { body, headers, query, rawBody } = ctx.request; const path = ctx.request.path.slice(prefix.length); const method = ctx.request.method; if (isProxyEnabledForPath(path, config) && proxyUrl) { return proxy("/", { changeOrigin: true, target: proxyUrl })(ctx, next); } addCors(ctx, dispatcher.registry.allowedMethods(path), headers); if (method === "OPTIONS") { ctx.status = HTTP_STATUS_CODE_OK; return undefined; } const response = await dispatcher.request({ auth, body: method === "HEAD" || method === "GET" ? undefined : body, /* @ts-expect-error the value of a header can be an array and we don't have a solution for that yet */ headers, method, path, /* @ts-expect-error the value of a querystring item can be an array and we don't have a solution for that yet */ query, rawBody: method === "HEAD" || method === "GET" ? undefined : rawBody, req: { path: "", ...ctx.req }, }); if (isAsyncIterable(response.body)) { const contentType = response.contentType ?? "application/jsonl"; ctx.type = contentType; ctx.body = asyncIterableToReadable(response.body, contentType); if (contentType === "text/event-stream") { ctx.set("Cache-Control", "no-cache"); ctx.set("X-Accel-Buffering", "no"); } } else { ctx.body = response.body; } if (response.contentType !== undefined && response.contentType !== "unknown/unknown") { ctx.type = response.contentType; } if (response.headers) { for (const [key, value] of Object.entries(response.headers)) { if (!HEADERS_TO_DROP.has(key.toLowerCase())) { if (Array.isArray(value)) { ctx.set(key, value); } else { ctx.set(key, value.toString()); } } } } if (response.appendedHeaders) { for (const [key, value] of response.appendedHeaders) { ctx.res.appendHeader(key, value); } } ctx.status = response.status ?? HTTP_STATUS_CODE_OK; return undefined; }; }