mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-06 18:21:12 +00:00
scripts/gen-api.mjs fetches https://storyteller-docs.netlify.app/api.json (the source of truth for API shape), trims it to src/upstream/allowlist.json plus transitively referenced schemas (19 paths / 103 schemas instead of 253 / 859), and emits test/fixtures/api.json and src/upstream/schema.d.ts via openapi-typescript. Both outputs are committed; `pnpm gen:api` regenerates them deliberately. Two discrepancies in the published document are corrected during generation and recorded in docs/backend-handoff.md: - path parameters are declared as one object-typed parameter (flattened when its properties match the path template); - POST /v1/login and /v1/accounts/google_sso declare their JSON body a second time as a path parameter (dropped as a duplicate of requestBody). The generator refuses to write anything if it meets a shape it does not recognise. Also: .gitattributes normalising to LF, eslint/prettier ignores for the generated files, Node-script lint block, nested .wrangler ignores. pnpm check: typecheck, eslint, prettier, 37 tests passing. Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
219 lines
8.3 KiB
JavaScript
219 lines
8.3 KiB
JavaScript
// Regenerates the upstream API snapshot and its TypeScript types from the published OpenAPI
|
|
// document — the source of truth for API shape (mcp/CLAUDE.md → "Source of truth for API shape").
|
|
//
|
|
// pnpm gen:api fetch the live spec
|
|
// pnpm gen:api ./api.json use a local copy (offline / reproducing a diff)
|
|
//
|
|
// Outputs (both committed; regenerate deliberately, review the diff):
|
|
// test/fixtures/api.json the spec trimmed to src/upstream/allowlist.json + referenced schemas
|
|
// src/upstream/schema.d.ts openapi-typescript output for that trimmed spec
|
|
//
|
|
// The trim keeps diffs reviewable and documents exactly the surface this service depends on.
|
|
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import openapiTS, { astToString } from "openapi-typescript";
|
|
|
|
const SPEC_URL = "https://storyteller-docs.netlify.app/api.json";
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const ALLOWLIST_PATH = path.join(ROOT, "src/upstream/allowlist.json");
|
|
const SNAPSHOT_PATH = path.join(ROOT, "test/fixtures/api.json");
|
|
const TYPES_PATH = path.join(ROOT, "src/upstream/schema.d.ts");
|
|
|
|
async function main() {
|
|
const source = process.argv[2] ?? SPEC_URL;
|
|
const spec = await loadSpec(source);
|
|
const allowlist = JSON.parse(await readFile(ALLOWLIST_PATH, "utf8")).routes;
|
|
|
|
const trimmed = trimToAllowlist(spec, allowlist);
|
|
const parameterFixes = flattenObjectParameters(trimmed);
|
|
if (parameterFixes.unexpected.length > 0) {
|
|
throw new Error(
|
|
`unhandled object-typed path parameters: ${parameterFixes.unexpected.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
await mkdir(path.dirname(SNAPSHOT_PATH), { recursive: true });
|
|
await writeFile(SNAPSHOT_PATH, `${JSON.stringify(trimmed, null, 2)}\n`);
|
|
|
|
const ast = await openapiTS(trimmed, { alphabetize: true });
|
|
const header = [
|
|
"// GENERATED by scripts/gen-api.mjs — do not edit.",
|
|
`// Source: ${SPEC_URL} (trimmed to src/upstream/allowlist.json).`,
|
|
"// Regenerate with `pnpm gen:api`; review the diff against the previous snapshot.",
|
|
"",
|
|
].join("\n");
|
|
await writeFile(TYPES_PATH, header + astToString(ast));
|
|
|
|
const pathCount = Object.keys(trimmed.paths).length;
|
|
const schemaCount = Object.keys(trimmed.components.schemas).length;
|
|
console.log(
|
|
`snapshot: ${pathCount} paths, ${schemaCount} schemas → ${path.relative(ROOT, SNAPSHOT_PATH)}`,
|
|
);
|
|
console.log(`types: → ${path.relative(ROOT, TYPES_PATH)}`);
|
|
if (parameterFixes.flattened.length > 0) {
|
|
console.log(
|
|
`flattened object-typed path parameters on: ${parameterFixes.flattened.join(", ")}`,
|
|
);
|
|
}
|
|
if (parameterFixes.movedToBody.length > 0) {
|
|
console.log(
|
|
`moved body-as-path parameter to requestBody on: ${parameterFixes.movedToBody.join(", ")}`,
|
|
);
|
|
}
|
|
if (parameterFixes.droppedDuplicateBody.length > 0) {
|
|
console.log(
|
|
`dropped path parameter duplicating requestBody on: ${parameterFixes.droppedDuplicateBody.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function loadSpec(source) {
|
|
if (/^https?:\/\//.test(source)) {
|
|
const response = await fetch(source);
|
|
if (!response.ok) throw new Error(`fetching ${source}: HTTP ${response.status}`);
|
|
return response.json();
|
|
}
|
|
return JSON.parse(await readFile(path.resolve(source), "utf8"));
|
|
}
|
|
|
|
/** Keep only allowlisted path+method operations and the component schemas they reference. */
|
|
function trimToAllowlist(spec, allowlist) {
|
|
const paths = {};
|
|
for (const route of allowlist) {
|
|
const pathItem = spec.paths[route.path];
|
|
const operation = pathItem?.[route.method];
|
|
if (!operation) {
|
|
throw new Error(
|
|
`allowlist route ${route.method.toUpperCase()} ${route.path} is not in the spec`,
|
|
);
|
|
}
|
|
paths[route.path] = { ...paths[route.path], [route.method]: operation };
|
|
}
|
|
|
|
const referenced = new Set();
|
|
collectRefs(paths, referenced);
|
|
// Transitively include schemas referenced by referenced schemas.
|
|
let frontier = [...referenced];
|
|
while (frontier.length > 0) {
|
|
const next = new Set();
|
|
for (const name of frontier) {
|
|
const schema = spec.components.schemas[name];
|
|
if (!schema) throw new Error(`spec references missing schema ${name}`);
|
|
collectRefs(schema, next);
|
|
}
|
|
frontier = [...next].filter((name) => !referenced.has(name));
|
|
for (const name of frontier) referenced.add(name);
|
|
}
|
|
|
|
const schemas = {};
|
|
for (const name of [...referenced].sort()) schemas[name] = spec.components.schemas[name];
|
|
|
|
return {
|
|
openapi: spec.openapi,
|
|
info: spec.info,
|
|
paths: sortKeys(paths),
|
|
components: { schemas },
|
|
};
|
|
}
|
|
|
|
function collectRefs(node, into) {
|
|
if (Array.isArray(node)) {
|
|
for (const item of node) collectRefs(item, into);
|
|
} else if (node && typeof node === "object") {
|
|
for (const [key, value] of Object.entries(node)) {
|
|
if (key === "$ref" && typeof value === "string") {
|
|
const name = value.replace("#/components/schemas/", "");
|
|
into.add(name);
|
|
} else {
|
|
collectRefs(value, into);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The backend's generator declares each operation's path parameters as ONE parameter whose
|
|
* schema is an object — e.g. `{ name: "path", in: "path", schema: { $ref:
|
|
* GetInferenceJobStatusPathInfo } }`. Typed literally, that would demand `params.path.path.token`
|
|
* while the URL template substitutes `params.path.token`. Two cases, decided per parameter:
|
|
*
|
|
* - the object's properties are exactly the path's `{template}` variables → flatten into one
|
|
* parameter per property;
|
|
* - the path has no template variables at all (e.g. `POST /v1/login` declares `LoginRequest`
|
|
* as a path parameter) → it is the JSON request body mislabelled: drop it when the operation
|
|
* already declares the same schema as `requestBody`, otherwise move it there.
|
|
*
|
|
* Anything else is left untouched and reported. Both discrepancies are recorded in
|
|
* docs/backend-handoff.md.
|
|
*/
|
|
function flattenObjectParameters(spec) {
|
|
const flattened = [];
|
|
const movedToBody = [];
|
|
const droppedDuplicateBody = [];
|
|
const unexpected = [];
|
|
for (const [pathKey, pathItem] of Object.entries(spec.paths)) {
|
|
const templateVariables = new Set([...pathKey.matchAll(/\{(\w+)\}/g)].map((m) => m[1]));
|
|
for (const [method, operation] of Object.entries(pathItem)) {
|
|
if (!Array.isArray(operation.parameters)) continue;
|
|
const label = `${method.toUpperCase()} ${pathKey}`;
|
|
const kept = [];
|
|
for (const parameter of operation.parameters) {
|
|
const ref = parameter.schema?.$ref;
|
|
if (!ref || parameter.in !== "path") {
|
|
kept.push(parameter);
|
|
continue;
|
|
}
|
|
const schema = spec.components.schemas[ref.replace("#/components/schemas/", "")];
|
|
const properties = Object.keys(schema?.properties ?? {});
|
|
const matchesTemplate =
|
|
properties.length > 0 && properties.every((name) => templateVariables.has(name));
|
|
|
|
if (matchesTemplate) {
|
|
for (const [name, propertySchema] of Object.entries(schema.properties)) {
|
|
kept.push({
|
|
name,
|
|
in: "path",
|
|
required: true,
|
|
...(propertySchema.description ? { description: propertySchema.description } : {}),
|
|
schema: propertySchema,
|
|
});
|
|
}
|
|
flattened.push(label);
|
|
} else if (templateVariables.size === 0) {
|
|
const bodyRef = operation.requestBody?.content?.["application/json"]?.schema?.$ref;
|
|
if (bodyRef === ref) {
|
|
droppedDuplicateBody.push(label);
|
|
} else if (!operation.requestBody) {
|
|
operation.requestBody = {
|
|
required: true,
|
|
content: { "application/json": { schema: { $ref: ref } } },
|
|
};
|
|
movedToBody.push(label);
|
|
} else {
|
|
kept.push(parameter);
|
|
unexpected.push(`${label} (${parameter.name}: body is ${bodyRef ?? "non-JSON"})`);
|
|
}
|
|
} else {
|
|
kept.push(parameter);
|
|
unexpected.push(`${label} (${parameter.name})`);
|
|
}
|
|
}
|
|
operation.parameters = kept;
|
|
if (kept.length === 0) delete operation.parameters;
|
|
}
|
|
}
|
|
return { flattened, movedToBody, droppedDuplicateBody, unexpected };
|
|
}
|
|
|
|
function sortKeys(object) {
|
|
return Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b)));
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|