132 lines
4.1 KiB
TypeScript
132 lines
4.1 KiB
TypeScript
import { readdir, readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import ts from 'typescript';
|
|
import { API_HTTP_METHODS, type ApiHttpMethod } from '@/openapi/operation';
|
|
|
|
const ROUTE_FILE_PATTERN = /^route\.(?:js|jsx|ts|tsx)$/;
|
|
const METHOD_NAMES = new Set(API_HTTP_METHODS.map(method => method.toUpperCase()));
|
|
|
|
export interface DiscoveredApiOperation {
|
|
method: ApiHttpMethod;
|
|
path: `/${string}`;
|
|
source: string;
|
|
}
|
|
|
|
async function walk(directory: string, predicate: (name: string) => boolean): Promise<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const files = await Promise.all(
|
|
entries.map(async entry => {
|
|
const entryPath = path.join(directory, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
return walk(entryPath, predicate);
|
|
}
|
|
|
|
return predicate(entry.name) ? [entryPath] : [];
|
|
}),
|
|
);
|
|
|
|
return files.flat();
|
|
}
|
|
|
|
function hasExportModifier(node: ts.Node) {
|
|
return ts.canHaveModifiers(node)
|
|
? ts.getModifiers(node)?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
|
: false;
|
|
}
|
|
|
|
function getExportedMethods(source: ts.SourceFile): ApiHttpMethod[] {
|
|
const methods = new Set<ApiHttpMethod>();
|
|
|
|
const addMethod = (name: string) => {
|
|
if (METHOD_NAMES.has(name)) {
|
|
methods.add(name.toLowerCase() as ApiHttpMethod);
|
|
}
|
|
};
|
|
|
|
source.statements.forEach(statement => {
|
|
if (ts.isFunctionDeclaration(statement) && statement.name && hasExportModifier(statement)) {
|
|
addMethod(statement.name.text);
|
|
return;
|
|
}
|
|
|
|
if (ts.isVariableStatement(statement) && hasExportModifier(statement)) {
|
|
statement.declarationList.declarations.forEach(declaration => {
|
|
if (ts.isIdentifier(declaration.name)) {
|
|
addMethod(declaration.name.text);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (ts.isExportDeclaration(statement) && statement.exportClause) {
|
|
if (ts.isNamedExports(statement.exportClause)) {
|
|
statement.exportClause.elements.forEach(element => {
|
|
addMethod(element.name.text);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return API_HTTP_METHODS.filter(method => methods.has(method));
|
|
}
|
|
|
|
function toOpenApiSegment(segment: string) {
|
|
if ((segment.startsWith('(') && segment.endsWith(')')) || segment.startsWith('@')) {
|
|
return null;
|
|
}
|
|
|
|
const optionalCatchAll = segment.match(/^\[\[\.\.\.(.+)]]$/);
|
|
const catchAll = segment.match(/^\[\.\.\.(.+)]$/);
|
|
const dynamic = segment.match(/^\[(.+)]$/);
|
|
const name = optionalCatchAll?.[1] ?? catchAll?.[1] ?? dynamic?.[1];
|
|
|
|
return name ? `{${name}}` : segment;
|
|
}
|
|
|
|
function getRoutePath(apiRoot: string, routeFile: string): `/${string}` {
|
|
const relativeDirectory = path.relative(apiRoot, path.dirname(routeFile));
|
|
const segments = relativeDirectory
|
|
.split(path.sep)
|
|
.filter(Boolean)
|
|
.map(toOpenApiSegment)
|
|
.filter((segment): segment is string => segment !== null);
|
|
|
|
return `/api${segments.length ? `/${segments.join('/')}` : ''}`;
|
|
}
|
|
|
|
export async function discoverApiOperations(
|
|
projectRoot = process.cwd(),
|
|
): Promise<DiscoveredApiOperation[]> {
|
|
const apiRoot = path.join(projectRoot, 'src', 'app', 'api');
|
|
const routeFiles = await walk(apiRoot, name => ROUTE_FILE_PATTERN.test(name));
|
|
const operations: DiscoveredApiOperation[] = [];
|
|
|
|
for (const routeFile of routeFiles.sort()) {
|
|
const content = await readFile(routeFile, 'utf8');
|
|
const source = ts.createSourceFile(
|
|
routeFile,
|
|
content,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
routeFile.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
);
|
|
const routePath = getRoutePath(apiRoot, routeFile);
|
|
const relativeSource = path.relative(projectRoot, routeFile).replaceAll(path.sep, '/');
|
|
|
|
getExportedMethods(source).forEach(method => {
|
|
operations.push({ method, path: routePath, source: relativeSource });
|
|
});
|
|
}
|
|
|
|
return operations.sort((left, right) =>
|
|
`${left.path}:${left.method}`.localeCompare(`${right.path}:${right.method}`),
|
|
);
|
|
}
|
|
|
|
export async function discoverContractFiles(projectRoot = process.cwd()) {
|
|
const apiRoot = path.join(projectRoot, 'src', 'app', 'api');
|
|
|
|
return (await walk(apiRoot, name => name === 'contract.ts')).sort();
|
|
}
|