mirror of
https://github.com/Drop-OSS/drop-api-autogen.git
synced 2026-01-30 20:55:17 +01:00
235 lines
7.2 KiB
TypeScript
235 lines
7.2 KiB
TypeScript
import oxc from "oxc-parser";
|
|
import * as ts from "typescript";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import generateDocsPage from "./docs";
|
|
import { searchForSymbol, useProgram } from "./utils";
|
|
|
|
async function recursivelyDiscoverRoutes(dir: string): Promise<string[]> {
|
|
const files = fs.readdirSync(dir);
|
|
const resultFiles: string[] = [];
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(dir, file);
|
|
const stat = fs.statSync(filePath);
|
|
if (stat.isDirectory()) {
|
|
resultFiles.push(...(await recursivelyDiscoverRoutes(filePath)));
|
|
continue;
|
|
}
|
|
|
|
if (!filePath.endsWith(".ts")) continue;
|
|
|
|
resultFiles.push(filePath);
|
|
}
|
|
|
|
return resultFiles;
|
|
}
|
|
|
|
export interface Route {
|
|
fsPath: string;
|
|
urlPath: string;
|
|
method: string;
|
|
}
|
|
|
|
async function transformRoutes(base: string, routes: string[]) {
|
|
const results: Route[] = [];
|
|
for (const route of routes) {
|
|
const rawUrl = path.relative(base, route);
|
|
|
|
const method = (rawUrl.split(".").slice(-2)[0] ?? "get").toUpperCase();
|
|
|
|
let url = rawUrl.split(".")[0]!;
|
|
|
|
if (url.endsWith("index")) url = url.slice(0, -"index".length);
|
|
if (url.endsWith("/")) url = url.slice(0, -1);
|
|
|
|
const finalUrl = url
|
|
.split("/")
|
|
// Convert from [id] to :id
|
|
.map((e) => {
|
|
if (e.startsWith("[") && e.endsWith("]")) return `:${e.slice(1, -1)}`;
|
|
|
|
return e;
|
|
})
|
|
.join("/");
|
|
|
|
results.push({
|
|
fsPath: route,
|
|
urlPath: `/api/${finalUrl}`,
|
|
method,
|
|
});
|
|
}
|
|
|
|
results.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
|
|
|
|
return results;
|
|
}
|
|
|
|
export interface RouteMetadata {
|
|
checker: ts.TypeChecker;
|
|
body?: ts.Type;
|
|
|
|
routeDescription?: string;
|
|
routeParams?: { [key: string]: { type: string; comment: string } };
|
|
|
|
acls?: string[];
|
|
aclMode?: "user" | "system";
|
|
|
|
clientRoute: boolean;
|
|
}
|
|
|
|
async function generateDocs(
|
|
sourceDir: string,
|
|
baseDir: string,
|
|
docsDir: string,
|
|
routes: Route[]
|
|
) {
|
|
const program = useProgram(sourceDir);
|
|
const checker = program.getTypeChecker();
|
|
|
|
const allDiagnostics = ts.getPreEmitDiagnostics(program);
|
|
if (allDiagnostics.length > 0) throw allDiagnostics;
|
|
|
|
// Fetch aclManager symbol so we can find ACLs later
|
|
const aclManagerPath = path.join(sourceDir, "server/internal/acls/index.ts");
|
|
const aclManagerSource = program.getSourceFile(aclManagerPath);
|
|
if (!aclManagerSource) throw "No ACL manager - did the source change?";
|
|
const aclManagerExport = aclManagerSource
|
|
.getChildAt(0)!
|
|
.getChildren()
|
|
.find((v) => ts.isExportAssignment(v))!.expression;
|
|
const aclManagerSymbol = checker.getSymbolAtLocation(aclManagerExport);
|
|
if (!aclManagerSymbol) throw "Failed to fetch ACL manager symbol. What.";
|
|
|
|
for (const route of routes) {
|
|
const routeTs = program.getSourceFile(route.fsPath);
|
|
if (!routeTs) throw `No source file for ${route.fsPath}`;
|
|
|
|
// Extract the `defineClientHandler`/`defineHandler` from the exports
|
|
const syntaxList = routeTs.getChildAt(0);
|
|
const exportAssignment = syntaxList
|
|
.getChildren()
|
|
.find((e) => ts.isExportAssignment(e))!;
|
|
const eventHandler = exportAssignment.expression;
|
|
if (!ts.isCallExpression(eventHandler)) continue;
|
|
|
|
// Check if this is defineClientHandler, and mark as a client route if so
|
|
const eventHandlerName = eventHandler.expression.getText();
|
|
const isClient = eventHandlerName.toLowerCase().includes("client");
|
|
|
|
const metadata: RouteMetadata = { checker, clientRoute: isClient };
|
|
// Extract the body type from the generic args
|
|
// Complicated and messy
|
|
if (eventHandler.typeArguments) {
|
|
const resolvedSignature = checker.getResolvedSignature(eventHandler);
|
|
if (!resolvedSignature)
|
|
throw "Type arguments but no resolved signature? Somehow an invalid node?";
|
|
|
|
// Get handler (function) type
|
|
const handlerParam = resolvedSignature.getParameters().at(0)!;
|
|
const handlerType = checker.getTypeOfSymbolAtLocation(
|
|
handlerParam,
|
|
handlerParam.declarations!.at(0)!
|
|
);
|
|
if (!handlerType.isUnion())
|
|
throw "Weird defineEventHandler result - did Nitro change something interally?";
|
|
|
|
const eventHandlerRequestType = handlerType.types.at(0)!;
|
|
const typeArguments = eventHandlerRequestType[
|
|
"resolvedTypeArguments"
|
|
] as ts.Type[];
|
|
|
|
const requestConfigurationType = typeArguments.at(0)!;
|
|
|
|
const properties = requestConfigurationType.getProperties();
|
|
for (const property of properties) {
|
|
metadata[property.escapedName!] = checker.getTypeOfSymbolAtLocation(
|
|
property,
|
|
property.declarations!.at(0)!
|
|
);
|
|
}
|
|
}
|
|
|
|
// A kinda inefficient search through every node in the AST
|
|
// For the symbol "aclManager"
|
|
const foundAclManager = searchForSymbol(
|
|
checker,
|
|
eventHandler,
|
|
aclManagerSymbol
|
|
);
|
|
|
|
// Check the above node for the markers of an ACL check
|
|
// We can't be 100% sure because I'm not good at AST stuff
|
|
|
|
// Deeply nested but no other way to do the control flow
|
|
// Unless refactoring into another function
|
|
if (foundAclManager) {
|
|
const callExpression = foundAclManager.parent.parent;
|
|
if (ts.isCallExpression(callExpression)) {
|
|
const isSystem = callExpression.expression
|
|
.getText()
|
|
.toLowerCase()
|
|
.includes("system");
|
|
|
|
metadata.aclMode = isSystem ? "system" : "user";
|
|
|
|
const arrayLiteral = callExpression.arguments.find((e) =>
|
|
ts.isArrayLiteralExpression(e)
|
|
);
|
|
if (arrayLiteral) {
|
|
const strings = arrayLiteral.elements.filter((e) =>
|
|
ts.isStringLiteral(e)
|
|
);
|
|
|
|
const acls = strings.map((e) => e.text);
|
|
|
|
metadata.acls = acls;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract and parse the JSDoc for additional metadata
|
|
const jsDoc = ts.getJSDocCommentsAndTags(exportAssignment).at(0);
|
|
if (jsDoc) {
|
|
metadata.routeParams ??= {};
|
|
|
|
const description = jsDoc.comment;
|
|
if (description) {
|
|
metadata.routeDescription = description.toString();
|
|
}
|
|
|
|
for (const tag of jsDoc.getChildren()) {
|
|
if (!ts.isJSDocParameterTag(tag)) continue;
|
|
const type = tag.typeExpression?.getText().slice(1, -1) ?? "string";
|
|
const paramName = tag.name.getText();
|
|
const comment = tag.comment?.toString() ?? "";
|
|
metadata.routeParams[paramName] = { type, comment };
|
|
}
|
|
}
|
|
|
|
// Generate docs page and write to disk
|
|
const docs = await generateDocsPage(route, metadata);
|
|
const relativeOutput = path.relative(baseDir, route.fsPath);
|
|
|
|
const relativeOutputDir = path.join(docsDir, path.dirname(relativeOutput));
|
|
fs.mkdirSync(relativeOutputDir, { recursive: true });
|
|
|
|
const filename = path.parse(relativeOutput).name + ".md";
|
|
fs.writeFileSync(path.join(relativeOutputDir, filename), docs);
|
|
}
|
|
}
|
|
|
|
export default async function build() {
|
|
const sourceDir = path.resolve("./drop");
|
|
const baseDir = path.resolve("./drop/server/api");
|
|
const docsDir = path.resolve("./docs");
|
|
|
|
const rawRoutes = await recursivelyDiscoverRoutes(baseDir);
|
|
|
|
const routes = await transformRoutes(baseDir, rawRoutes);
|
|
|
|
await generateDocs(sourceDir, baseDir, docsDir, routes);
|
|
|
|
//console.log(routes.slice(0, 5));
|
|
}
|