UNPKG

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
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