mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
refactor(openapi): drop the committed spec artifact; gen Go client from the full doc (#441)
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>
This commit is contained in:
@@ -1,93 +0,0 @@
|
||||
import { createTestApp } from '../server/test/setup'
|
||||
|
||||
// The downloader Go client only talks to these resources. Selecting their paths
|
||||
// from the (fully auto-generated) merged /api/openapi.json keeps the generated
|
||||
// client lean and keeps codegen robust — feeding it all 80+ better-auth
|
||||
// endpoints would bloat the client and risk oapi-codegen choking. This is a
|
||||
// scope allowlist, not a hand-maintained spec: the path/schema *content* is
|
||||
// still generated; we only choose which generated paths to emit a client for.
|
||||
const KEEP_PREFIXES = ['/api/auth/device/', '/api/downloads/', '/api/objects']
|
||||
|
||||
type Doc = {
|
||||
paths: Record<string, unknown>
|
||||
components?: { schemas?: Record<string, unknown> }
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
// oapi-codegen v2 only supports OpenAPI 3.0.x, but the served document (and
|
||||
// better-auth's generated schema) are 3.1 — which expresses nullability as
|
||||
// `type: ["string", "null"]`. Rewrite those unions to the 3.0 form
|
||||
// `type: "string", nullable: true` in place so the generator accepts the spec.
|
||||
// Only the codegen spec is downconverted; the served /api/openapi.json stays 3.1.
|
||||
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 downloader 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(spec: Doc): void {
|
||||
delete (spec as Record<string, unknown>).security
|
||||
if (spec.components) delete (spec.components as Record<string, unknown>).securitySchemes
|
||||
for (const item of Object.values(spec.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect every `#/components/schemas/X` reachable from `node`, transitively.
|
||||
function collectRefs(node: unknown, schemas: Record<string, unknown>, used: Set<string>): void {
|
||||
if (Array.isArray(node)) {
|
||||
for (const v of node) collectRefs(v, schemas, used)
|
||||
return
|
||||
}
|
||||
if (!node || typeof node !== 'object') return
|
||||
for (const [k, v] of Object.entries(node)) {
|
||||
if (k === '$ref' && typeof v === 'string') {
|
||||
const name = v.match(/^#\/components\/schemas\/(.+)$/)?.[1]
|
||||
if (name && !used.has(name)) {
|
||||
used.add(name)
|
||||
collectRefs(schemas[name], schemas, used)
|
||||
}
|
||||
} else {
|
||||
collectRefs(v, schemas, used)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the downloader client OpenAPI spec by reading the real merged document
|
||||
// from a throwaway in-memory app, then scoping it to the downloader's paths.
|
||||
export async function buildClientSpec(): 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
|
||||
|
||||
const paths = Object.fromEntries(
|
||||
Object.entries(doc.paths).filter(([p]) => KEEP_PREFIXES.some((prefix) => p.startsWith(prefix))),
|
||||
)
|
||||
|
||||
const allSchemas = doc.components?.schemas ?? {}
|
||||
const used = new Set<string>()
|
||||
collectRefs(paths, allSchemas, used)
|
||||
const schemas = Object.fromEntries(Object.entries(allSchemas).filter(([name]) => used.has(name)))
|
||||
|
||||
const spec = { ...doc, openapi: '3.0.3', paths, components: { ...doc.components, schemas } }
|
||||
stripSecurity(spec)
|
||||
downconvertTo30(spec)
|
||||
return spec
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { execFile as execFileCallback } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { buildClientSpec } from './build-client-spec'
|
||||
|
||||
const execFile = promisify(execFileCallback)
|
||||
const root = process.cwd()
|
||||
|
||||
async function main() {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'zpan-downloader-openapi-'))
|
||||
try {
|
||||
const generatedDocPath = join(tempDir, 'downloader.json')
|
||||
const generatedClientPath = join(tempDir, 'client.gen.go')
|
||||
const configPath = join(tempDir, 'oapi-codegen.yaml')
|
||||
|
||||
await mkdir(join(root, 'docs/openapi'), { recursive: true })
|
||||
await writeFile(generatedDocPath, `${JSON.stringify(await buildClientSpec(), null, 2)}\n`, 'utf8')
|
||||
await writeFile(
|
||||
configPath,
|
||||
[
|
||||
'package: openapi',
|
||||
'generate:',
|
||||
' models: true',
|
||||
' client: true',
|
||||
`output: ${JSON.stringify(generatedClientPath)}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
await execFile(
|
||||
'go',
|
||||
[
|
||||
'run',
|
||||
'github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.0',
|
||||
'-config',
|
||||
configPath,
|
||||
generatedDocPath,
|
||||
],
|
||||
{ cwd: root },
|
||||
)
|
||||
|
||||
await assertSame(
|
||||
'docs/openapi/downloader.json',
|
||||
generatedDocPath,
|
||||
'Downloader OpenAPI document is stale.',
|
||||
)
|
||||
await assertSame(
|
||||
'cmd/internal/openapi/client.gen.go',
|
||||
generatedClientPath,
|
||||
'Downloader Go OpenAPI client is stale.',
|
||||
)
|
||||
} finally {
|
||||
await rm(tempDir, { force: true, recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSame(path: string, generatedPath: string, message: string) {
|
||||
const actual = await readFile(join(root, path), 'utf8')
|
||||
const generated = await readFile(generatedPath, 'utf8')
|
||||
if (actual !== generated) {
|
||||
console.error(message)
|
||||
console.error(`Run: pnpm openapi:downloader:go`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { buildClientSpec } from './build-client-spec'
|
||||
|
||||
const output = resolve('docs/openapi/downloader.json')
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
await writeFile(output, `${JSON.stringify(await buildClientSpec(), null, 2)}\n`)
|
||||
// The in-memory app keeps no open handles, but exit explicitly so the script
|
||||
// never hangs on a stray timer from a transitively-imported module.
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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)
|
||||
Reference in New Issue
Block a user