203 lines
6.5 KiB
TypeScript
203 lines
6.5 KiB
TypeScript
import type {
|
|
ApiDocument,
|
|
JsonObject,
|
|
JsonValue,
|
|
LocalWorkspace,
|
|
} from "./types";
|
|
import { normalizeFilename } from "./parse";
|
|
|
|
function parentDirectory(filename: string): string {
|
|
const index = filename.lastIndexOf("/");
|
|
return index < 0 ? "" : filename.slice(0, index + 1);
|
|
}
|
|
|
|
function normalizeRelative(base: string, relative: string): string {
|
|
const segments = `${parentDirectory(base)}${relative}`.split("/");
|
|
const output: string[] = [];
|
|
for (const segment of segments) {
|
|
if (!segment || segment === ".") continue;
|
|
if (segment === "..") {
|
|
if (!output.length)
|
|
throw new TypeError("Reference escapes the local workspace");
|
|
output.pop();
|
|
} else output.push(segment);
|
|
}
|
|
return normalizeFilename(output.join("/"));
|
|
}
|
|
|
|
function pointerValue(value: JsonValue, fragment: string): JsonValue {
|
|
if (!fragment || fragment === "#") return value;
|
|
if (!fragment.startsWith("#/"))
|
|
throw new SyntaxError(
|
|
`Only JSON Pointer fragments are supported: ${fragment}`,
|
|
);
|
|
let current: JsonValue = value;
|
|
for (const raw of fragment.slice(2).split("/")) {
|
|
const token = decodeURIComponent(raw)
|
|
.replace(/~1/gu, "/")
|
|
.replace(/~0/gu, "~");
|
|
if (Array.isArray(current)) {
|
|
if (!/^(?:0|[1-9]\d*)$/u.test(token) || Number(token) >= current.length)
|
|
throw new ReferenceError(`Array reference token not found: ${token}`);
|
|
current = current[Number(token)]!;
|
|
} else if (
|
|
current &&
|
|
typeof current === "object" &&
|
|
Object.hasOwn(current, token)
|
|
)
|
|
current = current[token]!;
|
|
else throw new ReferenceError(`Reference token not found: ${token}`);
|
|
}
|
|
return current;
|
|
}
|
|
|
|
export function createWorkspace(
|
|
entry: ApiDocument,
|
|
supporting: readonly ApiDocument[] = [],
|
|
): LocalWorkspace {
|
|
const documents = new Map<string, ApiDocument>();
|
|
for (const document of [entry, ...supporting]) {
|
|
if (documents.has(document.filename))
|
|
throw new TypeError(`Duplicate local filename: ${document.filename}`);
|
|
documents.set(document.filename, document);
|
|
}
|
|
return { entry: entry.filename, documents };
|
|
}
|
|
|
|
export function resolveLocalReference(
|
|
workspace: LocalWorkspace,
|
|
reference: string,
|
|
from = workspace.entry,
|
|
): { document: ApiDocument; value: JsonValue; key: string } {
|
|
if (
|
|
/^[a-z][a-z0-9+.-]*:/iu.test(reference) ||
|
|
reference.startsWith("//") ||
|
|
reference.startsWith("/")
|
|
)
|
|
throw new TypeError(
|
|
`Remote or absolute references are disabled: ${reference}`,
|
|
);
|
|
const hash = reference.indexOf("#");
|
|
const filePart = hash < 0 ? reference : reference.slice(0, hash);
|
|
const fragment = hash < 0 ? "" : reference.slice(hash);
|
|
const filename = filePart ? normalizeRelative(from, filePart) : from;
|
|
const document = workspace.documents.get(filename);
|
|
if (!document)
|
|
throw new ReferenceError(
|
|
`Local reference file was not supplied: ${filename}`,
|
|
);
|
|
return {
|
|
document,
|
|
value: pointerValue(document.value, fragment),
|
|
key: `${filename}${fragment}`,
|
|
};
|
|
}
|
|
|
|
function asObject(value: JsonValue | undefined): JsonObject | undefined {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
export interface SampleResult {
|
|
value: JsonValue;
|
|
notices: string[];
|
|
}
|
|
|
|
export function generateSchemaSample(
|
|
workspace: LocalWorkspace,
|
|
schema: JsonValue,
|
|
from = workspace.entry,
|
|
): SampleResult {
|
|
const notices: string[] = [];
|
|
const active = new Set<string>();
|
|
let nodes = 0;
|
|
const build = (
|
|
value: JsonValue,
|
|
filename: string,
|
|
depth: number,
|
|
): JsonValue => {
|
|
nodes += 1;
|
|
if (nodes > 2_000 || depth > 20) {
|
|
notices.push(
|
|
"Sample generation reached its bounded depth or node limit.",
|
|
);
|
|
return null;
|
|
}
|
|
const object = asObject(value);
|
|
if (!object) return value;
|
|
if (typeof object.$ref === "string") {
|
|
const resolved = resolveLocalReference(workspace, object.$ref, filename);
|
|
if (active.has(resolved.key)) {
|
|
notices.push(`Reference cycle stopped at ${resolved.key}.`);
|
|
return { $cycle: resolved.key };
|
|
}
|
|
active.add(resolved.key);
|
|
try {
|
|
return build(resolved.value, resolved.document.filename, depth + 1);
|
|
} finally {
|
|
active.delete(resolved.key);
|
|
}
|
|
}
|
|
for (const key of ["example", "default", "const"] as const)
|
|
if (object[key] !== undefined) return object[key]!;
|
|
if (Array.isArray(object.enum) && object.enum.length)
|
|
return object.enum[0]!;
|
|
if (Array.isArray(object.allOf)) {
|
|
const merged: JsonObject = Object.create(null) as JsonObject;
|
|
for (const child of object.allOf) {
|
|
const sample = build(child, filename, depth + 1);
|
|
if (asObject(sample)) Object.assign(merged, sample);
|
|
else notices.push("A non-object allOf sample could not be merged.");
|
|
}
|
|
return merged;
|
|
}
|
|
for (const key of ["oneOf", "anyOf"] as const)
|
|
if (Array.isArray(object[key]) && object[key]!.length) {
|
|
notices.push(`${key} uses the first alternative.`);
|
|
return build(object[key]![0]!, filename, depth + 1);
|
|
}
|
|
const type =
|
|
typeof object.type === "string"
|
|
? object.type
|
|
: object.properties
|
|
? "object"
|
|
: object.items
|
|
? "array"
|
|
: undefined;
|
|
if (type === "object") {
|
|
const output: JsonObject = Object.create(null) as JsonObject;
|
|
const properties = asObject(object.properties) ?? {};
|
|
for (const [key, child] of Object.entries(properties).slice(0, 100))
|
|
output[key] = build(child, filename, depth + 1);
|
|
return output;
|
|
}
|
|
if (type === "array")
|
|
return object.items ? [build(object.items, filename, depth + 1)] : [];
|
|
if (type === "integer" || type === "number")
|
|
return typeof object.minimum === "number" ? object.minimum : 0;
|
|
if (type === "boolean") return true;
|
|
if (type === "null") return null;
|
|
const format = typeof object.format === "string" ? object.format : "";
|
|
return (
|
|
(
|
|
{
|
|
date: "2026-09-01",
|
|
"date-time": "2026-09-01T12:00:00Z",
|
|
email: "user@example.test",
|
|
uuid: "00000000-0000-4000-8000-000000000000",
|
|
uri: "https://example.test/resource",
|
|
hostname: "example.test",
|
|
ipv4: "192.0.2.1",
|
|
ipv6: "2001:db8::1",
|
|
binary: "<binary>",
|
|
} as Record<string, string>
|
|
)[format] ??
|
|
(typeof object.pattern === "string"
|
|
? `<string matching ${object.pattern}>`
|
|
: "string")
|
|
);
|
|
};
|
|
return { value: build(schema, from, 0), notices: [...new Set(notices)] };
|
|
}
|