336 lines
9.6 KiB
TypeScript
336 lines
9.6 KiB
TypeScript
/**
|
|
* 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<string, Record<string, OpenApiOperation>>;
|
|
}
|
|
|
|
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<string, string>();
|
|
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<OperationOutput<'${id}'>> {
|
|
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<string, OperationDefinition>;
|
|
|
|
export type OperationId = keyof typeof operations;
|
|
|
|
export type Schemas = components['schemas'];
|
|
|
|
type Clean<T> = [T] extends [never] ? Record<never, never> : T;
|
|
|
|
type OperationPathParams<K extends OperationId> = OperationTypes[K]['parameters'] extends {
|
|
path: infer P;
|
|
}
|
|
? Clean<P>
|
|
: Record<never, never>;
|
|
|
|
type OperationQuery<K extends OperationId> = Clean<
|
|
NonNullable<OperationTypes[K]['parameters']['query']>
|
|
>;
|
|
|
|
type OperationBody<K extends OperationId> = OperationTypes[K] extends { requestBody?: infer R }
|
|
? NonNullable<R> extends { content: { 'application/json': infer B } }
|
|
? Clean<B>
|
|
: Record<never, never>
|
|
: Record<never, never>;
|
|
|
|
export type OperationInput<K extends OperationId> = OperationPathParams<K> &
|
|
OperationQuery<K> &
|
|
OperationBody<K> &
|
|
Record<string, unknown>;
|
|
|
|
export type OperationOutput<K extends OperationId> = OperationTypes[K]['responses'] extends {
|
|
200: { content: { 'application/json': infer R } };
|
|
}
|
|
? R
|
|
: unknown;
|
|
|
|
export abstract class GeneratedUmamiClient {
|
|
protected abstract execute<K extends OperationId>(
|
|
operationId: K,
|
|
input: Record<string, unknown> | undefined,
|
|
options?: RequestOptions,
|
|
): Promise<OperationOutput<K>>;
|
|
|
|
${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<string, string> = {
|
|
'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)}.`,
|
|
);
|
|
}
|