Files
artcraft/mcp/scripts/gen-api.mjs
T
Tanish Manku adc91b9e57 mcp: generate upstream types and a trimmed spec snapshot from the published OpenAPI document
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
2026-08-24 20:09:03 -07:00

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);
});