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:
Jasper Van
2026-06-15 23:17:33 -04:00
committed by GitHub
parent 91d10f9b97
commit c1542e6a9c
10 changed files with 19172 additions and 7656 deletions
-93
View File
@@ -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
}
-73
View File
@@ -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)
})
-10
View File
@@ -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)
+130
View File
@@ -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)