mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): find every live route when generating openapi.json (#2484)
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "tsx src/scripts/GenerateSpec.ts",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"validate": "tsx src/scripts/GenerateSpec.ts --validate-only"
|
||||
},
|
||||
@@ -21,6 +22,8 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"prettier": "^3.8.3",
|
||||
"tsx": "catalog:"
|
||||
"tsx": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,19 @@ export interface OpenAPIGeneratorOptions {
|
||||
readonly serverUrl?: string;
|
||||
readonly routeScope?: OpenAPIRouteScope;
|
||||
}
|
||||
export interface SkippedRoute {
|
||||
readonly method: string;
|
||||
readonly path: string;
|
||||
readonly source: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
export interface OpenAPIGenerationStats {
|
||||
readonly controllerCount: number;
|
||||
readonly routeCount: number;
|
||||
readonly operationCount: number;
|
||||
readonly skippedRouteCount: number;
|
||||
readonly skippedRoutes: ReadonlyArray<SkippedRoute>;
|
||||
readonly untemplatableRoutes: ReadonlyArray<SkippedRoute>;
|
||||
readonly registeredSchemaCount: number;
|
||||
readonly publishedSchemaCount: number;
|
||||
readonly tagCount: number;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
OpenAPIGenerationResult,
|
||||
OpenAPIGeneratorOptions,
|
||||
OpenAPIRouteScope,
|
||||
SkippedRoute,
|
||||
} from '@fluxer/openapi/src/OpenAPIGenerationTypes';
|
||||
import type {ExtractedRoute, OpenAPIDocument, OpenAPIPathItem, OpenAPISchema} from '@fluxer/openapi/src/OpenAPITypes';
|
||||
import {convertPathToOpenAPI} from '@fluxer/openapi/src/registry/ParameterRegistry';
|
||||
@@ -16,7 +17,11 @@ import {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry';
|
||||
interface PathBuildResult {
|
||||
readonly paths: Record<string, OpenAPIPathItem>;
|
||||
readonly operationCount: number;
|
||||
readonly skippedRouteCount: number;
|
||||
readonly skippedRoutes: ReadonlyArray<SkippedRoute>;
|
||||
readonly untemplatableRoutes: ReadonlyArray<SkippedRoute>;
|
||||
}
|
||||
function hasOpenAPIPathTemplate(routePath: string): boolean {
|
||||
return !routePath.includes('*') && !routePath.includes('{');
|
||||
}
|
||||
interface GeneratorSettings {
|
||||
readonly basePath: string;
|
||||
@@ -36,6 +41,14 @@ function createGeneratorSettings(options: OpenAPIGeneratorOptions): GeneratorSet
|
||||
routeScope: options.routeScope ?? 'public',
|
||||
};
|
||||
}
|
||||
function describeRoute(route: ExtractedRoute, reason: string): SkippedRoute {
|
||||
return {
|
||||
method: route.method.toUpperCase(),
|
||||
path: route.path,
|
||||
source: `${route.controllerFile}:${route.lineNumber.toString()}`,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
function isAdminRoute(route: ExtractedRoute): boolean {
|
||||
return route.path === '/admin' || route.path.startsWith('/admin/');
|
||||
}
|
||||
@@ -102,7 +115,9 @@ export class OpenAPIGenerator {
|
||||
controllerCount: controllerFiles.length,
|
||||
routeCount: routes.length,
|
||||
operationCount: pathBuildResult.operationCount,
|
||||
skippedRouteCount: pathBuildResult.skippedRouteCount,
|
||||
skippedRouteCount: pathBuildResult.skippedRoutes.length,
|
||||
skippedRoutes: pathBuildResult.skippedRoutes,
|
||||
untemplatableRoutes: pathBuildResult.untemplatableRoutes,
|
||||
registeredSchemaCount,
|
||||
publishedSchemaCount: Object.keys(publishedSchemas).length,
|
||||
tagCount: tags.length,
|
||||
@@ -122,13 +137,18 @@ export class OpenAPIGenerator {
|
||||
private buildPaths(routes: Array<ExtractedRoute>, operationBuilder: OpenAPIOperationBuilder): PathBuildResult {
|
||||
const paths: Record<string, OpenAPIPathItem> = {};
|
||||
let operationCount = 0;
|
||||
let skippedRouteCount = 0;
|
||||
const skippedRoutes: Array<SkippedRoute> = [];
|
||||
const untemplatableRoutes: Array<SkippedRoute> = [];
|
||||
for (const route of routes) {
|
||||
if (isExcludedRoutePath(route.path)) {
|
||||
continue;
|
||||
}
|
||||
if (!route.responseSchemaName && !route.hasNoContent) {
|
||||
skippedRouteCount++;
|
||||
skippedRoutes.push(describeRoute(route, 'no responseSchema and no NoContent()'));
|
||||
continue;
|
||||
}
|
||||
if (!hasOpenAPIPathTemplate(route.path)) {
|
||||
untemplatableRoutes.push(describeRoute(route, 'the Hono path has no OpenAPI path template'));
|
||||
continue;
|
||||
}
|
||||
const openApiPath = convertPathToOpenAPI(route.path);
|
||||
@@ -143,7 +163,8 @@ export class OpenAPIGenerator {
|
||||
return {
|
||||
paths: sortedPaths,
|
||||
operationCount,
|
||||
skippedRouteCount,
|
||||
skippedRoutes,
|
||||
untemplatableRoutes,
|
||||
};
|
||||
}
|
||||
private filterPublishedSchemas(
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import {
|
||||
EMPTY_SCOPE,
|
||||
type Scope,
|
||||
StaticPathResolver,
|
||||
type StaticValue,
|
||||
UNRESOLVED,
|
||||
} from '@fluxer/openapi/src/extractors/StaticPathResolver';
|
||||
import type {ExtractedRoute, ExtractedValidator, HttpMethod, ValidatorTarget} from '@fluxer/openapi/src/Types';
|
||||
import {type CallExpression, Node, Project, type SourceFile} from 'ts-morph';
|
||||
import {type CallExpression, type FunctionDeclaration, Node, Project, type SourceFile} from 'ts-morph';
|
||||
|
||||
const HTTP_METHODS: ReadonlySet<string> = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
||||
function isHttpMethod(method: string): method is HttpMethod {
|
||||
@@ -45,10 +52,24 @@ function extractOAuth2ScopeArgs(args: ReadonlyArray<Node>): Array<string> | null
|
||||
}
|
||||
return scopes.length > 0 ? scopes : null;
|
||||
}
|
||||
function extractObjectLiteralValue(node: Node): unknown {
|
||||
interface MetadataContext {
|
||||
readonly resolver: StaticPathResolver;
|
||||
readonly scope: Scope;
|
||||
}
|
||||
function resolveMetadataText(node: Node, context: MetadataContext | null): string | null {
|
||||
if (context == null) {
|
||||
return null;
|
||||
}
|
||||
const value = context.resolver.resolve(node, context.scope);
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
function extractObjectLiteralValue(node: Node, context: MetadataContext | null): unknown {
|
||||
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return node.getLiteralValue();
|
||||
}
|
||||
if (Node.isTemplateExpression(node) || Node.isConditionalExpression(node)) {
|
||||
return resolveMetadataText(node, context);
|
||||
}
|
||||
if (Node.isNumericLiteral(node)) {
|
||||
return Number.parseFloat(node.getText());
|
||||
}
|
||||
@@ -65,13 +86,26 @@ function extractObjectLiteralValue(node: Node): unknown {
|
||||
return node.getText();
|
||||
}
|
||||
if (Node.isPropertyAccessExpression(node)) {
|
||||
return node.getText();
|
||||
return resolveMetadataText(node, context) ?? node.getText();
|
||||
}
|
||||
if (Node.isCallExpression(node)) {
|
||||
return node.getText();
|
||||
}
|
||||
if (Node.isArrayLiteralExpression(node)) {
|
||||
return node.getElements().map((el) => extractObjectLiteralValue(el));
|
||||
const values: Array<unknown> = [];
|
||||
for (const element of node.getElements()) {
|
||||
if (Node.isSpreadElement(element)) {
|
||||
const spread = context?.resolver.resolve(element.getExpression(), context.scope);
|
||||
if (spread == null || spread === UNRESOLVED || !Array.isArray(spread)) {
|
||||
values.push(null);
|
||||
continue;
|
||||
}
|
||||
values.push(...spread);
|
||||
continue;
|
||||
}
|
||||
values.push(extractObjectLiteralValue(element, context));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
if (Node.isObjectLiteralExpression(node)) {
|
||||
const result: Record<string, unknown> = {};
|
||||
@@ -80,7 +114,7 @@ function extractObjectLiteralValue(node: Node): unknown {
|
||||
const key = prop.getName();
|
||||
const initializer = prop.getInitializer();
|
||||
if (initializer) {
|
||||
result[key] = extractObjectLiteralValue(initializer);
|
||||
result[key] = extractObjectLiteralValue(initializer, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,9 +122,9 @@ function extractObjectLiteralValue(node: Node): unknown {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function parseObjectLiteralMetadata(objLiteral: Node): Record<string, unknown> {
|
||||
function parseObjectLiteralMetadata(objLiteral: Node, context: MetadataContext | null): Record<string, unknown> {
|
||||
if (!Node.isObjectLiteralExpression(objLiteral)) return {};
|
||||
return extractObjectLiteralValue(objLiteral) as Record<string, unknown>;
|
||||
return extractObjectLiteralValue(objLiteral, context) as Record<string, unknown>;
|
||||
}
|
||||
function extractValidatorInfo(callExpr: CallExpression): ExtractedValidator | null {
|
||||
const expression = callExpr.getExpression();
|
||||
@@ -162,7 +196,7 @@ interface MiddlewareInfo {
|
||||
description?: string;
|
||||
} | null;
|
||||
}
|
||||
function extractMiddlewareInfo(callExpr: CallExpression): MiddlewareInfo | null {
|
||||
function extractMiddlewareInfo(callExpr: CallExpression, context: MetadataContext | null): MiddlewareInfo | null {
|
||||
const expression = callExpr.getExpression();
|
||||
if (Node.isIdentifier(expression)) {
|
||||
const name = expression.getText();
|
||||
@@ -245,7 +279,7 @@ function extractMiddlewareInfo(callExpr: CallExpression): MiddlewareInfo | null
|
||||
if (args.length === 0) return null;
|
||||
const firstArg = args[0];
|
||||
if (Node.isObjectLiteralExpression(firstArg)) {
|
||||
const metadata = parseObjectLiteralMetadata(firstArg);
|
||||
const metadata = parseObjectLiteralMetadata(firstArg, context);
|
||||
const operationId = typeof metadata.operationId === 'string' ? metadata.operationId : null;
|
||||
const summary = typeof metadata.summary === 'string' ? metadata.summary : null;
|
||||
const description = typeof metadata.description === 'string' ? metadata.description : null;
|
||||
@@ -479,24 +513,142 @@ function extractSuccessStatusCodes(handler: Node): Array<number> {
|
||||
});
|
||||
return Array.from(codes).sort((a, b) => a - b);
|
||||
}
|
||||
function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile): ExtractedRoute | null {
|
||||
interface RegistrationCall {
|
||||
readonly call: CallExpression;
|
||||
readonly methods: ReadonlyArray<HttpMethod>;
|
||||
readonly pathArgument: Node;
|
||||
readonly middlewareArguments: ReadonlyArray<Node>;
|
||||
}
|
||||
interface UnresolvedRegistration {
|
||||
readonly filePath: string;
|
||||
readonly lineNumber: number;
|
||||
readonly methods: string;
|
||||
readonly expression: string;
|
||||
}
|
||||
function methodsFromOnArgument(node: Node, resolver: StaticPathResolver, scope: Scope): Array<HttpMethod> | null {
|
||||
const value = resolver.resolve(node, scope);
|
||||
if (value === UNRESOLVED) {
|
||||
return null;
|
||||
}
|
||||
const entries: Array<StaticValue> = Array.isArray(value) ? [...value] : [value];
|
||||
const methods: Array<HttpMethod> = [];
|
||||
for (const entry of entries) {
|
||||
if (typeof entry !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const lowered = entry.toLowerCase();
|
||||
if (lowered === 'head') {
|
||||
continue;
|
||||
}
|
||||
if (!isHttpMethod(lowered)) {
|
||||
return null;
|
||||
}
|
||||
methods.push(lowered);
|
||||
}
|
||||
return methods.length > 0 ? methods : null;
|
||||
}
|
||||
const HONO_TYPE_PATTERN = /\bHono(App|Env)?\b/u;
|
||||
function isHonoReceiver(receiver: Node): boolean {
|
||||
if (!Node.isIdentifier(receiver)) {
|
||||
return false;
|
||||
}
|
||||
const name = receiver.getText();
|
||||
for (const ancestor of receiver.getAncestors()) {
|
||||
if (
|
||||
Node.isFunctionDeclaration(ancestor) ||
|
||||
Node.isArrowFunction(ancestor) ||
|
||||
Node.isFunctionExpression(ancestor) ||
|
||||
Node.isMethodDeclaration(ancestor)
|
||||
) {
|
||||
for (const parameter of ancestor.getParameters()) {
|
||||
const nameNode = parameter.getNameNode();
|
||||
if (Node.isIdentifier(nameNode) && nameNode.getText() === name) {
|
||||
return HONO_TYPE_PATTERN.test(parameter.getTypeNode()?.getText() ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Node.isBlock(ancestor) || Node.isSourceFile(ancestor)) {
|
||||
for (const statement of ancestor.getStatements()) {
|
||||
if (!Node.isVariableStatement(statement)) {
|
||||
continue;
|
||||
}
|
||||
for (const declaration of statement.getDeclarations()) {
|
||||
const nameNode = declaration.getNameNode();
|
||||
if (Node.isIdentifier(nameNode) && nameNode.getText() === name) {
|
||||
const annotation = declaration.getTypeNode()?.getText() ?? '';
|
||||
const initializer = declaration.getInitializer()?.getText() ?? '';
|
||||
return HONO_TYPE_PATTERN.test(`${annotation} ${initializer}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function isRegistrationCall(callExpr: CallExpression): boolean {
|
||||
const expression = callExpr.getExpression();
|
||||
if (!Node.isPropertyAccessExpression(expression)) {
|
||||
return false;
|
||||
}
|
||||
if (!isHonoReceiver(expression.getExpression())) {
|
||||
return false;
|
||||
}
|
||||
const name = expression.getName().toLowerCase();
|
||||
const args = callExpr.getArguments();
|
||||
if (isHttpMethod(name)) {
|
||||
return args.length >= 2;
|
||||
}
|
||||
return name === 'on' && args.length >= 3;
|
||||
}
|
||||
function pathArgumentOf(callExpr: CallExpression): Node | null {
|
||||
const expression = callExpr.getExpression();
|
||||
if (!Node.isPropertyAccessExpression(expression)) {
|
||||
return null;
|
||||
}
|
||||
const method = expression.getName().toLowerCase();
|
||||
if (!isHttpMethod(method)) {
|
||||
return null;
|
||||
}
|
||||
const args = callExpr.getArguments();
|
||||
if (args.length < 2) {
|
||||
return expression.getName().toLowerCase() === 'on' ? (args[1] ?? null) : (args[0] ?? null);
|
||||
}
|
||||
function readRegistrationCall(
|
||||
callExpr: CallExpression,
|
||||
resolver: StaticPathResolver,
|
||||
scope: Scope,
|
||||
): RegistrationCall | null {
|
||||
if (!isRegistrationCall(callExpr)) {
|
||||
return null;
|
||||
}
|
||||
const pathArg = args[0];
|
||||
const path = extractStringLiteral(pathArg);
|
||||
if (!path) {
|
||||
const expression = callExpr.getExpression();
|
||||
if (!Node.isPropertyAccessExpression(expression)) {
|
||||
return null;
|
||||
}
|
||||
const name = expression.getName().toLowerCase();
|
||||
const args = callExpr.getArguments();
|
||||
if (isHttpMethod(name)) {
|
||||
return {
|
||||
call: callExpr,
|
||||
methods: [name],
|
||||
pathArgument: args[0],
|
||||
middlewareArguments: args.slice(1),
|
||||
};
|
||||
}
|
||||
const methods = methodsFromOnArgument(args[0], resolver, scope);
|
||||
if (methods == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
call: callExpr,
|
||||
methods,
|
||||
pathArgument: args[1],
|
||||
middlewareArguments: args.slice(2),
|
||||
};
|
||||
}
|
||||
function buildRoute(
|
||||
registration: RegistrationCall,
|
||||
method: HttpMethod,
|
||||
routePath: string,
|
||||
sourceFile: SourceFile,
|
||||
resolver: StaticPathResolver,
|
||||
scope: Scope,
|
||||
): ExtractedRoute {
|
||||
const validators: Array<ExtractedValidator> = [];
|
||||
const middlewares: Array<string> = [];
|
||||
let hasLoginRequired = false;
|
||||
@@ -525,8 +677,7 @@ function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile):
|
||||
url: string;
|
||||
description?: string;
|
||||
} | null = null;
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
for (const arg of registration.middlewareArguments) {
|
||||
if (Node.isIdentifier(arg)) {
|
||||
const name = arg.getText();
|
||||
middlewares.push(name);
|
||||
@@ -544,7 +695,7 @@ function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile):
|
||||
if (validatorInfo) {
|
||||
validators.push(validatorInfo);
|
||||
} else {
|
||||
const middlewareInfo = extractMiddlewareInfo(arg);
|
||||
const middlewareInfo = extractMiddlewareInfo(arg, {resolver, scope});
|
||||
if (middlewareInfo) {
|
||||
middlewares.push(middlewareInfo.middlewareName);
|
||||
if (middlewareInfo.rateLimitConfig) {
|
||||
@@ -580,7 +731,7 @@ function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile):
|
||||
if (middlewareInfo.oauth2RequiredScopes && middlewareInfo.oauth2ScopeMode) {
|
||||
if (oauth2ScopeMode && oauth2ScopeMode !== middlewareInfo.oauth2ScopeMode) {
|
||||
throw new Error(
|
||||
`Cannot combine OAuth2 scope middleware modes on ${method.toUpperCase()} ${path} in ${sourceFile.getFilePath()}:${callExpr.getStartLineNumber()}`,
|
||||
`Cannot combine OAuth2 scope middleware modes on ${method.toUpperCase()} ${routePath} in ${sourceFile.getFilePath()}:${registration.call.getStartLineNumber()}`,
|
||||
);
|
||||
}
|
||||
oauth2ScopeMode = middlewareInfo.oauth2ScopeMode;
|
||||
@@ -615,9 +766,9 @@ function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile):
|
||||
}
|
||||
return {
|
||||
method,
|
||||
path,
|
||||
path: routePath,
|
||||
controllerFile: sourceFile.getFilePath(),
|
||||
lineNumber: callExpr.getStartLineNumber(),
|
||||
lineNumber: registration.call.getStartLineNumber(),
|
||||
validators,
|
||||
middlewares,
|
||||
hasLoginRequired,
|
||||
@@ -645,16 +796,194 @@ function extractRouteFromCall(callExpr: CallExpression, sourceFile: SourceFile):
|
||||
explicitExternalDocs,
|
||||
};
|
||||
}
|
||||
function findRoutesInSourceFile(sourceFile: SourceFile): Array<ExtractedRoute> {
|
||||
const routes: Array<ExtractedRoute> = [];
|
||||
sourceFile.forEachDescendant((node) => {
|
||||
if (Node.isCallExpression(node)) {
|
||||
const route = extractRouteFromCall(node, sourceFile);
|
||||
if (route) {
|
||||
routes.push(route);
|
||||
function owningFunction(node: Node): FunctionDeclaration | null {
|
||||
for (const ancestor of node.getAncestors()) {
|
||||
if (Node.isFunctionDeclaration(ancestor)) {
|
||||
return ancestor;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function bindParameters(
|
||||
fn: FunctionDeclaration,
|
||||
args: ReadonlyArray<Node>,
|
||||
callerScope: Scope,
|
||||
resolver: StaticPathResolver,
|
||||
): Scope {
|
||||
const scope = new Map<string, StaticValue>();
|
||||
fn.getParameters().forEach((parameter, index) => {
|
||||
const arg = args[index];
|
||||
if (arg == null) {
|
||||
return;
|
||||
}
|
||||
const value = resolver.resolve(arg, callerScope);
|
||||
if (value === UNRESOLVED) {
|
||||
return;
|
||||
}
|
||||
const nameNode = parameter.getNameNode();
|
||||
if (Node.isIdentifier(nameNode)) {
|
||||
scope.set(nameNode.getText(), value);
|
||||
return;
|
||||
}
|
||||
if (Node.isObjectBindingPattern(nameNode) && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
const record = value as {readonly [key: string]: StaticValue};
|
||||
for (const element of nameNode.getElements()) {
|
||||
const key = element.getPropertyNameNode()?.getText() ?? element.getName();
|
||||
if (key in record) {
|
||||
scope.set(element.getName(), record[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return scope;
|
||||
}
|
||||
function scopesForFunction(
|
||||
fn: FunctionDeclaration,
|
||||
sourceFile: SourceFile,
|
||||
resolver: StaticPathResolver,
|
||||
visiting: Set<FunctionDeclaration>,
|
||||
): Array<Scope> {
|
||||
if (visiting.has(fn)) {
|
||||
return [EMPTY_SCOPE];
|
||||
}
|
||||
const name = fn.getName();
|
||||
if (name == null) {
|
||||
return [EMPTY_SCOPE];
|
||||
}
|
||||
visiting.add(fn);
|
||||
try {
|
||||
const scopes: Array<Scope> = [];
|
||||
sourceFile.forEachDescendant((node) => {
|
||||
if (!Node.isCallExpression(node)) {
|
||||
return;
|
||||
}
|
||||
const callee = node.getExpression();
|
||||
if (!Node.isIdentifier(callee) || callee.getText() !== name) {
|
||||
return;
|
||||
}
|
||||
const enclosing = owningFunction(node);
|
||||
const outerScopes =
|
||||
enclosing == null || enclosing === fn
|
||||
? [EMPTY_SCOPE]
|
||||
: scopesForFunction(enclosing, sourceFile, resolver, visiting);
|
||||
for (const outerScope of outerScopes) {
|
||||
for (const loopScope of expandLoops(node, enclosing, outerScope, resolver)) {
|
||||
scopes.push(bindParameters(fn, node.getArguments(), loopScope, resolver));
|
||||
}
|
||||
}
|
||||
});
|
||||
return scopes.length > 0 ? scopes : [EMPTY_SCOPE];
|
||||
} finally {
|
||||
visiting.delete(fn);
|
||||
}
|
||||
}
|
||||
function expandLoops(
|
||||
node: Node,
|
||||
stopAt: FunctionDeclaration | null,
|
||||
baseScope: Scope,
|
||||
resolver: StaticPathResolver,
|
||||
): Array<Scope> {
|
||||
const loops: Array<Node> = [];
|
||||
for (const ancestor of node.getAncestors()) {
|
||||
if (ancestor === stopAt || Node.isSourceFile(ancestor)) {
|
||||
break;
|
||||
}
|
||||
if (Node.isForOfStatement(ancestor)) {
|
||||
loops.push(ancestor);
|
||||
}
|
||||
}
|
||||
let scopes: Array<Scope> = [baseScope];
|
||||
for (const loop of loops.reverse()) {
|
||||
if (!Node.isForOfStatement(loop)) {
|
||||
continue;
|
||||
}
|
||||
const initializer = loop.getInitializer();
|
||||
if (!Node.isVariableDeclarationList(initializer)) {
|
||||
return scopes;
|
||||
}
|
||||
const declaration = initializer.getDeclarations()[0];
|
||||
const nameNode = declaration?.getNameNode();
|
||||
if (nameNode == null || !Node.isIdentifier(nameNode)) {
|
||||
return scopes;
|
||||
}
|
||||
const expanded: Array<Scope> = [];
|
||||
for (const scope of scopes) {
|
||||
const iterated = resolver.resolve(loop.getExpression(), scope);
|
||||
if (!Array.isArray(iterated)) {
|
||||
return scopes;
|
||||
}
|
||||
for (const element of iterated) {
|
||||
const next = new Map(scope);
|
||||
next.set(nameNode.getText(), element);
|
||||
expanded.push(next);
|
||||
}
|
||||
}
|
||||
scopes = expanded;
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
function findRoutesInSourceFile(
|
||||
sourceFile: SourceFile,
|
||||
resolver: StaticPathResolver,
|
||||
unresolved: Array<UnresolvedRegistration>,
|
||||
): Array<ExtractedRoute> {
|
||||
const registrations: Array<CallExpression> = [];
|
||||
sourceFile.forEachDescendant((node) => {
|
||||
if (Node.isCallExpression(node)) {
|
||||
registrations.push(node);
|
||||
}
|
||||
});
|
||||
const byOwner = new Map<FunctionDeclaration | null, Array<CallExpression>>();
|
||||
for (const call of registrations) {
|
||||
if (!isRegistrationCall(call)) {
|
||||
continue;
|
||||
}
|
||||
const owner = owningFunction(call);
|
||||
const bucket = byOwner.get(owner);
|
||||
if (bucket == null) {
|
||||
byOwner.set(owner, [call]);
|
||||
} else {
|
||||
bucket.push(call);
|
||||
}
|
||||
}
|
||||
const routes: Array<ExtractedRoute> = [];
|
||||
for (const [owner, calls] of byOwner) {
|
||||
const scopes = owner == null ? [EMPTY_SCOPE] : scopesForFunction(owner, sourceFile, resolver, new Set());
|
||||
for (const call of calls) {
|
||||
const seen = new Set<string>();
|
||||
let resolvedAny = false;
|
||||
for (const scope of scopes) {
|
||||
const registration = readRegistrationCall(call, resolver, scope);
|
||||
if (registration == null) {
|
||||
continue;
|
||||
}
|
||||
const routePath = resolver.resolveString(registration.pathArgument, scope);
|
||||
if (routePath == null) {
|
||||
continue;
|
||||
}
|
||||
resolvedAny = true;
|
||||
for (const method of registration.methods) {
|
||||
const key = `${method} ${routePath}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
routes.push(buildRoute(registration, method, routePath, sourceFile, resolver, scope));
|
||||
}
|
||||
}
|
||||
if (!resolvedAny) {
|
||||
const expression = call.getExpression();
|
||||
const methodName = Node.isPropertyAccessExpression(expression) ? expression.getName().toUpperCase() : '?';
|
||||
const pathArgument = pathArgumentOf(call);
|
||||
unresolved.push({
|
||||
filePath: sourceFile.getFilePath(),
|
||||
lineNumber: call.getStartLineNumber(),
|
||||
methods: methodName === 'ON' ? `ON ${call.getArguments()[0].getText()}` : methodName,
|
||||
expression: (pathArgument ?? call).getText().replace(/\s+/gu, ' '),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
export function extractRoutesFromControllers(controllerPaths: Array<string>): Array<ExtractedRoute> {
|
||||
@@ -662,16 +991,32 @@ export function extractRoutesFromControllers(controllerPaths: Array<string>): Ar
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
skipFileDependencyResolution: true,
|
||||
});
|
||||
const resolver = new StaticPathResolver(project);
|
||||
const routes: Array<ExtractedRoute> = [];
|
||||
const unresolved: Array<UnresolvedRegistration> = [];
|
||||
for (const controllerPath of controllerPaths) {
|
||||
try {
|
||||
const sourceFile = project.addSourceFileAtPath(controllerPath);
|
||||
const fileRoutes = findRoutesInSourceFile(sourceFile);
|
||||
const fileRoutes = findRoutesInSourceFile(sourceFile, resolver, unresolved);
|
||||
routes.push(...fileRoutes);
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Could not parse ${controllerPath}:`, error);
|
||||
}
|
||||
}
|
||||
if (unresolved.length > 0) {
|
||||
const lines = unresolved.map(
|
||||
(entry) => ` ${entry.filePath}:${entry.lineNumber.toString()} ${entry.methods} ${entry.expression}`,
|
||||
);
|
||||
throw new Error(
|
||||
[
|
||||
`The route extractor could not read ${unresolved.length.toString()} route path(s). A path it cannot read is a route`,
|
||||
'that would vanish from openapi.json and from the docs coverage gate without a trace, so extraction',
|
||||
'stops here instead. Give the path a literal, or a const the resolver can follow, or teach',
|
||||
'packages/openapi/src/extractors/StaticPathResolver.ts to read the expression.',
|
||||
...lines,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
export function discoverControllerFiles(apiPackagePath: string): Array<string> {
|
||||
@@ -679,6 +1024,10 @@ export function discoverControllerFiles(apiPackagePath: string): Array<string> {
|
||||
tsConfigFilePath: `${apiPackagePath}/tsconfig.json`,
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
const sourceFiles = project.addSourceFilesAtPaths([`${apiPackagePath}/src/**/*Controller.ts`]);
|
||||
const sourceFiles = project.addSourceFilesAtPaths([
|
||||
`${apiPackagePath}/src/**/*.ts`,
|
||||
`!${apiPackagePath}/src/**/*.test.ts`,
|
||||
`!${apiPackagePath}/src/**/tests/**`,
|
||||
]);
|
||||
return sourceFiles.map((sf) => sf.getFilePath());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import * as path from 'node:path';
|
||||
import {Node, type Project, type SourceFile, SyntaxKind} from 'ts-morph';
|
||||
|
||||
export const UNRESOLVED = Symbol('unresolved');
|
||||
|
||||
export type StaticValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| ReadonlyArray<StaticValue>
|
||||
| {readonly [key: string]: StaticValue};
|
||||
|
||||
type Resolved = StaticValue | typeof UNRESOLVED;
|
||||
|
||||
export type Scope = ReadonlyMap<string, StaticValue>;
|
||||
|
||||
export const EMPTY_SCOPE: Scope = new Map<string, StaticValue>();
|
||||
|
||||
const MAX_DEPTH = 48;
|
||||
|
||||
function isPlainObject(value: Resolved): value is {readonly [key: string]: StaticValue} {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isTruthy(value: StaticValue): boolean {
|
||||
if (Array.isArray(value)) return true;
|
||||
if (isPlainObject(value)) return true;
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function unwrap(node: Node): Node {
|
||||
let current = node;
|
||||
while (
|
||||
Node.isParenthesizedExpression(current) ||
|
||||
Node.isAsExpression(current) ||
|
||||
Node.isSatisfiesExpression(current) ||
|
||||
Node.isNonNullExpression(current) ||
|
||||
Node.isTypeAssertion(current)
|
||||
) {
|
||||
current = current.getExpression();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export class StaticPathResolver {
|
||||
private readonly moduleConstants = new Map<SourceFile, Map<string, Node>>();
|
||||
private readonly inFlight = new Set<Node>();
|
||||
|
||||
constructor(private readonly project: Project) {}
|
||||
|
||||
public resolve(node: Node, scope: Scope): Resolved {
|
||||
return this.evaluate(node, scope, 0);
|
||||
}
|
||||
|
||||
public resolveString(node: Node, scope: Scope): string | null {
|
||||
const value = this.evaluate(node, scope, 0);
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
private evaluate(rawNode: Node, scope: Scope, depth: number): Resolved {
|
||||
if (depth > MAX_DEPTH) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const node = unwrap(rawNode);
|
||||
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return node.getLiteralValue();
|
||||
}
|
||||
if (Node.isNumericLiteral(node)) {
|
||||
return node.getLiteralValue();
|
||||
}
|
||||
if (Node.isTrueLiteral(node)) {
|
||||
return true;
|
||||
}
|
||||
if (Node.isFalseLiteral(node)) {
|
||||
return false;
|
||||
}
|
||||
if (Node.isNullLiteral(node)) {
|
||||
return null;
|
||||
}
|
||||
if (Node.isTemplateExpression(node)) {
|
||||
let text = node.getHead().getLiteralText();
|
||||
for (const span of node.getTemplateSpans()) {
|
||||
const value = this.evaluate(span.getExpression(), scope, depth + 1);
|
||||
if (value === UNRESOLVED || Array.isArray(value) || isPlainObject(value)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
text += String(value);
|
||||
text += span.getLiteral().getLiteralText();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
if (Node.isIdentifier(node)) {
|
||||
return this.evaluateIdentifier(node, scope, depth);
|
||||
}
|
||||
if (Node.isPropertyAccessExpression(node)) {
|
||||
const target = this.evaluate(node.getExpression(), scope, depth + 1);
|
||||
if (!isPlainObject(target)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const name = node.getName();
|
||||
return name in target ? target[name] : UNRESOLVED;
|
||||
}
|
||||
if (Node.isElementAccessExpression(node)) {
|
||||
const target = this.evaluate(node.getExpression(), scope, depth + 1);
|
||||
const argument = node.getArgumentExpression();
|
||||
if (argument == null || target === UNRESOLVED) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const key = this.evaluate(argument, scope, depth + 1);
|
||||
if (typeof key !== 'string' && typeof key !== 'number') {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if (Array.isArray(target)) {
|
||||
const index = typeof key === 'number' ? key : Number.parseInt(key, 10);
|
||||
return Number.isInteger(index) && index >= 0 && index < target.length ? target[index] : UNRESOLVED;
|
||||
}
|
||||
if (isPlainObject(target)) {
|
||||
const name = String(key);
|
||||
return name in target ? target[name] : UNRESOLVED;
|
||||
}
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if (Node.isArrayLiteralExpression(node)) {
|
||||
const values: Array<StaticValue> = [];
|
||||
for (const element of node.getElements()) {
|
||||
if (Node.isSpreadElement(element)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const value = this.evaluate(element, scope, depth + 1);
|
||||
if (value === UNRESOLVED) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
values.push(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
if (Node.isObjectLiteralExpression(node)) {
|
||||
const result: Record<string, StaticValue> = {};
|
||||
for (const property of node.getProperties()) {
|
||||
if (Node.isPropertyAssignment(property)) {
|
||||
const initializer = property.getInitializer();
|
||||
if (initializer == null) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const value = this.evaluate(initializer, scope, depth + 1);
|
||||
if (value === UNRESOLVED) {
|
||||
continue;
|
||||
}
|
||||
result[property.getName()] = value;
|
||||
continue;
|
||||
}
|
||||
if (Node.isShorthandPropertyAssignment(property)) {
|
||||
const value = this.evaluate(property.getNameNode(), scope, depth + 1);
|
||||
if (value === UNRESOLVED) {
|
||||
continue;
|
||||
}
|
||||
result[property.getName()] = value;
|
||||
continue;
|
||||
}
|
||||
return UNRESOLVED;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (Node.isConditionalExpression(node)) {
|
||||
const condition = this.evaluate(node.getCondition(), scope, depth + 1);
|
||||
if (condition === UNRESOLVED) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
return this.evaluate(isTruthy(condition) ? node.getWhenTrue() : node.getWhenFalse(), scope, depth + 1);
|
||||
}
|
||||
if (Node.isPrefixUnaryExpression(node)) {
|
||||
if (node.getOperatorToken() !== SyntaxKind.ExclamationToken) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const operand = this.evaluate(node.getOperand(), scope, depth + 1);
|
||||
return operand === UNRESOLVED ? UNRESOLVED : !isTruthy(operand);
|
||||
}
|
||||
if (Node.isBinaryExpression(node)) {
|
||||
return this.evaluateBinary(node, scope, depth);
|
||||
}
|
||||
return UNRESOLVED;
|
||||
}
|
||||
|
||||
private evaluateBinary(node: Node, scope: Scope, depth: number): Resolved {
|
||||
if (!Node.isBinaryExpression(node)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const operator = node.getOperatorToken().getText();
|
||||
const left = this.evaluate(node.getLeft(), scope, depth + 1);
|
||||
if (left === UNRESOLVED) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if (operator === '&&') {
|
||||
return isTruthy(left) ? this.evaluate(node.getRight(), scope, depth + 1) : left;
|
||||
}
|
||||
if (operator === '||') {
|
||||
return isTruthy(left) ? left : this.evaluate(node.getRight(), scope, depth + 1);
|
||||
}
|
||||
if (operator === '??') {
|
||||
return left === null ? this.evaluate(node.getRight(), scope, depth + 1) : left;
|
||||
}
|
||||
const right = this.evaluate(node.getRight(), scope, depth + 1);
|
||||
if (right === UNRESOLVED) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if (operator === '+') {
|
||||
if (typeof left === 'string' && (typeof right === 'string' || typeof right === 'number')) {
|
||||
return left + String(right);
|
||||
}
|
||||
if (typeof left === 'number' && typeof right === 'number') {
|
||||
return left + right;
|
||||
}
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const comparable =
|
||||
(typeof left === 'string' || typeof left === 'number' || typeof left === 'boolean' || left === null) &&
|
||||
(typeof right === 'string' || typeof right === 'number' || typeof right === 'boolean' || right === null);
|
||||
if (!comparable) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if (operator === '===' || operator === '==') {
|
||||
return left === right;
|
||||
}
|
||||
if (operator === '!==' || operator === '!=') {
|
||||
return left !== right;
|
||||
}
|
||||
return UNRESOLVED;
|
||||
}
|
||||
|
||||
private evaluateIdentifier(node: Node, scope: Scope, depth: number): Resolved {
|
||||
if (!Node.isIdentifier(node)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const name = node.getText();
|
||||
if (name === 'undefined') {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const bound = scope.get(name);
|
||||
if (bound !== undefined) {
|
||||
return bound;
|
||||
}
|
||||
const local = this.findBindingInScopeChain(node, name);
|
||||
if (local != null) {
|
||||
return this.evaluateBinding(local, scope, depth);
|
||||
}
|
||||
return this.evaluateImportedConstant(node.getSourceFile(), name, depth);
|
||||
}
|
||||
|
||||
private findBindingInScopeChain(from: Node, name: string): Node | null {
|
||||
for (const ancestor of from.getAncestors()) {
|
||||
if (!Node.isBlock(ancestor) && !Node.isSourceFile(ancestor) && !Node.isCaseClause(ancestor)) {
|
||||
continue;
|
||||
}
|
||||
for (const statement of ancestor.getStatements()) {
|
||||
if (!Node.isVariableStatement(statement)) {
|
||||
continue;
|
||||
}
|
||||
if (statement.getDeclarationKind() !== 'const') {
|
||||
continue;
|
||||
}
|
||||
for (const declaration of statement.getDeclarations()) {
|
||||
const nameNode = declaration.getNameNode();
|
||||
if (Node.isIdentifier(nameNode)) {
|
||||
if (nameNode.getText() === name) {
|
||||
return declaration;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (Node.isObjectBindingPattern(nameNode) || Node.isArrayBindingPattern(nameNode)) {
|
||||
for (const element of nameNode.getElements()) {
|
||||
if (Node.isBindingElement(element) && element.getName() === name) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private evaluateBinding(binding: Node, scope: Scope, depth: number): Resolved {
|
||||
if (this.inFlight.has(binding)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
this.inFlight.add(binding);
|
||||
try {
|
||||
if (Node.isVariableDeclaration(binding)) {
|
||||
const initializer = binding.getInitializer();
|
||||
return initializer == null ? UNRESOLVED : this.evaluate(initializer, scope, depth + 1);
|
||||
}
|
||||
if (!Node.isBindingElement(binding)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if (binding.getDotDotDotToken() != null) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const pattern = binding.getParent();
|
||||
const declaration = pattern.getParent();
|
||||
if (!Node.isVariableDeclaration(declaration)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const initializer = declaration.getInitializer();
|
||||
if (initializer == null) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const source = this.evaluate(initializer, scope, depth + 1);
|
||||
if (source === UNRESOLVED) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if (Node.isObjectBindingPattern(pattern)) {
|
||||
if (!isPlainObject(source)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const key = binding.getPropertyNameNode()?.getText() ?? binding.getName();
|
||||
return key in source ? source[key] : UNRESOLVED;
|
||||
}
|
||||
if (Node.isArrayBindingPattern(pattern)) {
|
||||
if (!Array.isArray(source)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const index = pattern.getElements().indexOf(binding);
|
||||
return index >= 0 && index < source.length ? source[index] : UNRESOLVED;
|
||||
}
|
||||
return UNRESOLVED;
|
||||
} finally {
|
||||
this.inFlight.delete(binding);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateImportedConstant(sourceFile: SourceFile, name: string, depth: number): Resolved {
|
||||
const target = this.resolveImportTarget(sourceFile, name);
|
||||
if (target == null) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const constants = this.constantsOf(target.sourceFile);
|
||||
const initializer = constants.get(target.exportedName);
|
||||
if (initializer == null) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
return this.evaluate(initializer, EMPTY_SCOPE, depth + 1);
|
||||
}
|
||||
|
||||
private resolveImportTarget(
|
||||
sourceFile: SourceFile,
|
||||
name: string,
|
||||
): {sourceFile: SourceFile; exportedName: string} | null {
|
||||
for (const declaration of sourceFile.getImportDeclarations()) {
|
||||
for (const named of declaration.getNamedImports()) {
|
||||
const localName = named.getAliasNode()?.getText() ?? named.getName();
|
||||
if (localName !== name) {
|
||||
continue;
|
||||
}
|
||||
const resolved = this.resolveModule(sourceFile, declaration.getModuleSpecifierValue());
|
||||
if (resolved == null) {
|
||||
return null;
|
||||
}
|
||||
return {sourceFile: resolved, exportedName: named.getName()};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveModule(from: SourceFile, specifier: string): SourceFile | null {
|
||||
if (!specifier.startsWith('.')) {
|
||||
return null;
|
||||
}
|
||||
const base = path.resolve(path.dirname(from.getFilePath()), specifier);
|
||||
for (const candidate of [`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, base]) {
|
||||
const existing = this.project.getSourceFile(candidate);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
const added = this.project.addSourceFileAtPathIfExists(candidate);
|
||||
if (added != null) {
|
||||
return added;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private constantsOf(sourceFile: SourceFile): Map<string, Node> {
|
||||
const cached = this.moduleConstants.get(sourceFile);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
const constants = new Map<string, Node>();
|
||||
for (const statement of sourceFile.getVariableStatements()) {
|
||||
if (statement.getDeclarationKind() !== 'const') {
|
||||
continue;
|
||||
}
|
||||
for (const declaration of statement.getDeclarations()) {
|
||||
const nameNode = declaration.getNameNode();
|
||||
const initializer = declaration.getInitializer();
|
||||
if (Node.isIdentifier(nameNode) && initializer != null) {
|
||||
constants.set(nameNode.getText(), initializer);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.moduleConstants.set(sourceFile, constants);
|
||||
return constants;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {discoverControllerFiles, extractRoutesFromControllers} from '@fluxer/openapi/src/extractors/RouteExtractor';
|
||||
import {beforeAll, describe, expect, it} from 'vitest';
|
||||
|
||||
const API_PACKAGE_PATH = path.join(fileURLToPath(new URL('../../../../../', import.meta.url)), 'fluxer_api');
|
||||
|
||||
describe('discoverControllerFiles', () => {
|
||||
let files: Array<string>;
|
||||
let shapes: Set<string>;
|
||||
beforeAll(() => {
|
||||
files = discoverControllerFiles(API_PACKAGE_PATH);
|
||||
shapes = new Set(extractRoutesFromControllers(files).map((route) => `${route.method.toUpperCase()} ${route.path}`));
|
||||
});
|
||||
it('leaves test sources out of the discovered set', () => {
|
||||
expect(files.filter((file) => file.endsWith('.test.ts'))).toEqual([]);
|
||||
expect(files.filter((file) => file.includes('/tests/'))).toEqual([]);
|
||||
});
|
||||
it('reads routes registered in a *Controller.ts file', () => {
|
||||
expect(shapes).toContain('GET /gifs/search');
|
||||
expect(shapes).toContain('GET /gifs/featured');
|
||||
});
|
||||
it('reads routes registered outside a *Controller.ts file', () => {
|
||||
expect(shapes).toContain('POST /webhooks/twilio/sms');
|
||||
expect(shapes).toContain('GET /_metrics');
|
||||
expect(shapes).toContain('GET /_health');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type {OpenAPIRouteScope} from '@fluxer/openapi/src/OpenAPIGenerationTypes';
|
||||
import type {OpenAPIGenerationStats, OpenAPIRouteScope, SkippedRoute} from '@fluxer/openapi/src/OpenAPIGenerationTypes';
|
||||
import {OpenAPIGenerator} from '@fluxer/openapi/src/OpenAPIGenerator';
|
||||
import {transformAdminOpenAPISpec} from '@fluxer/openapi/src/output/AdminSpecTransform';
|
||||
import {printValidationResult, validateSpec} from '@fluxer/openapi/src/output/SpecValidator';
|
||||
@@ -62,6 +62,21 @@ function getTargetOutputPath(basePath: string, target: GenerateTarget, customOut
|
||||
function getRouteScope(target: GenerateTarget): OpenAPIRouteScope {
|
||||
return target === 'admin' ? 'admin' : 'public';
|
||||
}
|
||||
function reportRoutesLeftOut(target: GenerateTarget, stats: OpenAPIGenerationStats): void {
|
||||
const groups: Array<[string, ReadonlyArray<SkippedRoute>]> = [
|
||||
['registered but not written to the spec', stats.skippedRoutes],
|
||||
['registered but not expressible as an OpenAPI path', stats.untemplatableRoutes],
|
||||
];
|
||||
for (const [title, routes] of groups) {
|
||||
if (routes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
console.log(`${target} routes ${title}: ${routes.length.toString()}`);
|
||||
for (const route of [...routes].sort((a, b) => `${a.path} ${a.method}`.localeCompare(`${b.path} ${b.method}`))) {
|
||||
console.log(` ${route.method} ${route.path} ${route.reason} (${route.source})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function buildTargetSpec(basePath: string, target: GenerateTarget): Promise<WritableOpenAPISpec> {
|
||||
const generator = new OpenAPIGenerator({
|
||||
basePath,
|
||||
@@ -71,11 +86,12 @@ async function buildTargetSpec(basePath: string, target: GenerateTarget): Promis
|
||||
serverUrl: 'https://api.fluxer.app/v1',
|
||||
routeScope: getRouteScope(target),
|
||||
});
|
||||
const spec = await generator.generate();
|
||||
const {document, stats} = await generator.generateWithStats();
|
||||
reportRoutesLeftOut(target, stats);
|
||||
if (target === 'admin') {
|
||||
return transformAdminOpenAPISpec(spec);
|
||||
return transformAdminOpenAPISpec(document);
|
||||
}
|
||||
return spec;
|
||||
return document;
|
||||
}
|
||||
function validateTargetSpec(target: GenerateTarget, spec: WritableOpenAPISpec): boolean {
|
||||
const validationResult = validateSpec(spec, {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
testTimeout: 60000,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user