Generate explicit OpenAPI contracts for API routes
This commit is contained in:
1 parent
cc5d71d39f
commit
e1a4934038
177 files changed
+50955
-1460
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { loadApiContracts } from '@/openapi/contracts';
|
||||
import { discoverApiOperations, discoverContractFiles } from '@/openapi/discover';
|
||||
import { buildOpenApiDocument } from '@/openapi/document';
|
||||
import { inferApiContracts } from '@/openapi/infer';
|
||||
import type { LoadedApiOperationContract } from '@/openapi/operation';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const generatedFileName = 'contract.generated.ts';
|
||||
const generatedHeader = '// Generated by `pnpm openapi:contracts`. Do not edit directly.';
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const existingGeneratedFiles = (await discoverContractFiles(projectRoot)).filter(
|
||||
file => path.basename(file) === generatedFileName,
|
||||
);
|
||||
|
||||
await Promise.all(existingGeneratedFiles.map(file => rm(file)));
|
||||
|
||||
const [explicitContracts, discovered] = await Promise.all([
|
||||
loadApiContracts(projectRoot),
|
||||
discoverApiOperations(projectRoot),
|
||||
]);
|
||||
const inferredContracts = inferApiContracts(discovered, explicitContracts);
|
||||
const { document } = await buildOpenApiDocument('all', projectRoot);
|
||||
const documentPaths = document.paths ?? {};
|
||||
const byRoute = new Map<string, LoadedApiOperationContract[]>();
|
||||
const generatedFiles: string[] = [];
|
||||
|
||||
inferredContracts.forEach(contract => {
|
||||
byRoute.set(contract.source, [...(byRoute.get(contract.source) ?? []), contract]);
|
||||
});
|
||||
|
||||
function serialize(value: unknown) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
for (const [routeSource, contracts] of byRoute) {
|
||||
const routeFile = path.join(projectRoot, ...routeSource.split('/'));
|
||||
const outputFile = path.join(path.dirname(routeFile), generatedFileName);
|
||||
const operationDefinitions = contracts.map((contract, index) => {
|
||||
const generatedOperation = documentPaths[contract.path]?.[contract.method];
|
||||
|
||||
if (!generatedOperation || typeof generatedOperation !== 'object') {
|
||||
throw new Error(
|
||||
`Generated document is missing ${contract.method.toUpperCase()} ${contract.path}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const operation = structuredClone(generatedOperation) as Record<string, unknown>;
|
||||
delete operation.security;
|
||||
delete operation['x-umami-audience'];
|
||||
delete operation['x-umami-contract'];
|
||||
delete operation['x-umami-source'];
|
||||
|
||||
if (
|
||||
typeof operation.description === 'string' &&
|
||||
operation.description.startsWith('Generated from')
|
||||
) {
|
||||
operation.description = `${operation.summary}. The request and response models are derived from the validated route contract.`;
|
||||
}
|
||||
|
||||
return `const operation${index + 1} = defineOperation({\n method: ${serialize(contract.method)},\n path: ${serialize(contract.path)},\n audience: ${serialize(contract.audience)},\n auth: ${serialize(contract.auth)},\n operation: ${serialize(operation)} as ZodOpenApiOperationObject,\n});`;
|
||||
});
|
||||
const exports = contracts.map((_, index) => `operation${index + 1}`).join(', ');
|
||||
const content = `${generatedHeader}\n\nimport type { ZodOpenApiOperationObject } from 'zod-openapi';\nimport { defineOperation } from '@/openapi/operation';\n\n${operationDefinitions.join('\n\n')}\n\nexport const operations = [${exports}] as const;\n`;
|
||||
|
||||
await writeFile(outputFile, content, 'utf8');
|
||||
generatedFiles.push(outputFile);
|
||||
}
|
||||
|
||||
const formatter = path.join(projectRoot, 'node_modules', '@biomejs', 'biome', 'bin', 'biome');
|
||||
for (let index = 0; index < generatedFiles.length; index += 20) {
|
||||
await execFileAsync(process.execPath, [
|
||||
formatter,
|
||||
'format',
|
||||
'--write',
|
||||
...generatedFiles.slice(index, index + 20),
|
||||
]);
|
||||
}
|
||||
|
||||
const generatedCount = [...byRoute.values()].reduce(
|
||||
(total, contracts) => total + contracts.length,
|
||||
0,
|
||||
);
|
||||
console.log(`Generated ${generatedCount} explicit contracts in ${byRoute.size} route modules.`);
|
||||
Reference in new issue
Block a user