/** * Generates `packages/api-client/src/generated/*` from `public/openapi.json`. * * pnpm generate:api:client write generated files * pnpm generate:api:client --check fail if generated files are stale * * The OpenAPI document is the single source of truth: operation IDs become client method names, * parameter/body/response schemas become input/output types. Never edit generated files by hand. */ import { execFile } from 'node:child_process'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import openapiTS, { astToString } from 'openapi-typescript'; const execFileAsync = promisify(execFile); const projectRoot = process.cwd(); const specFile = path.join(projectRoot, 'public', 'openapi.json'); const outputDir = path.join(projectRoot, 'packages', 'api-client', 'src', 'generated'); const check = process.argv.includes('--check'); const HEADER = `// GENERATED FILE. DO NOT EDIT. // Source: public/openapi.json — regenerate with \`pnpm generate:api\`. `; const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const; type HttpMethod = (typeof HTTP_METHODS)[number]; const INCLUDED_AUDIENCES = new Set(['public', 'collect']); const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; interface OpenApiParameter { name: string; in: 'path' | 'query' | 'header' | 'cookie'; required?: boolean; } interface OpenApiOperation { operationId?: string; summary?: string; description?: string; deprecated?: boolean; tags?: string[]; parameters?: OpenApiParameter[]; requestBody?: { required?: boolean }; 'x-umami-audience'?: string; } interface OpenApiDocument { info: { version: string }; paths: Record>; } interface OperationDefinition { operationId: string; method: HttpMethod; path: string; summary: string; description?: string; deprecated?: boolean; tags: string[]; pathParams: string[]; queryParams: string[]; requiredQueryParams: string[]; hasBody: boolean; bodyRequired: boolean; } function collectOperations(document: OpenApiDocument): OperationDefinition[] { const operations: OperationDefinition[] = []; const seen = new Map(); const errors: string[] = []; for (const [route, pathItem] of Object.entries(document.paths)) { for (const method of HTTP_METHODS) { const operation = pathItem[method]; if (!operation) { continue; } const key = `${method.toUpperCase()} ${route}`; const audience = operation['x-umami-audience'] ?? 'public'; if (!INCLUDED_AUDIENCES.has(audience)) { continue; } const operationId = operation.operationId; if (!operationId) { errors.push(`${key} has no operationId.`); continue; } if (!IDENTIFIER.test(operationId)) { errors.push(`${key} has an operationId that is not a valid identifier: ${operationId}`); continue; } const previous = seen.get(operationId); if (previous) { errors.push(`Duplicate operationId ${operationId}: ${previous} and ${key}`); continue; } seen.set(operationId, key); const parameters = operation.parameters ?? []; const pathParams = [...route.matchAll(/\{([^}]+)}/g)].map(match => match[1]); const queryParams = parameters .filter(parameter => parameter.in === 'query') .map(parameter => parameter.name); const requiredQueryParams = parameters .filter(parameter => parameter.in === 'query' && parameter.required) .map(parameter => parameter.name); operations.push({ operationId, method, path: route, summary: operation.summary ?? operationId, description: operation.description, deprecated: operation.deprecated, tags: operation.tags ?? [], pathParams, queryParams, requiredQueryParams, hasBody: !!operation.requestBody, bodyRequired: operation.requestBody?.required !== false && !!operation.requestBody, }); } } if (errors.length) { throw new Error(`Cannot generate API client:\n${errors.map(error => `- ${error}`).join('\n')}`); } return operations.sort((left, right) => left.operationId.localeCompare(right.operationId)); } function jsDoc(lines: (string | undefined)[]) { const content = lines.filter((line): line is string => !!line).flatMap(line => line.split('\n')); if (!content.length) { return ''; } return ` /**\n${content.map(line => ` * ${line.replace(/\*\//g, '* /')}`).join('\n')}\n */\n`; } function renderOperations(operations: OperationDefinition[], version: string) { const table = operations .map(operation => { const fields = [ `operationId: '${operation.operationId}'`, `method: '${operation.method}'`, `path: '${operation.path}'`, `pathParams: [${operation.pathParams.map(name => `'${name}'`).join(', ')}]`, `queryParams: [${operation.queryParams.map(name => `'${name}'`).join(', ')}]`, `hasBody: ${operation.hasBody}`, ]; return ` ${operation.operationId}: { ${fields.join(', ')} },`; }) .join('\n'); const methods = operations .map(operation => { const inputOptional = !operation.pathParams.length && !operation.requiredQueryParams.length && !operation.bodyRequired; const doc = jsDoc([ operation.summary, operation.deprecated ? '@deprecated See the operation description for migration details.' : undefined, operation.description ? '' : undefined, operation.description, '', `\`${operation.method.toUpperCase()} ${operation.path}\``, ]); const id = operation.operationId; return `${doc} ${id}(input${inputOptional ? '?' : ''}: OperationInput<'${id}'>, options?: RequestOptions): Promise> { return this.execute('${id}', input, options); }`; }) .join('\n\n'); return `${HEADER} import type { RequestOptions } from '../types'; import type { components, operations as OperationTypes } from './types'; export const API_VERSION = '${version}'; export type HttpMethod = ${HTTP_METHODS.map(method => `'${method}'`).join(' | ')}; export interface OperationDefinition { readonly operationId: string; readonly method: HttpMethod; readonly path: string; readonly pathParams: readonly string[]; readonly queryParams: readonly string[]; readonly hasBody: boolean; } export const operations = { ${table} } as const satisfies Record; export type OperationId = keyof typeof operations; export type Schemas = components['schemas']; type Clean = [T] extends [never] ? Record : T; type OperationPathParams = OperationTypes[K]['parameters'] extends { path: infer P; } ? Clean

: Record; type OperationQuery = Clean< NonNullable >; type OperationBody = OperationTypes[K] extends { requestBody?: infer R } ? NonNullable extends { content: { 'application/json': infer B } } ? Clean : Record : Record; export type OperationInput = OperationPathParams & OperationQuery & OperationBody & Record; export type OperationOutput = OperationTypes[K]['responses'] extends { 200: { content: { 'application/json': infer R } }; } ? R : unknown; export abstract class GeneratedUmamiClient { protected abstract execute( operationId: K, input: Record | undefined, options?: RequestOptions, ): Promise>; ${methods} } `; } async function format(files: string[]) { const biome = path.join( projectRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'biome.cmd' : 'biome', ); await execFileAsync(biome, ['format', '--write', ...files], { cwd: projectRoot, shell: process.platform === 'win32', }); } async function readExisting(file: string) { try { return await readFile(file, 'utf8'); } catch { return ''; } } const document = JSON.parse(await readFile(specFile, 'utf8')) as OpenApiDocument; const operations = collectOperations(document); const ast = await openapiTS(document as never, { alphabetize: true, exportType: false, }); const files: Record = { 'types.ts': `${HEADER}\n${astToString(ast)}`, 'operations.ts': renderOperations(operations, document.info.version), }; const tmpDir = path.join(outputDir, check ? '.check' : ''); await mkdir(tmpDir, { recursive: true }); const written: string[] = []; for (const [name, content] of Object.entries(files)) { const file = path.join(tmpDir, name); await writeFile(file, content, 'utf8'); written.push(file); } await format(written); if (check) { let stale = false; for (const name of Object.keys(files)) { const [expected, actual] = await Promise.all([ readFile(path.join(tmpDir, name), 'utf8'), readExisting(path.join(outputDir, name)), ]); if (expected !== actual) { console.error( `packages/api-client/src/generated/${name} is stale. Run \`pnpm generate:api\`.`, ); stale = true; } } const { rm } = await import('node:fs/promises'); await rm(tmpDir, { recursive: true, force: true }); if (stale) { process.exit(1); } console.log(`API client is up to date (${operations.length} operations).`); } else { console.log( `Generated ${operations.length} operations into ${path.relative(projectRoot, outputDir)}.`, ); }