counterfact
Version:
Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.
192 lines (191 loc) • 6.27 kB
JavaScript
/**
* A node in the dereferenced OpenAPI spec tree.
*
* A `Requirement` wraps a raw JSON object (`data`) together with its location
* (`url`, a JSON Pointer) and a back-reference to the owning
* {@link Specification}. Navigation methods (`get`, `select`, `find`, …)
* transparently follow `$ref` pointers.
*/
export class Requirement {
data;
url;
specification;
/**
* The requirement that produced this one via a `get()` call, or `undefined`
* for root requirements that were constructed directly.
*
* For path-traversal purposes this is the "logical" parent: when a `$ref` is
* followed, the parent is the resolved reference target rather than the
* `$ref` node itself.
*/
parent;
constructor(data, url = "", specification = undefined) {
this.data = data;
this.url = url;
this.specification = specification;
}
/** `true` when this node is a JSON Reference (`$ref`) rather than inline data. */
get isReference() {
return (typeof this.data === "object" &&
this.data !== null &&
this.data["$ref"] !== undefined);
}
/**
* When this node is a JSON Reference, returns the raw `$ref` URL string.
* Returns `undefined` for non-reference (inline) nodes.
*/
get refUrl() {
if (typeof this.data !== "object" || this.data === null) {
return undefined;
}
return this.data["$ref"];
}
/**
* Resolves the `$ref` and returns the target {@link Requirement}.
*
* @throws When `isReference` is `false` or the specification is not set.
*/
reference() {
return this.specification.getRequirement(this.refUrl);
}
/**
* Returns `true` when this node has a child property named `item`.
*
* Transparently follows `$ref` references.
*
* @param item - The property key to check.
*/
has(item) {
if (this.isReference) {
return this.reference().has(item);
}
if (typeof this.data !== "object" || this.data === null) {
return false;
}
return item in this.data;
}
/**
* Returns the child {@link Requirement} for `item`, or `undefined`.
*
* @param item - The property key (string) or array index (number).
*/
get(item) {
if (this.isReference) {
return this.reference().get(item);
}
const key = String(item);
if (!this.has(key)) {
return undefined;
}
if (typeof this.data !== "object" || this.data === null) {
return undefined;
}
const objectData = this.data;
const child = new Requirement(objectData[key], `${this.url}/${this.escapeJsonPointer(key)}`, this.specification);
child.parent = this;
return child;
}
/**
* Navigates to a descendant node using a slash-delimited JSON Pointer path.
*
* Tilde-escaped characters (`~0` → `~`, `~1` → `/`) and percent-encoded
* characters are unescaped during traversal.
*
* @param path - A slash-delimited path (e.g. `"responses/200/content"`).
* @returns The target {@link Requirement}, or `undefined` if the path does
* not exist.
*/
select(path) {
const parts = path
.split("/")
.map((p) => this.unescapeJsonPointer(p))
// Unescape URL encoded characters (e.g. %20 -> " ")
// Technically we should not be unescaping, but it came up in https://github.com/pmcelhaney/counterfact/issues/1083
// and I can't think of a reason anyone would intentionally put a % in a key name.
.map((part) => {
try {
return decodeURIComponent(part);
}
catch {
return part;
}
});
// eslint-disable-next-line @typescript-eslint/no-this-alias
let result = this;
for (const part of parts) {
result = result.get(part);
if (result === undefined) {
return undefined;
}
}
return result;
}
/**
* Iterates over all child properties and calls `callback` with each child
* requirement and its key.
*
* @param callback - Called for each child with `(child, key)`.
*/
forEach(callback) {
if (typeof this.data !== "object" || this.data === null) {
return;
}
Object.keys(this.data).forEach((key) => {
callback(this.select(this.escapeJsonPointer(key)), key);
});
}
/**
* Maps over all child properties and returns the collected results.
*
* @param callback - Transformation function called with `(child, key)`.
*/
map(callback) {
const result = [];
this.forEach((value, key) => result.push(callback(value, key)));
return result;
}
/**
* Maps and flattens over all child properties.
*
* @param callback - Transformation function that may return a value or an
* array of values.
*/
flatMap(callback) {
return this.map(callback).flat();
}
/**
* Returns the first child for which `callback` returns `true`, or
* `undefined` when nothing matches.
*
* @param callback - Predicate called with `(child, key)`.
*/
find(callback) {
let result;
this.forEach((value, key) => {
if (result === undefined && callback(value, key)) {
result = value;
}
});
return result;
}
/**
* Escapes a JSON Pointer token: `~` → `~0`, `/` → `~1`.
*
* @param value - The token to escape.
*/
escapeJsonPointer(value) {
if (typeof value !== "string")
return value;
return value.replaceAll("~", "~0").replaceAll("/", "~1");
}
/**
* Unescapes a JSON Pointer token: `~1` → `/`, `~0` → `~`.
*
* @param pointer - The token to unescape.
*/
unescapeJsonPointer(pointer) {
if (typeof pointer !== "string")
return pointer;
return pointer.replaceAll("~1", "/").replaceAll("~0", "~");
}
}