next-openapi-gen
Version:
Automatically generate OpenAPI 3.0 documentation from Next.js projects, with support for Zod schemas and TypeScript types.
1,016 lines • 74.3 kB
JavaScript
import fs from "fs";
import path from "path";
import traverseModule from "@babel/traverse";
import * as t from "@babel/types";
import yaml from "js-yaml";
// Handle both ES modules and CommonJS
const traverse = traverseModule.default || traverseModule;
import { parseTypeScriptFile } from "./utils.js";
import { ZodSchemaConverter } from "./zod-converter.js";
import { logger } from "./logger.js";
/**
* Normalize schemaType to array
*/
function normalizeSchemaTypes(schemaType) {
return Array.isArray(schemaType) ? schemaType : [schemaType];
}
export class SchemaProcessor {
schemaDir;
typeDefinitions = {};
openapiDefinitions = {};
contentType = "";
customSchemas = {};
directoryCache = {};
statCache = {};
processSchemaTracker = {};
processingTypes = new Set();
zodSchemaConverter = null;
schemaTypes;
isResolvingPickOmitBase = false;
// Track imports per file for resolving ReturnType<typeof func>
importMap = {}; // { filePath: { importName: importPath } }
currentFilePath = ""; // Track the file being processed
constructor(schemaDir, schemaType = "typescript", schemaFiles) {
this.schemaDir = path.resolve(schemaDir);
this.schemaTypes = normalizeSchemaTypes(schemaType);
// Initialize Zod converter if Zod is enabled
if (this.schemaTypes.includes("zod")) {
this.zodSchemaConverter = new ZodSchemaConverter(schemaDir);
}
// Load custom schema files if provided
if (schemaFiles && schemaFiles.length > 0) {
this.loadCustomSchemas(schemaFiles);
}
}
/**
* Load custom OpenAPI schema files (YAML/JSON)
*/
loadCustomSchemas(schemaFiles) {
for (const filePath of schemaFiles) {
try {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
logger.warn(`Schema file not found: ${filePath}`);
continue;
}
const content = fs.readFileSync(resolvedPath, "utf-8");
const ext = path.extname(filePath).toLowerCase();
let parsed;
if (ext === ".yaml" || ext === ".yml") {
parsed = yaml.load(content);
}
else if (ext === ".json") {
parsed = JSON.parse(content);
}
else {
logger.warn(`Unsupported file type: ${filePath} (use .json, .yaml, or .yml)`);
continue;
}
// Extract schemas from OpenAPI structure or use file content directly
const schemas = parsed?.components?.schemas || parsed?.schemas || parsed;
if (typeof schemas === "object" && schemas !== null) {
Object.assign(this.customSchemas, schemas);
logger.log(`✓ Loaded custom schemas from: ${filePath}`);
}
else {
logger.warn(`No valid schemas found in ${filePath}. Expected OpenAPI format with components.schemas or plain object.`);
}
}
catch (error) {
logger.warn(`Failed to load schema file ${filePath}: ${error.message}`);
}
}
}
/**
* Get all defined schemas (for components.schemas section)
* Merges schemas from all sources with proper priority:
* 1. TypeScript types (lowest priority - base layer)
* 2. Zod schemas (medium priority)
* 3. Custom files (highest priority - overrides all)
*/
getDefinedSchemas() {
const merged = {};
// Layer 1: TypeScript types (base layer)
const filteredSchemas = {};
Object.entries(this.openapiDefinitions).forEach(([key, value]) => {
if (!this.isGenericTypeParameter(key) &&
!this.isInvalidSchemaName(key) &&
!this.isBuiltInUtilityType(key) &&
!this.isFunctionSchema(key)) {
filteredSchemas[key] = value;
}
});
Object.assign(merged, filteredSchemas);
// Layer 2: Zod schemas (if enabled - overrides TypeScript)
if (this.schemaTypes.includes("zod") && this.zodSchemaConverter) {
const zodSchemas = this.zodSchemaConverter.getProcessedSchemas();
Object.assign(merged, zodSchemas);
}
// Layer 3: Custom files (highest priority - overrides all)
Object.assign(merged, this.customSchemas);
return merged;
}
findSchemaDefinition(schemaName, contentType) {
// Assign type that is actually processed
this.contentType = contentType;
// Check if the schemaName is a generic type (contains < and >)
if (schemaName.includes("<") && schemaName.includes(">")) {
return this.resolveGenericTypeFromString(schemaName);
}
// Priority 1: Check custom schemas first (highest priority)
if (this.customSchemas[schemaName]) {
logger.debug(`Found schema in custom files: ${schemaName}`);
return this.customSchemas[schemaName];
}
// Priority 2: Try Zod schemas if enabled
if (this.schemaTypes.includes("zod") && this.zodSchemaConverter) {
logger.debug(`Looking for Zod schema: ${schemaName}`);
// Check type mapping first
const mappedSchemaName = this.zodSchemaConverter.typeToSchemaMapping[schemaName];
if (mappedSchemaName) {
logger.debug(`Type '${schemaName}' is mapped to Zod schema '${mappedSchemaName}'`);
}
// Try to convert Zod schema
const zodSchema = this.zodSchemaConverter.convertZodSchemaToOpenApi(schemaName);
if (zodSchema) {
logger.debug(`Found and processed Zod schema: ${schemaName}`);
this.openapiDefinitions[schemaName] = zodSchema;
return zodSchema;
}
logger.debug(`No Zod schema found for ${schemaName}, trying TypeScript fallback`);
}
// Fall back to TypeScript types
this.scanSchemaDir(this.schemaDir, schemaName);
return this.openapiDefinitions[schemaName] || {};
}
scanSchemaDir(dir, schemaName) {
let files = this.directoryCache[dir];
if (typeof files === "undefined") {
files = fs.readdirSync(dir);
this.directoryCache[dir] = files;
}
files.forEach((file) => {
const filePath = path.join(dir, file);
let stat = this.statCache[filePath];
if (typeof stat === "undefined") {
stat = fs.statSync(filePath);
this.statCache[filePath] = stat;
}
if (stat.isDirectory()) {
this.scanSchemaDir(filePath, schemaName);
}
else if (file.endsWith(".ts") || file.endsWith(".tsx")) {
this.processSchemaFile(filePath, schemaName);
}
});
}
collectImports(ast, filePath) {
// Normalize path to avoid Windows/Unix path separator issues
const normalizedPath = path.normalize(filePath);
if (!this.importMap[normalizedPath]) {
this.importMap[normalizedPath] = {};
}
traverse(ast, {
ImportDeclaration: (path) => {
const importPath = path.node.source.value;
// Handle named imports: import { foo, bar } from './file'
path.node.specifiers.forEach((specifier) => {
if (t.isImportSpecifier(specifier)) {
const importedName = t.isIdentifier(specifier.imported) ? specifier.imported.name : specifier.imported.value;
this.importMap[normalizedPath][importedName] = importPath;
}
// Handle default imports: import foo from './file'
else if (t.isImportDefaultSpecifier(specifier)) {
const importedName = specifier.local.name;
this.importMap[normalizedPath][importedName] = importPath;
}
// Handle namespace imports: import * as foo from './file'
else if (t.isImportNamespaceSpecifier(specifier)) {
const importedName = specifier.local.name;
this.importMap[normalizedPath][importedName] = importPath;
}
});
},
});
}
/**
* Resolve an import path relative to the current file
* Converts import paths like "../app/api/products/route.utils" to absolute file paths
*/
resolveImportPath(importPath, fromFilePath) {
// Skip node_modules imports
if (!importPath.startsWith('.')) {
return null;
}
const fromDir = path.dirname(fromFilePath);
let resolvedPath = path.resolve(fromDir, importPath);
// Try with .ts extension
if (fs.existsSync(resolvedPath + '.ts')) {
return resolvedPath + '.ts';
}
// Try with .tsx extension
if (fs.existsSync(resolvedPath + '.tsx')) {
return resolvedPath + '.tsx';
}
// Try as-is (might already have extension)
if (fs.existsSync(resolvedPath)) {
return resolvedPath;
}
return null;
}
/**
* Collect all exported type definitions from an AST without filtering by name
* Used when processing imported files to ensure all referenced types are available
*/
collectAllExportedDefinitions(ast, filePath) {
const currentFile = filePath || this.currentFilePath;
traverse(ast, {
TSTypeAliasDeclaration: (path) => {
if (path.node.id && t.isIdentifier(path.node.id)) {
const name = path.node.id.name;
if (!this.typeDefinitions[name]) {
const node = (path.node.typeParameters && path.node.typeParameters.params.length > 0)
? path.node
: path.node.typeAnnotation;
this.typeDefinitions[name] = { node, filePath: currentFile };
}
}
},
TSInterfaceDeclaration: (path) => {
if (path.node.id && t.isIdentifier(path.node.id)) {
const name = path.node.id.name;
if (!this.typeDefinitions[name]) {
this.typeDefinitions[name] = { node: path.node, filePath: currentFile };
}
}
},
TSEnumDeclaration: (path) => {
if (path.node.id && t.isIdentifier(path.node.id)) {
const name = path.node.id.name;
if (!this.typeDefinitions[name]) {
this.typeDefinitions[name] = { node: path.node, filePath: currentFile };
}
}
},
ExportNamedDeclaration: (path) => {
// Handle exported interfaces
if (t.isTSInterfaceDeclaration(path.node.declaration)) {
const interfaceDecl = path.node.declaration;
if (interfaceDecl.id && t.isIdentifier(interfaceDecl.id)) {
const name = interfaceDecl.id.name;
if (!this.typeDefinitions[name]) {
this.typeDefinitions[name] = { node: interfaceDecl, filePath: currentFile };
}
}
}
// Handle exported type aliases
if (t.isTSTypeAliasDeclaration(path.node.declaration)) {
const typeDecl = path.node.declaration;
if (typeDecl.id && t.isIdentifier(typeDecl.id)) {
const name = typeDecl.id.name;
if (!this.typeDefinitions[name]) {
const node = (typeDecl.typeParameters && typeDecl.typeParameters.params.length > 0)
? typeDecl
: typeDecl.typeAnnotation;
this.typeDefinitions[name] = { node, filePath: currentFile };
}
}
}
},
});
}
collectTypeDefinitions(ast, schemaName, filePath) {
const currentFile = filePath || this.currentFilePath;
traverse(ast, {
VariableDeclarator: (path) => {
if (t.isIdentifier(path.node.id, { name: schemaName })) {
const name = path.node.id.name;
this.typeDefinitions[name] = { node: path.node.init || path.node, filePath: currentFile };
}
},
TSTypeAliasDeclaration: (path) => {
if (t.isIdentifier(path.node.id, { name: schemaName })) {
const name = path.node.id.name;
// Store the full node for generic types, just the type annotation for regular types
const node = (path.node.typeParameters && path.node.typeParameters.params.length > 0)
? path.node // Store the full declaration for generic types
: path.node.typeAnnotation; // Store just the type annotation for regular types
this.typeDefinitions[name] = { node, filePath: currentFile };
}
},
TSInterfaceDeclaration: (path) => {
if (t.isIdentifier(path.node.id, { name: schemaName })) {
const name = path.node.id.name;
this.typeDefinitions[name] = { node: path.node, filePath: currentFile };
}
},
TSEnumDeclaration: (path) => {
if (t.isIdentifier(path.node.id, { name: schemaName })) {
const name = path.node.id.name;
this.typeDefinitions[name] = { node: path.node, filePath: currentFile };
}
},
// Collect function declarations for ReturnType<typeof func> support
FunctionDeclaration: (path) => {
if (path.node.id && t.isIdentifier(path.node.id, { name: schemaName })) {
const name = path.node.id.name;
this.typeDefinitions[name] = { node: path.node, filePath: currentFile };
}
},
// Collect exported zod schemas and functions
ExportNamedDeclaration: (path) => {
if (t.isVariableDeclaration(path.node.declaration)) {
path.node.declaration.declarations.forEach((declaration) => {
if (t.isIdentifier(declaration.id) &&
declaration.id.name === schemaName &&
declaration.init) {
// Check if is Zod schema
if (t.isCallExpression(declaration.init) &&
t.isMemberExpression(declaration.init.callee) &&
t.isIdentifier(declaration.init.callee.object) &&
declaration.init.callee.object.name === "z") {
const name = declaration.id.name;
this.typeDefinitions[name] = { node: declaration.init, filePath: currentFile };
}
}
});
}
// Handle exported function declarations
if (t.isFunctionDeclaration(path.node.declaration)) {
const funcDecl = path.node.declaration;
if (funcDecl.id && t.isIdentifier(funcDecl.id, { name: schemaName })) {
const name = funcDecl.id.name;
this.typeDefinitions[name] = { node: funcDecl, filePath: currentFile };
}
}
},
});
}
resolveType(typeName) {
if (this.processingTypes.has(typeName)) {
// Return reference to type to avoid infinite recursion
return { $ref: `#/components/schemas/${typeName}` };
}
// Add type to processing types
this.processingTypes.add(typeName);
try {
// If we are using Zod and the given type is not found yet, try using Zod converter first
if (this.schemaTypes.includes("zod") &&
!this.openapiDefinitions[typeName]) {
const zodSchema = this.zodSchemaConverter.convertZodSchemaToOpenApi(typeName);
if (zodSchema) {
this.openapiDefinitions[typeName] = zodSchema;
return zodSchema;
}
}
const typeDefEntry = this.typeDefinitions[typeName.toString()];
if (!typeDefEntry)
return {};
const typeNode = typeDefEntry.node || typeDefEntry; // Support both old and new format
// Handle generic type alias declarations (full node)
if (t.isTSTypeAliasDeclaration(typeNode)) {
// This is a generic type, should be handled by the caller via resolveGenericType
// For non-generic access, just return the type annotation
const typeAnnotation = typeNode.typeAnnotation;
return this.resolveTSNodeType(typeAnnotation);
}
// Check if node is Zod
if (t.isCallExpression(typeNode) &&
t.isMemberExpression(typeNode.callee) &&
t.isIdentifier(typeNode.callee.object) &&
typeNode.callee.object.name === "z") {
if (this.schemaTypes.includes("zod")) {
const zodSchema = this.zodSchemaConverter.processZodNode(typeNode);
if (zodSchema) {
this.openapiDefinitions[typeName] = zodSchema;
return zodSchema;
}
}
}
if (t.isTSEnumDeclaration(typeNode)) {
const enumValues = this.processEnum(typeNode);
return enumValues;
}
if (t.isTSTypeLiteral(typeNode) ||
t.isTSInterfaceBody(typeNode) ||
t.isTSInterfaceDeclaration(typeNode)) {
const properties = {};
// Handle interface extends clause
if (t.isTSInterfaceDeclaration(typeNode) &&
typeNode.extends &&
typeNode.extends.length > 0) {
typeNode.extends.forEach((extendedType) => {
const extendedSchema = this.resolveTSNodeType(extendedType);
if (extendedSchema.properties) {
Object.assign(properties, extendedSchema.properties);
}
});
}
// Get members from interface declaration body or direct members
const members = t.isTSInterfaceDeclaration(typeNode)
? typeNode.body.body
: typeNode.members;
if (members) {
(members || []).forEach((member) => {
if (t.isTSPropertySignature(member) && t.isIdentifier(member.key)) {
const propName = member.key.name;
const options = this.getPropertyOptions(member);
const property = {
...this.resolveTSNodeType(member.typeAnnotation?.typeAnnotation),
...options,
};
properties[propName] = property;
}
});
}
return { type: "object", properties };
}
if (t.isTSArrayType(typeNode)) {
return {
type: "array",
items: this.resolveTSNodeType(typeNode.elementType),
};
}
if (t.isTSUnionType(typeNode)) {
return this.resolveTSNodeType(typeNode);
}
if (t.isTSTypeReference(typeNode)) {
return this.resolveTSNodeType(typeNode);
}
// Handle indexed access types (e.g., Parameters<typeof func>[0])
if (t.isTSIndexedAccessType(typeNode)) {
return this.resolveTSNodeType(typeNode);
}
return {};
}
finally {
// Remove type from processed set after we finish
this.processingTypes.delete(typeName);
}
}
isDateString(node) {
if (t.isStringLiteral(node)) {
const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z)?$/;
return dateRegex.test(node.value);
}
return false;
}
isDateObject(node) {
return (t.isNewExpression(node) && t.isIdentifier(node.callee, { name: "Date" }));
}
isDateNode(node) {
return this.isDateString(node) || this.isDateObject(node);
}
resolveTSNodeType(node) {
if (!node)
return { type: "object" }; // Default type for undefined/null
if (t.isTSStringKeyword(node))
return { type: "string" };
if (t.isTSNumberKeyword(node))
return { type: "number" };
if (t.isTSBooleanKeyword(node))
return { type: "boolean" };
if (t.isTSAnyKeyword(node) || t.isTSUnknownKeyword(node))
return { type: "object" };
if (t.isTSVoidKeyword(node) ||
t.isTSNullKeyword(node) ||
t.isTSUndefinedKeyword(node))
return { type: "null" };
if (this.isDateNode(node))
return { type: "string", format: "date-time" };
// Handle literal types like "admin" | "member" | "guest"
if (t.isTSLiteralType(node)) {
if (t.isStringLiteral(node.literal)) {
return {
type: "string",
enum: [node.literal.value],
};
}
else if (t.isNumericLiteral(node.literal)) {
return {
type: "number",
enum: [node.literal.value],
};
}
else if (t.isBooleanLiteral(node.literal)) {
return {
type: "boolean",
enum: [node.literal.value],
};
}
}
// Handle TSExpressionWithTypeArguments (used in interface extends)
if (t.isTSExpressionWithTypeArguments(node)) {
if (t.isIdentifier(node.expression)) {
// Convert to TSTypeReference-like structure for processing
const syntheticNode = {
type: "TSTypeReference",
typeName: node.expression,
typeParameters: node.typeParameters,
};
return this.resolveTSNodeType(syntheticNode);
}
}
// Handle indexed access types: SomeType[0] or SomeType["key"]
if (t.isTSIndexedAccessType(node)) {
const objectType = this.resolveTSNodeType(node.objectType);
const indexType = node.indexType;
// Handle numeric index: Parameters<typeof func>[0]
if (t.isTSLiteralType(indexType) && t.isNumericLiteral(indexType.literal)) {
const index = indexType.literal.value;
// If objectType is a tuple (has prefixItems), get the specific item
if (objectType.prefixItems && Array.isArray(objectType.prefixItems)) {
if (index < objectType.prefixItems.length) {
return objectType.prefixItems[index];
}
else {
logger.warn(`Index ${index} is out of bounds for tuple type.`);
return { type: "object" };
}
}
// If objectType is a regular array, return the items type
if (objectType.type === "array" && objectType.items) {
return objectType.items;
}
}
// Handle string index: SomeType["propertyName"]
if (t.isTSLiteralType(indexType) && t.isStringLiteral(indexType.literal)) {
const key = indexType.literal.value;
// If objectType has properties, get the specific property
if (objectType.properties && objectType.properties[key]) {
return objectType.properties[key];
}
}
// Fallback
return { type: "object" };
}
if (t.isTSTypeReference(node) && t.isIdentifier(node.typeName)) {
const typeName = node.typeName.name;
// Special handling for built-in types
if (typeName === "Date") {
return { type: "string", format: "date-time" };
}
// Handle Promise<T> - in OpenAPI, promises are transparent (we document the resolved value)
if (typeName === "Promise") {
if (node.typeParameters && node.typeParameters.params.length > 0) {
// Return the inner type directly - promises are async wrappers
return this.resolveTSNodeType(node.typeParameters.params[0]);
}
return { type: "object" }; // Promise with no type parameter
}
if (typeName === "Array" || typeName === "ReadonlyArray") {
if (node.typeParameters && node.typeParameters.params.length > 0) {
return {
type: "array",
items: this.resolveTSNodeType(node.typeParameters.params[0]),
};
}
return { type: "array", items: { type: "object" } };
}
if (typeName === "Record") {
if (node.typeParameters && node.typeParameters.params.length > 1) {
const keyType = this.resolveTSNodeType(node.typeParameters.params[0]);
const valueType = this.resolveTSNodeType(node.typeParameters.params[1]);
return {
type: "object",
additionalProperties: valueType,
};
}
return { type: "object", additionalProperties: true };
}
if (typeName === "Partial" ||
typeName === "Required" ||
typeName === "Readonly") {
if (node.typeParameters && node.typeParameters.params.length > 0) {
return this.resolveTSNodeType(node.typeParameters.params[0]);
}
}
// Handle Awaited<T> utility type
if (typeName === "Awaited") {
if (node.typeParameters && node.typeParameters.params.length > 0) {
// Unwrap the inner type - promises are transparent in OpenAPI
return this.resolveTSNodeType(node.typeParameters.params[0]);
}
}
// Handle ReturnType<typeof X> utility type
if (typeName === "ReturnType") {
if (node.typeParameters && node.typeParameters.params.length > 0) {
const typeParam = node.typeParameters.params[0];
// ReturnType<typeof functionName>
if (t.isTSTypeQuery(typeParam)) {
const funcName = t.isIdentifier(typeParam.exprName)
? typeParam.exprName.name
: null;
if (funcName) {
// Save current file path before findSchemaDefinition which may change it
const savedFilePath = this.currentFilePath;
// First try to find the function in the current file
this.findSchemaDefinition(funcName, this.contentType);
let funcDefEntry = this.typeDefinitions[funcName];
let funcNode = funcDefEntry?.node || funcDefEntry; // Support both old and new format
const funcFilePath = funcDefEntry?.filePath;
// If not found, check if it's an imported function
// Use the saved file path (where the utility type is defined)
const sourceFilePath = savedFilePath;
const normalizedSourcePath = path.normalize(sourceFilePath);
if (!funcNode && sourceFilePath && this.importMap[normalizedSourcePath]) {
const importPath = this.importMap[normalizedSourcePath][funcName];
if (importPath) {
// Resolve the import path to an absolute file path
const resolvedPath = this.resolveImportPath(importPath, sourceFilePath);
if (resolvedPath) {
// Process the imported file to collect the function
const content = fs.readFileSync(resolvedPath, "utf-8");
const ast = parseTypeScriptFile(content);
// Collect imports and type definitions from the imported file
this.collectImports(ast, resolvedPath);
this.collectTypeDefinitions(ast, funcName, resolvedPath);
// Also collect all exported types/interfaces from the same file
// This ensures referenced types like Product are available
this.collectAllExportedDefinitions(ast, resolvedPath);
// Now try to get the function node again
funcDefEntry = this.typeDefinitions[funcName];
funcNode = funcDefEntry?.node || funcDefEntry;
}
}
}
if (funcNode) {
// Extract the return type annotation
const returnTypeNode = this.extractFunctionReturnType(funcNode);
if (returnTypeNode) {
// Recursively resolve the return type
return this.resolveTSNodeType(returnTypeNode);
}
else {
logger.warn(`ReturnType<typeof ${funcName}>: Function '${funcName}' does not have an explicit return type annotation. ` +
`Add a return type to the function signature for accurate schema generation.`);
return { type: "object" };
}
}
else {
logger.warn(`ReturnType<typeof ${funcName}>: Function '${funcName}' not found in schema files or imports. ` +
`Ensure the function is exported and imported correctly.`);
return { type: "object" };
}
}
}
// Fallback: If not TSTypeQuery, try resolving directly
logger.warn(`ReturnType<T>: Expected 'typeof functionName' but got a different type. ` +
`Use ReturnType<typeof yourFunction> pattern for best results.`);
return this.resolveTSNodeType(typeParam);
}
}
// Handle Parameters<typeof X> utility type
if (typeName === "Parameters") {
if (node.typeParameters && node.typeParameters.params.length > 0) {
const typeParam = node.typeParameters.params[0];
// Parameters<typeof functionName>
if (t.isTSTypeQuery(typeParam)) {
const funcName = t.isIdentifier(typeParam.exprName)
? typeParam.exprName.name
: null;
if (funcName) {
// Save current file path before findSchemaDefinition which may change it
const savedFilePath = this.currentFilePath;
// First try to find the function in the current file
this.findSchemaDefinition(funcName, this.contentType);
let funcDefEntry = this.typeDefinitions[funcName];
let funcNode = funcDefEntry?.node || funcDefEntry; // Support both old and new format
const funcFilePath = funcDefEntry?.filePath;
// If not found, check if it's an imported function
// Use the saved file path (where the utility type is defined)
const sourceFilePath = savedFilePath;
const normalizedSourcePath = path.normalize(sourceFilePath);
if (!funcNode && sourceFilePath && this.importMap[normalizedSourcePath]) {
const importPath = this.importMap[normalizedSourcePath][funcName];
if (importPath) {
// Resolve the import path to an absolute file path
const resolvedPath = this.resolveImportPath(importPath, sourceFilePath);
if (resolvedPath) {
// Process the imported file to collect the function
const content = fs.readFileSync(resolvedPath, "utf-8");
const ast = parseTypeScriptFile(content);
// Collect imports and type definitions from the imported file
this.collectImports(ast, resolvedPath);
this.collectTypeDefinitions(ast, funcName, resolvedPath);
// Also collect all exported types/interfaces from the same file
// This ensures referenced types like Product are available
this.collectAllExportedDefinitions(ast, resolvedPath);
// Now try to get the function node again
funcDefEntry = this.typeDefinitions[funcName];
funcNode = funcDefEntry?.node || funcDefEntry;
}
}
}
if (funcNode) {
// Extract parameters from function
const params = this.extractFunctionParameters(funcNode);
if (params && params.length > 0) {
// Parameters<T> returns a tuple type [Param1, Param2, ...]
const paramTypes = params.map((param) => {
if (param.typeAnnotation &&
param.typeAnnotation.typeAnnotation) {
return this.resolveTSNodeType(param.typeAnnotation.typeAnnotation);
}
return { type: "any" };
});
// Return as tuple (array with prefixItems for OpenAPI 3.1)
return {
type: "array",
prefixItems: paramTypes,
items: false,
minItems: paramTypes.length,
maxItems: paramTypes.length,
};
}
else {
// No parameters
return {
type: "array",
maxItems: 0,
};
}
}
else {
logger.warn(`Parameters<typeof ${funcName}>: Function '${funcName}' not found in schema files or imports.`);
return { type: "array", items: { type: "object" } };
}
}
}
}
}
if (typeName === "Pick" || typeName === "Omit") {
if (node.typeParameters && node.typeParameters.params.length > 1) {
const baseTypeParam = node.typeParameters.params[0];
const keysParam = node.typeParameters.params[1];
// Resolve base type without adding it to schema definitions
this.isResolvingPickOmitBase = true;
const baseType = this.resolveTSNodeType(baseTypeParam);
this.isResolvingPickOmitBase = false;
if (baseType.properties) {
const properties = {};
const keyNames = this.extractKeysFromLiteralType(keysParam);
if (typeName === "Pick") {
keyNames.forEach((key) => {
if (baseType.properties[key]) {
properties[key] = baseType.properties[key];
}
});
}
else {
// Omit
Object.entries(baseType.properties).forEach(([key, value]) => {
if (!keyNames.includes(key)) {
properties[key] = value;
}
});
}
return { type: "object", properties };
}
}
// Fallback to just the base type if we can't process properly
if (node.typeParameters && node.typeParameters.params.length > 0) {
return this.resolveTSNodeType(node.typeParameters.params[0]);
}
}
// Handle custom generic types
if (node.typeParameters && node.typeParameters.params.length > 0) {
// Find the generic type definition first
this.findSchemaDefinition(typeName, this.contentType);
const genericDefEntry = this.typeDefinitions[typeName];
const genericTypeDefinition = genericDefEntry?.node || genericDefEntry;
if (genericTypeDefinition) {
// Resolve the generic type by substituting type parameters
return this.resolveGenericType(genericTypeDefinition, node.typeParameters.params, typeName);
}
}
// Check if it is a type that we are already processing
if (this.processingTypes.has(typeName)) {
return { $ref: `#/components/schemas/${typeName}` };
}
// Find type definition
this.findSchemaDefinition(typeName, this.contentType);
return this.resolveType(node.typeName.name);
}
if (t.isTSArrayType(node)) {
return {
type: "array",
items: this.resolveTSNodeType(node.elementType),
};
}
if (t.isTSTypeLiteral(node)) {
const properties = {};
node.members.forEach((member) => {
if (t.isTSPropertySignature(member) && t.isIdentifier(member.key)) {
const propName = member.key.name;
properties[propName] = this.resolveTSNodeType(member.typeAnnotation?.typeAnnotation);
}
});
return { type: "object", properties };
}
if (t.isTSUnionType(node)) {
// Handle union types with literal types, like "admin" | "member" | "guest"
const literals = node.types.filter((type) => t.isTSLiteralType(type));
// Check if all union elements are literals
if (literals.length === node.types.length) {
// All union members are literals, convert to enum
const enumValues = literals
.map((type) => {
if (t.isTSLiteralType(type) && t.isStringLiteral(type.literal)) {
return type.literal.value;
}
else if (t.isTSLiteralType(type) &&
t.isNumericLiteral(type.literal)) {
return type.literal.value;
}
else if (t.isTSLiteralType(type) &&
t.isBooleanLiteral(type.literal)) {
return type.literal.value;
}
return null;
})
.filter((value) => value !== null);
if (enumValues.length > 0) {
// Check if all enum values are of the same type
const firstType = typeof enumValues[0];
const sameType = enumValues.every((val) => typeof val === firstType);
if (sameType) {
return {
type: firstType,
enum: enumValues,
};
}
}
}
// Handling null | undefined in type union
const nullableTypes = node.types.filter((type) => t.isTSNullKeyword(type) ||
t.isTSUndefinedKeyword(type) ||
t.isTSVoidKeyword(type));
const nonNullableTypes = node.types.filter((type) => !t.isTSNullKeyword(type) &&
!t.isTSUndefinedKeyword(type) &&
!t.isTSVoidKeyword(type));
// If a type can be null/undefined, we mark it as nullable
if (nullableTypes.length > 0 && nonNullableTypes.length === 1) {
const mainType = this.resolveTSNodeType(nonNullableTypes[0]);
return {
...mainType,
nullable: true,
};
}
// Standard union type support via oneOf
return {
oneOf: node.types
.filter((type) => !t.isTSNullKeyword(type) &&
!t.isTSUndefinedKeyword(type) &&
!t.isTSVoidKeyword(type))
.map((subNode) => this.resolveTSNodeType(subNode)),
};
}
if (t.isTSIntersectionType(node)) {
// For intersection types, we combine properties
const allProperties = {};
const requiredProperties = [];
node.types.forEach((typeNode) => {
const resolvedType = this.resolveTSNodeType(typeNode);
if (resolvedType.type === "object" && resolvedType.properties) {
Object.entries(resolvedType.properties).forEach(([key, value]) => {
allProperties[key] = value;
if (value.required) {
requiredProperties.push(key);
}
});
}
});
return {
type: "object",
properties: allProperties,
required: requiredProperties.length > 0 ? requiredProperties : undefined,
};
}
// Case where a type is a reference to another defined type
if (t.isTSTypeReference(node) && t.isIdentifier(node.typeName)) {
return { $ref: `#/components/schemas/${node.typeName.name}` };
}
logger.debug("Unrecognized TypeScript type node:", node);
return { type: "object" }; // By default we return an object
}
processSchemaFile(filePath, schemaName) {
// Check if the file has already been processed
if (this.processSchemaTracker[`${filePath}-${schemaName}`])
return;
try {
// Recognizes different elements of TS like variable, type, interface, enum
const content = fs.readFileSync(filePath, "utf-8");
const ast = parseTypeScriptFile(content);
// Track current file path for import resolution (normalize for consistency)
this.currentFilePath = path.normalize(filePath);
// Collect imports from this file
this.collectImports(ast, filePath);
// Collect type definitions, passing the file path explicitly
this.collectTypeDefinitions(ast, schemaName, filePath);
// Reset the set of processed types before each schema processing
this.processingTypes.clear();
const definition = this.resolveType(schemaName);
if (!this.isResolvingPickOmitBase) {
this.openapiDefinitions[schemaName] = definition;
}
this.processSchemaTracker[`${filePath}-${schemaName}`] = true;
return definition;
}
catch (error) {
logger.error(`Error processing schema file ${filePath} for schema ${schemaName}: ${error}`);
return { type: "object" }; // By default we return an empty object on error
}
}
processEnum(enumNode) {
// Initialization OpenAPI enum object
const enumSchema = {
type: "string",
enum: [],
};
// Iterate throught enum members
enumNode.members.forEach((member) => {
if (t.isTSEnumMember(member)) {
// @ts-ignore
const name = member.id?.name;
// @ts-ignore
const value = member.initializer?.value;
let type = member.initializer?.type;
if (type === "NumericLiteral") {
enumSchema.type = "number";
}
const targetValue = value || name;
if (enumSchema.enum) {
enumSchema.enum.push(targetValue);
}
}
});
return enumSchema;
}
extractKeysFromLiteralType(node) {
if (t.isTSLiteralType(node) && t.isStringLiteral(node.literal)) {
return [node.literal.value];
}
if (t.isTSUnionType(node)) {
const keys = [];
node.types.forEach((type) => {
if (t.isTSLiteralType(type) && t.isStringLiteral(type.literal)) {
keys.push(type.literal.value);
}
});
return keys;
}
return [];
}
getPropertyOptions(node) {
const isOptional = !!node.optional; // check if property is optional
let description = null;
// get comments for field
if (node.trailingComments && node.trailingComments.length) {
description = node.trailingComments[0].value.trim(); // get first comment
}
const options = {};
if (description) {
options.description = description;
}
if (this.contentType === "body") {
options.nullable = isOptional;
}
return options;
}
/**
* Generate example values based on parameter type and name
*/
getExampleForParam(paramName, type = "string") {
// Common ID-like parameters
if (paramName === "id" ||
paramName.endsWith("Id") ||
paramName.endsWith("_id")) {
return type === "string" ? "123" : 123;
}
// For specific common parameter names
switch (paramName.toLowerCase()) {
case "slug":
return "slug";
case "uuid":
return "123e4567-e89b-12d3-a456-426614174000";
case "username":
return "johndoe";
case "email":
return "user@example.com";
case "name":
return "name";
case "date":
return "2023-01-01";
case "page":
return 1;
case "role":
return "admin";
default:
// Default examples by type
if (type === "string")
return "example";
if (type === "number")
return 1;
if (type === "boolean")
return