fix(api): find every live route when generating openapi.json (#2484)

This commit is contained in:
Hampus
2026-09-06 14:54:21 +02:00
committed by GitHub
parent 8734956d86
commit ea93ef5352
10 changed files with 3065 additions and 166 deletions
+4 -1
View File
@@ -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;
+26 -5
View File
@@ -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(
+382 -33
View File
@@ -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');
});
});
+20 -4
View File
@@ -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, {
+28
View File
@@ -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/'],
},
},
});