Files
lobehub__lobehub/packages/openapi/scripts/generate-openapi.ts
Rdmclin2 e85ae51880 ✨ feat(openapi): add eval read routes for runs, datasets and topics (#19423)
* 🐛 fix(openapi): exempt heterogeneous relay routes from spec parity

`POST /api/v1/anthropic/v1/messages` and `POST /api/v1/openai/v1/responses`
are registered without `describeRoute`, so the generator's parity check has
been failing since #18558 and `openapi.yml` could no longer be regenerated.

Both relays speak the upstream Anthropic/OpenAI wire formats and are gated by
`requireHeteroModelInvocation` (operation JWT), so no API key holder can reach
them and their schemas are not LobeHub's to publish. Exempt them the same way
the documentation-serving routes already are, instead of describing endpoints
that are not part of the public API surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmyR6qUiKJX9VteSJR1hiu

* ✨ feat(openapi): add eval read routes for runs, datasets and topics

`/api/v1/eval` could create a run and read that one run back, but offered no
way to discover what already exists, so API consumers had to know a run id out
of band. Add the four missing read endpoints:

- `GET /eval/runs` (paginated, filterable by dataset and status)
- `GET /eval/datasets`
- `GET /eval/datasets/:id`
- `GET /eval/runs/:id/topics`

All four reuse the domain's existing posture: `AGENT_READ` RBAC, mandatory
pagination, and public-field allowlists so internal columns never reach the
response. Cross-tenant reads stay 404 rather than 403 so the endpoints do not
confirm the existence of other tenants' rows.

The models gain only the read primitives the routes need (count + paginated
list); no schema or migration changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmyR6qUiKJX9VteSJR1hiu

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 12:28:50 +08:00

103 lines
4.1 KiB
TypeScript

/**
* Generate the OpenAPI 3.1 spec (openapi.yml) from the live Hono app.
*
* The document itself is assembled by `src/spec.ts` (shared with the runtime
* `GET /api/v1/openapi.json` endpoint). This script adds what the emitter
* needs on top: safe env bootstrapping, a parity check that fails when a
* registered route is missing from the spec (instead of silently vanishing),
* and a `--check` mode for CI.
*
* Usage:
* bun scripts/generate-openapi.ts # regenerate openapi.yml
* bun scripts/generate-openapi.ts --check # verify openapi.yml is up to date (CI)
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
// Safe defaults so importing the app never touches real infrastructure.
// NODE_ENV=test makes the database adaptor return a mock instance, and the
// dummy secrets satisfy import-time env validation in downstream packages.
// (NODE_ENV is typed read-only, hence Object.assign)
if (!process.env.NODE_ENV) Object.assign(process.env, { NODE_ENV: 'test' });
process.env.KEY_VAULTS_SECRET ??= 'openapi-spec-generation';
process.env.CLOUD_DATABASE_URL ??= 'postgresql://mock:mock@localhost:5432/mock';
process.env.QSTASH_TOKEN ??= 'mock-qstash-token';
const PKG_ROOT = path.join(import.meta.dirname, '..');
const SPEC_PATH = path.join(PKG_ROOT, 'openapi.yml');
const HTTP_METHODS = new Set(['DELETE', 'GET', 'PATCH', 'POST', 'PUT']);
// Real endpoints deliberately kept out of the API surface described by the
// spec: the documentation routes themselves, plus the heterogeneous-agent
// relays. The relays speak the Anthropic/OpenAI wire formats and are gated by
// `requireHeteroModelInvocation` (operation JWT), so no API key holder can
// call them and their schemas belong to the upstream vendors, not LobeHub.
const SPEC_EXEMPT = new Set([
'GET /api/v1/docs',
'GET /api/v1/openapi.json',
'POST /api/v1/anthropic/v1/messages',
'POST /api/v1/openai/v1/responses',
]);
// Import after the env defaults above are in place.
const { honoApp } = await import('../src/app');
const { buildSpecDocument } = await import('../src/spec');
const YAML = await import('yaml');
const spec = await buildSpecDocument(honoApp);
const paths = (spec.paths ?? {}) as Record<string, Record<string, unknown>>;
// ---------- Parity check: every registered route must be in the spec ----------
const normalize = (routePath: string) => routePath.replaceAll(/:([^/]+)/g, '{$1}');
const registered = new Set(
honoApp.routes
.filter((r) => HTTP_METHODS.has(r.method))
.map((r) => `${r.method} ${normalize(r.path)}`)
.filter((endpoint) => !SPEC_EXEMPT.has(endpoint)),
);
const documented = new Set(
Object.entries(paths).flatMap(([specPath, item]) =>
Object.keys(item)
.filter((method) => HTTP_METHODS.has(method.toUpperCase()))
.map((method) => `${method.toUpperCase()} ${specPath}`),
),
);
const missing = [...registered].filter((endpoint) => !documented.has(endpoint));
const extra = [...documented].filter((endpoint) => !registered.has(endpoint));
if (missing.length > 0 || extra.length > 0) {
if (missing.length > 0) {
console.error(
`✗ ${missing.length} route(s) missing from the spec (no hono-openapi validator/describeRoute attached):`,
);
for (const endpoint of missing) console.error(` - ${endpoint}`);
}
for (const endpoint of extra) console.error(` ? documented but not registered: ${endpoint}`);
process.exit(1);
}
// ---------- Emit ----------
const output = YAML.stringify(spec, { aliasDuplicateObjects: false });
if (process.argv.includes('--check')) {
const committed = readFileSync(SPEC_PATH, 'utf8');
if (committed !== output) {
console.error(
'✗ openapi.yml is out of date with the routes. Run `bun generate:openapi` in packages/openapi and commit the result.',
);
process.exit(1);
}
console.log(`✓ openapi.yml is up to date (${documented.size} operations)`);
} else {
writeFileSync(SPEC_PATH, output);
console.log(
`✓ openapi.yml written: ${documented.size} operations, ${Object.keys(paths).length} paths`,
);
}
// The auth middleware registers a cache-cleanup interval at import time,
// which would otherwise keep the process alive.
process.exit(0);