Release API Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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"] 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)] };
|
||||
}
|
||||
Reference in New Issue
Block a user