counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
120 lines (119 loc) • 4.44 kB
JavaScript
import { load as loadYaml } from "js-yaml";
import { JSONPath } from "jsonpath-plus";
import { readFile } from "./read-file.js";
/**
* Deeply merges `source` into `target`, overwriting scalar values and
* recursively merging plain objects. Arrays and non-plain-object values in
* `source` always overwrite the corresponding entry in `target`.
*/
function deepMerge(target, source) {
for (const [key, value] of Object.entries(source)) {
// Guard against prototype pollution attacks.
if (key === "__proto__" || key === "constructor" || key === "prototype") {
continue;
}
if (typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof target[key] === "object" &&
target[key] !== null &&
!Array.isArray(target[key])) {
deepMerge(target[key], value);
}
else {
target[key] = value;
}
}
}
/**
* Applies a list of overlay actions to `document` in place.
*
* Each action may either:
* - **update**: deep-merge the `action.update` object into every node matched
* by the JSONPath `action.target`.
* - **remove**: delete every node matched by `action.target` from its parent.
*
* @param document - The OpenAPI document object to mutate.
* @param actions - The ordered list of overlay actions to apply.
*/
export function applyOverlayActions(document, actions) {
for (const action of actions) {
const results = JSONPath({
path: action.target,
json: document,
resultType: "all",
});
if (action.remove === true) {
// Iterate in reverse so that removing by numeric index doesn't shift
// subsequent items in the same parent array.
for (const result of [...results].reverse()) {
const { parent, parentProperty } = result;
if (Array.isArray(parent)) {
parent.splice(Number(parentProperty), 1);
}
else {
delete parent[String(parentProperty)];
}
}
}
else if (action.update !== undefined) {
for (const result of results) {
if (typeof result.value === "object" &&
result.value !== null &&
!Array.isArray(result.value)) {
deepMerge(result.value, action.update);
}
}
}
}
}
/**
* Loads and parses an overlay file (YAML or JSON), validates that it looks
* like a valid OpenAPI overlay document, and returns the parsed object.
*
* @param overlayPath - Path or URL to the overlay file.
* @throws When the file cannot be read, parsed, or does not contain an
* `overlay` version field and an `actions` array.
*/
export async function loadOverlay(overlayPath) {
let content;
try {
content = await readFile(overlayPath);
}
catch (error) {
const details = error instanceof Error ? error.message : String(error);
throw new Error(`Could not read overlay file "${overlayPath}".\n${details}`, { cause: error });
}
let parsed;
try {
parsed = loadYaml(content);
}
catch (error) {
const details = error instanceof Error ? error.message : String(error);
throw new Error(`Could not parse overlay file "${overlayPath}".\n${details}`, { cause: error });
}
if (typeof parsed !== "object" ||
parsed === null ||
!("overlay" in parsed) ||
!("actions" in parsed) ||
!Array.isArray(parsed.actions)) {
throw new Error(`"${overlayPath}" does not appear to be a valid OpenAPI overlay file. ` +
`Expected an object with "overlay" and "actions" fields.`);
}
return parsed;
}
/**
* Applies all overlays listed in `overlayPaths` to `document` in order.
*
* Each overlay is loaded from disk (or a URL), parsed, and its actions are
* applied sequentially. The document is mutated in place.
*
* @param document - The OpenAPI document object to mutate.
* @param overlayPaths - Ordered list of paths/URLs to overlay files.
*/
export async function applyOverlays(document, overlayPaths) {
for (const overlayPath of overlayPaths) {
const overlay = await loadOverlay(overlayPath);
applyOverlayActions(document, overlay.actions);
}
}