mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
c1542e6a9c
No more docs/openapi/downloader.json and no curated subset. The Go client is generated straight from the complete, live /api/openapi.json: a single scripts/openapi-client.ts boots the in-memory app, reads the whole merged document, and feeds it to oapi-codegen via a throwaway temp file — only the generated client.gen.go is committed. - delete docs/openapi/downloader.json, cmd/oapi-codegen.yaml, and the three build/generate/check scripts; replace with one scripts/openapi-client.ts (`pnpm openapi:client` / `--check`). - generate from the full document — every endpoint, no allowlist/prune. The only transforms are whole-document mechanics for oapi-codegen (3.1→3.0 nullable, strip security, declare better-auth's missing path params). - rename the CI step + package scripts openapi:downloader:* → openapi:client*. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
131 lines
5.4 KiB
TypeScript
131 lines
5.4 KiB
TypeScript
// Generates the downloader's Go OpenAPI client (cmd/internal/openapi/client.gen.go)
|
|
// straight from the complete, live /api/openapi.json — no committed intermediate
|
|
// spec, no hand-curated subset. The spec is written to a temp file only so
|
|
// oapi-codegen has something to read, then discarded.
|
|
//
|
|
// pnpm openapi:client regenerate the committed Go client
|
|
// pnpm openapi:client --check fail if the committed client is stale
|
|
import { execFile as execFileCallback } from 'node:child_process'
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, resolve } from 'node:path'
|
|
import { promisify } from 'node:util'
|
|
import { createTestApp } from '../server/test/setup'
|
|
|
|
const execFile = promisify(execFileCallback)
|
|
const CLIENT_PATH = resolve('cmd/internal/openapi/client.gen.go')
|
|
|
|
type Doc = { openapi?: string; components?: { securitySchemes?: unknown }; paths: Record<string, unknown> }
|
|
|
|
// oapi-codegen v2 only supports OpenAPI 3.0.x, but the document is 3.1 — which
|
|
// expresses nullability as `type: ["string", "null"]`. Rewrite those unions to
|
|
// the 3.0 form `type: "string", nullable: true`. Whole-document transform; no
|
|
// endpoints are removed.
|
|
function downconvertTo30(node: unknown): void {
|
|
if (Array.isArray(node)) {
|
|
for (const v of node) downconvertTo30(v)
|
|
return
|
|
}
|
|
if (!node || typeof node !== 'object') return
|
|
const obj = node as Record<string, unknown>
|
|
if (Array.isArray(obj.type) && obj.type.includes('null')) {
|
|
const nonNull = (obj.type as string[]).filter((t) => t !== 'null')
|
|
obj.type = nonNull.length === 1 ? nonNull[0] : nonNull
|
|
obj.nullable = true
|
|
}
|
|
for (const v of Object.values(obj)) downconvertTo30(v)
|
|
}
|
|
|
|
// The client attaches its bearer token manually via a RequestEditorFn, so it
|
|
// needs no security metadata. Strip it: better-auth's bearerAuth scheme otherwise
|
|
// makes oapi-codegen (client-only) emit a `BearerAuthScopes` const whose
|
|
// context-key type is only generated in server mode → undefined symbol.
|
|
function stripSecurity(doc: Doc): void {
|
|
delete (doc as Record<string, unknown>).security
|
|
if (doc.components) delete (doc.components as Record<string, unknown>).securitySchemes
|
|
for (const item of Object.values(doc.paths)) {
|
|
if (!item || typeof item !== 'object') continue
|
|
for (const op of Object.values(item as Record<string, unknown>)) {
|
|
if (op && typeof op === 'object') delete (op as Record<string, unknown>).security
|
|
}
|
|
}
|
|
}
|
|
|
|
const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'])
|
|
|
|
// better-auth omits path-parameter declarations on some operations (e.g.
|
|
// /api/auth/callback/{id}), which oapi-codegen rejects. Declare any `{param}`
|
|
// segment that an operation is missing. Whole-document, mechanical.
|
|
function declareMissingPathParams(doc: Doc): void {
|
|
for (const [path, item] of Object.entries(doc.paths)) {
|
|
const names = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1])
|
|
if (names.length === 0 || !item || typeof item !== 'object') continue
|
|
for (const [method, op] of Object.entries(item as Record<string, unknown>)) {
|
|
if (!HTTP_METHODS.has(method) || !op || typeof op !== 'object') continue
|
|
const operation = op as { parameters?: { name?: string; in?: string }[] }
|
|
const params = (operation.parameters ??= [])
|
|
const declared = new Set(params.filter((p) => p.in === 'path').map((p) => p.name))
|
|
for (const name of names) {
|
|
if (!declared.has(name)) params.push({ name, in: 'path', required: true, schema: { type: 'string' } } as never)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function buildCodegenSpec(): Promise<Doc> {
|
|
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'codegen' })
|
|
const res = await app.request('/api/openapi.json')
|
|
if (res.status !== 200) throw new Error(`/api/openapi.json returned ${res.status}`)
|
|
const doc = (await res.json()) as Doc
|
|
doc.openapi = '3.0.3'
|
|
stripSecurity(doc)
|
|
declareMissingPathParams(doc)
|
|
downconvertTo30(doc)
|
|
return doc
|
|
}
|
|
|
|
async function generateClient(outputPath: string): Promise<void> {
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'zpan-openapi-'))
|
|
try {
|
|
const specPath = join(tempDir, 'openapi.json')
|
|
const configPath = join(tempDir, 'oapi-codegen.yaml')
|
|
await writeFile(specPath, `${JSON.stringify(await buildCodegenSpec(), null, 2)}\n`, 'utf8')
|
|
await writeFile(
|
|
configPath,
|
|
['package: openapi', 'generate:', ' models: true', ' client: true', `output: ${JSON.stringify(outputPath)}`, ''].join(
|
|
'\n',
|
|
),
|
|
'utf8',
|
|
)
|
|
await execFile('go', [
|
|
'run',
|
|
'github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.0',
|
|
'-config',
|
|
configPath,
|
|
specPath,
|
|
])
|
|
} finally {
|
|
await rm(tempDir, { force: true, recursive: true })
|
|
}
|
|
}
|
|
|
|
if (process.argv.includes('--check')) {
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'zpan-openapi-check-'))
|
|
try {
|
|
const candidate = join(tempDir, 'client.gen.go')
|
|
await generateClient(candidate)
|
|
const [committed, regenerated] = await Promise.all([readFile(CLIENT_PATH, 'utf8'), readFile(candidate, 'utf8')])
|
|
if (committed !== regenerated) {
|
|
console.error('Go OpenAPI client is stale. Run: pnpm openapi:client')
|
|
process.exit(1)
|
|
}
|
|
console.log('OpenAPI client is up to date.')
|
|
} finally {
|
|
await rm(tempDir, { force: true, recursive: true })
|
|
}
|
|
} else {
|
|
await generateClient(CLIENT_PATH)
|
|
console.log(`Generated ${CLIENT_PATH}`)
|
|
}
|
|
process.exit(0)
|