Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)) |
<!-- ENDPOINTS:END -->

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*`,
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
75 changes: 69 additions & 6 deletions scripts/generate-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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 `<!-- ENDPOINTS:START -->`
* 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<string> {
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<void> {
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<string[]> {
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<void> {
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)
})
}
11 changes: 10 additions & 1 deletion tests/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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([])
})
})