diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2929c8a9..604d0995 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,10 @@ jobs: cache: npm - run: npm ci || npm install - run: node node_modules/prisma/build/index.js generate + # Fails when openapi.json or the README's generated endpoint table has + # drifted from openapi.yaml. Run `npm run openapi:gen` to fix. Runs after + # `prisma generate` because the README renderer loads the venue registry. + - run: npm run openapi:check - name: Generate TypeScript client from openapi.yaml run: npm run client:gen - name: Fail if the committed client is out of date diff --git a/README.md b/README.md index 24302db6..7c86296c 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,11 @@ npx @modelcontextprotocol/inspector npm run mcp:server | GET/POST | `/graphql` | GraphQL query/mutation endpoint (GraphiQL IDE at `/graphiql`, docs: [`docs/graphql.md`](docs/graphql.md)) | +The REST table above is generated from [`openapi.yaml`](openapi.yaml) by +`npm run openapi:gen` (or `npm run readme:gen`) — edit the spec, not the table. +`npm run openapi:check` (run in CI) fails when the committed README or +`openapi.json` differs from a fresh generation. + Every non-internal route above has a matching entry in [`openapi.yaml`](openapi.yaml); `tests/openapi.test.ts` boots the server and fails if a registered route is missing from the spec. Operator-only and non-HTTP routes (`/admin/keys*`, diff --git a/package.json b/package.json index b4899fe7..02f4b793 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "smoke": "node scripts/deployed-smoke.mjs", "test:smoke": "node --test scripts/test-deployed-smoke.mjs", "openapi:gen": "tsx scripts/generate-openapi.ts", + "openapi:check": "tsx scripts/generate-openapi.ts --check", + "graphql:schema:gen": "tsx scripts/generate-graphql-schema.ts", "readme:gen": "tsx scripts/generate-readme.ts", "spec:validate-live": "tsx scripts/validate-live-spec.ts", "client:gen": "openapi-typescript openapi.yaml -o clients/typescript/src/schema.d.ts", diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 96b450f3..577221ac 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -4,6 +4,7 @@ import YAML from 'js-yaml' const sourcePath = fileURLToPath(new URL('../openapi.yaml', import.meta.url)) const destinationPath = fileURLToPath(new URL('../openapi.json', import.meta.url)) +const readmePath = fileURLToPath(new URL('../README.md', import.meta.url)) /** * Renders the committed `openapi.json` from `openapi.yaml`. @@ -23,14 +24,76 @@ export function renderOpenApiJson(yamlSource?: string): string { return JSON.stringify(document, null, 2) + '\n' } -/** Regenerates `openapi.json` from `openapi.yaml`, in place. */ -export function generateOpenApi(): void { +/** + * Renders README.md with its generated sections (the REST endpoint table from + * `openapi.yaml`, plus the venue table) refreshed. + * + * The rendering itself lives in `scripts/generate-readme.ts` (`npm run + * readme:gen`), the single owner of the README's `` + * block; this wrapper only lets `openapi:gen` / `openapi:check` cover the + * README too, so editing the spec and running one command keeps every + * spec-derived artifact in step. Imported lazily because the venue table pulls + * in the ingest registry (and with it the Prisma client), which + * `renderOpenApiJson` callers do not need. + */ +async function renderReadmeFromSpec(readmeSource: string): Promise { + const { renderReadme } = await import('./generate-readme') + return renderReadme(readmeSource) +} + +/** Normalises CRLF so a Windows checkout does not read as drift. */ +function normaliseNewlines(value: string): string { + return value.replace(/\r\n/g, '\n') +} + +/** Regenerates `openapi.json` and the README's generated tables, in place. */ +export async function generateOpenApi(): Promise { writeFileSync(destinationPath, renderOpenApiJson()) + const readme = readFileSync(readmePath, 'utf8') + writeFileSync(readmePath, await renderReadmeFromSpec(readme)) +} + +/** + * Lists committed artifacts that differ from a fresh generation. + * + * Empty means the tree is up to date. Used by `npm run openapi:check` in CI so + * a hand-edited spec, JSON or README table fails the build instead of drifting. + */ +export async function checkGeneratedArtifacts(): Promise { + const problems: string[] = [] + + if (renderOpenApiJson() !== normaliseNewlines(readFileSync(destinationPath, 'utf8'))) { + problems.push('openapi.json does not match openapi.yaml') + } + + const readme = normaliseNewlines(readFileSync(readmePath, 'utf8')) + if ((await renderReadmeFromSpec(readme)) !== readme) { + problems.push('README generated tables do not match openapi.yaml / the venue registry') + } + + return problems +} + +// Run only when invoked directly (`npm run openapi:gen` / `openapi:check`), +// not when imported by the test. +async function main(): Promise { + if (process.argv.includes('--check')) { + const problems = await checkGeneratedArtifacts() + if (problems.length > 0) { + console.error(`Generated API artifacts are stale:\n - ${problems.join('\n - ')}`) + console.error('Run `npm run openapi:gen` and commit the result.') + process.exit(1) + } + console.log('openapi.json and the README endpoint table are up to date') + } else { + await generateOpenApi() + console.log('Generated openapi.json and the README endpoint table') + } } -// Run only when invoked directly (`npm run openapi:gen`), not when imported by -// the test. if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { - generateOpenApi() - console.log('Generated openapi.json') + main().catch(error => { + console.error(error) + process.exit(1) + }) } diff --git a/tests/openapi.test.ts b/tests/openapi.test.ts index aad0d2b3..820834ce 100644 --- a/tests/openapi.test.ts +++ b/tests/openapi.test.ts @@ -10,7 +10,7 @@ import { isUndocumentableOperation, toOpenApiPath, } from '../src/openapi/coverage' -import { renderOpenApiJson } from '../scripts/generate-openapi' +import { checkGeneratedArtifacts, renderOpenApiJson } from '../scripts/generate-openapi' const specPath = fileURLToPath(new URL('../openapi.yaml', import.meta.url)) const jsonPath = fileURLToPath(new URL('../openapi.json', import.meta.url)) @@ -114,3 +114,12 @@ describe('generated openapi.json', () => { expect(renderOpenApiJson()).toBe(renderOpenApiJson()) }) }) + +describe('openapi:check', () => { + // The README endpoint table itself is rendered and asserted row-by-row in + // tests/readme.test.ts; this guards the single command CI runs, so editing + // openapi.yaml without regenerating fails here as well as in the pipeline. + it('reports no stale generated artifacts', async () => { + expect(await checkGeneratedArtifacts()).toEqual([]) + }) +})