UNPKG

next-openapi-gen

Version:

Automatically generate OpenAPI 3.0 documentation from Next.js projects, with support for Zod schemas and TypeScript types.

959 lines (958 loc) 102 kB
import fs from "fs"; import path from "path"; import traverseModule from "@babel/traverse"; import * as t from "@babel/types"; // Handle both ES modules and CommonJS const traverse = traverseModule.default || traverseModule; import { parseTypeScriptFile } from "./utils.js"; import { logger } from "./logger.js"; import { DrizzleZodProcessor } from "./drizzle-zod-processor.js"; /** * Class for converting Zod schemas to OpenAPI specifications */ export class ZodSchemaConverter { schemaDir; zodSchemas = {}; processingSchemas = new Set(); processedModules = new Set(); typeToSchemaMapping = {}; drizzleZodImports = new Set(); factoryCache = new Map(); // Cache for analyzed factory functions factoryCheckCache = new Map(); // Cache for non-factory functions fileASTCache = new Map(); // Cache for parsed files fileImportsCache = new Map(); // Cache for file imports // Current processing context (set during file processing) currentFilePath; currentAST; currentImports; constructor(schemaDir) { this.schemaDir = path.resolve(schemaDir); } /** * Find a Zod schema by name and convert it to OpenAPI spec */ convertZodSchemaToOpenApi(schemaName) { // Run pre-scan only one time if (Object.keys(this.typeToSchemaMapping).length === 0) { this.preScanForTypeMappings(); } logger.debug(`Looking for Zod schema: ${schemaName}`); // Check mapped types const mappedSchemaName = this.typeToSchemaMapping[schemaName]; if (mappedSchemaName) { logger.debug(`Type '${schemaName}' is mapped to schema '${mappedSchemaName}'`); schemaName = mappedSchemaName; } // Check for circular references if (this.processingSchemas.has(schemaName)) { return { $ref: `#/components/schemas/${schemaName}` }; } // Add to processing set this.processingSchemas.add(schemaName); try { // Return cached schema if it exists if (this.zodSchemas[schemaName]) { return this.zodSchemas[schemaName]; } // Find all route files and process them first const routeFiles = this.findRouteFiles(); for (const routeFile of routeFiles) { this.processFileForZodSchema(routeFile, schemaName); if (this.zodSchemas[schemaName]) { logger.debug(`Found Zod schema '${schemaName}' in route file: ${routeFile}`); return this.zodSchemas[schemaName]; } } // Scan schema directory this.scanDirectoryForZodSchema(this.schemaDir, schemaName); // Return the schema if found, or null if not if (this.zodSchemas[schemaName]) { logger.debug(`Found and processed Zod schema: ${schemaName}`); return this.zodSchemas[schemaName]; } logger.debug(`Could not find Zod schema: ${schemaName}`); return null; } finally { // Remove from processing set this.processingSchemas.delete(schemaName); } } /** * Find all route files in the project */ findRouteFiles() { const routeFiles = []; // Look for route files in common Next.js API directories const possibleApiDirs = [ path.join(process.cwd(), "src", "app", "api"), path.join(process.cwd(), "src", "pages", "api"), path.join(process.cwd(), "app", "api"), path.join(process.cwd(), "pages", "api"), ]; for (const dir of possibleApiDirs) { if (fs.existsSync(dir)) { this.findRouteFilesInDir(dir, routeFiles); } } return routeFiles; } /** * Recursively find route files in a directory */ findRouteFilesInDir(dir, routeFiles) { try { const files = fs.readdirSync(dir); for (const file of files) { const filePath = path.join(dir, file); const stats = fs.statSync(filePath); if (stats.isDirectory()) { this.findRouteFilesInDir(filePath, routeFiles); } else if (file === "route.ts" || file === "route.tsx" || (file.endsWith(".ts") && file.includes("api"))) { routeFiles.push(filePath); } } } catch (error) { logger.error(`Error scanning directory ${dir} for route files: ${error}`); } } /** * Recursively scan directory for Zod schemas */ scanDirectoryForZodSchema(dir, schemaName) { try { const files = fs.readdirSync(dir); for (const file of files) { const filePath = path.join(dir, file); const stats = fs.statSync(filePath); if (stats.isDirectory()) { this.scanDirectoryForZodSchema(filePath, schemaName); } else if (file.endsWith(".ts") || file.endsWith(".tsx")) { this.processFileForZodSchema(filePath, schemaName); } } } catch (error) { logger.error(`Error scanning directory ${dir}: ${error}`); } } /** * Process a file to find Zod schema definitions */ processFileForZodSchema(filePath, schemaName) { try { const content = fs.readFileSync(filePath, "utf-8"); // Check if file contains schema we are looking for if (!content.includes(schemaName)) { return; } // Pre-process all schemas in file this.preprocessAllSchemasInFile(filePath); // Return it, if the schema has already been processed during pre-processing if (this.zodSchemas[schemaName]) { return; } // Parse the file const ast = parseTypeScriptFile(content); // Cache AST for later use this.fileASTCache.set(filePath, ast); // Create a map to store imported modules let importedModules = {}; // Check if we have cached imports if (this.fileImportsCache.has(filePath)) { importedModules = this.fileImportsCache.get(filePath); } else { // Build imports cache traverse(ast, { ImportDeclaration: (path) => { const source = path.node.source.value; // Track drizzle-zod imports if (source === "drizzle-zod") { path.node.specifiers.forEach((specifier) => { if (t.isImportSpecifier(specifier) || t.isImportDefaultSpecifier(specifier)) { this.drizzleZodImports.add(specifier.local.name); } }); } // Process each import specifier path.node.specifiers.forEach((specifier) => { if (t.isImportSpecifier(specifier) || t.isImportDefaultSpecifier(specifier)) { const importedName = specifier.local.name; importedModules[importedName] = source; } }); }, }); // Cache imports for this file this.fileImportsCache.set(filePath, importedModules); } // Set current processing context for use by processZodNode during factory expansion this.currentFilePath = filePath; this.currentAST = ast; this.currentImports = importedModules; // Look for all exported Zod schemas traverse(ast, { // For export const SchemaName = z.object({...}) 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 this is a drizzle-zod helper function if (t.isCallExpression(declaration.init) && t.isIdentifier(declaration.init.callee) && this.drizzleZodImports.has(declaration.init.callee.name)) { const schema = this.processZodNode(declaration.init); if (schema) { this.zodSchemas[schemaName] = schema; } } // Check if this is a call expression with .extend() else if (t.isCallExpression(declaration.init) && t.isMemberExpression(declaration.init.callee) && t.isIdentifier(declaration.init.callee.property) && declaration.init.callee.property.name === "extend") { const schema = this.processZodNode(declaration.init); if (schema) { this.zodSchemas[schemaName] = schema; } } // Existing code for z.object({...}) else if (t.isCallExpression(declaration.init) && t.isMemberExpression(declaration.init.callee) && t.isIdentifier(declaration.init.callee.object) && declaration.init.callee.object.name === "z") { const schema = this.processZodNode(declaration.init); if (schema) { this.zodSchemas[schemaName] = schema; } } // Check if this is a factory function call else if (t.isCallExpression(declaration.init) && t.isIdentifier(declaration.init.callee)) { const factoryName = declaration.init.callee.name; logger.debug(`[Schema] Detected potential factory function call: ${factoryName} for schema ${schemaName}`); const factoryNode = this.findFactoryFunction(factoryName, filePath, ast, importedModules); if (factoryNode) { logger.debug(`[Schema] Found factory function, attempting to expand...`); const schema = this.expandFactoryCall(factoryNode, declaration.init, filePath); if (schema) { this.zodSchemas[schemaName] = schema; logger.debug(`[Schema] Successfully expanded factory function '${factoryName}' for schema '${schemaName}'`); } else { logger.debug(`[Schema] Failed to expand factory function '${factoryName}'`); } } else { logger.debug(`[Schema] Could not find factory function '${factoryName}'`); } } } }); } else if (t.isTSTypeAliasDeclaration(path.node.declaration)) { // Handle export type aliases with z schema definitions if (t.isIdentifier(path.node.declaration.id) && path.node.declaration.id.name === schemaName) { const typeAnnotation = path.node.declaration.typeAnnotation; // Check if this is a reference to a z schema (e.g., export type UserSchema = z.infer<typeof UserSchema>) if (t.isTSTypeReference(typeAnnotation) && t.isIdentifier(typeAnnotation.typeName) && typeAnnotation.typeName.name === "z.infer") { // Extract the schema name from z.infer<typeof SchemaName> if (typeAnnotation.typeParameters && typeAnnotation.typeParameters.params.length > 0 && t.isTSTypeReference(typeAnnotation.typeParameters.params[0]) && t.isTSTypeQuery(typeAnnotation.typeParameters.params[0].typeName) && t.isIdentifier( // @ts-ignore typeAnnotation.typeParameters.params[0].typeName.exprName)) { const referencedSchema = // @ts-ignore typeAnnotation.typeParameters.params[0].typeName.exprName .name; // Look for the referenced schema in the same file if (!this.zodSchemas[referencedSchema]) { this.processFileForZodSchema(filePath, referencedSchema); } // Use the referenced schema for this type alias if (this.zodSchemas[referencedSchema]) { this.zodSchemas[schemaName] = this.zodSchemas[referencedSchema]; } } } } } }, // For const SchemaName = z.object({...}) VariableDeclarator: (path) => { if (t.isIdentifier(path.node.id) && path.node.id.name === schemaName && path.node.init) { // Check if this is any Zod schema (including chained calls) if (this.isZodSchema(path.node.init)) { const schema = this.processZodNode(path.node.init); if (schema) { this.zodSchemas[schemaName] = schema; } return; } // Helper function for processing the call chain const processChainedCall = (node, baseSchema) => { if (!t.isCallExpression(node) || !t.isMemberExpression(node.callee)) { return baseSchema; } // @ts-ignore const methodName = node.callee.property.name; let schema = baseSchema; // If there is an even deeper call, process it first if (t.isCallExpression(node.callee.object)) { schema = processChainedCall(node.callee.object, baseSchema); } // Now apply the current method switch (methodName) { case "omit": if (node.arguments.length > 0 && t.isObjectExpression(node.arguments[0])) { node.arguments[0].properties.forEach((prop) => { if (t.isObjectProperty(prop) && t.isBooleanLiteral(prop.value) && prop.value.value === true) { const key = t.isIdentifier(prop.key) ? prop.key.name : t.isStringLiteral(prop.key) ? prop.key.value : null; if (key && schema.properties) { logger.debug(`Removing property: ${key}`); delete schema.properties[key]; if (schema.required) { schema.required = schema.required.filter((r) => r !== key); } } } }); } break; case "partial": // All fields become optional if (schema.properties) { Object.keys(schema.properties).forEach((key) => { schema.properties[key].nullable = true; }); // Remove all required delete schema.required; } break; case "pick": if (node.arguments.length > 0 && t.isObjectExpression(node.arguments[0])) { const keysToPick = []; node.arguments[0].properties.forEach((prop) => { if (t.isObjectProperty(prop) && t.isBooleanLiteral(prop.value) && prop.value.value === true) { const key = t.isIdentifier(prop.key) ? prop.key.name : t.isStringLiteral(prop.key) ? prop.key.value : null; if (key) keysToPick.push(key); } }); // Keep only selected properties if (schema.properties) { const newProperties = {}; keysToPick.forEach((key) => { if (schema.properties[key]) { newProperties[key] = schema.properties[key]; } }); schema.properties = newProperties; // Update required if (schema.required) { schema.required = schema.required.filter((key) => keysToPick.includes(key)); } } } break; case "required": // All fields become required if (schema.properties) { const requiredFields = Object.keys(schema.properties); schema.required = requiredFields; // Remove nullable from fields Object.keys(schema.properties).forEach((key) => { delete schema.properties[key].nullable; }); } break; case "extend": // Extend the schema with new properties if (node.arguments.length > 0 && t.isObjectExpression(node.arguments[0])) { const extensionProperties = {}; const extensionRequired = []; node.arguments[0].properties.forEach((prop) => { if (t.isObjectProperty(prop)) { const key = t.isIdentifier(prop.key) ? prop.key.name : t.isStringLiteral(prop.key) ? prop.key.value : null; if (key) { // Process the Zod type for this property const propSchema = this.processZodNode(prop.value); if (propSchema) { extensionProperties[key] = propSchema; // Check if the schema itself has nullable set (which processZodNode sets for optional fields) const isOptional = propSchema.nullable === true; if (!isOptional) { extensionRequired.push(key); } } } } }); // Merge with existing schema if (schema.properties) { schema.properties = { ...schema.properties, ...extensionProperties, }; } else { schema.properties = extensionProperties; } // Merge required arrays if (extensionRequired.length > 0) { schema.required = [ ...(schema.required || []), ...extensionRequired, ]; // Deduplicate schema.required = [...new Set(schema.required)]; } } break; } return schema; }; // Find the underlying schema (the most nested object in the chain) const findBaseSchema = (node) => { if (t.isIdentifier(node)) { return node.name; } else if (t.isMemberExpression(node)) { return findBaseSchema(node.object); } else if (t.isCallExpression(node) && t.isMemberExpression(node.callee)) { return findBaseSchema(node.callee.object); } return null; }; // Check method calls on other schemas if (t.isCallExpression(path.node.init)) { const baseSchemaName = findBaseSchema(path.node.init); if (baseSchemaName && baseSchemaName !== "z") { logger.debug(`Found chained call starting from: ${baseSchemaName}`); // First make sure the underlying schema is processed if (!this.zodSchemas[baseSchemaName]) { logger.debug(`Base schema ${baseSchemaName} not found, processing it first`); this.processFileForZodSchema(filePath, baseSchemaName); } if (this.zodSchemas[baseSchemaName]) { logger.debug("Base schema found, applying transformations"); // Copy base schema const baseSchema = JSON.parse(JSON.stringify(this.zodSchemas[baseSchemaName])); // Process the entire call chain const finalSchema = processChainedCall(path.node.init, baseSchema); this.zodSchemas[schemaName] = finalSchema; logger.debug(`Created ${schemaName} with properties: ${Object.keys(finalSchema.properties || {})}`); return; } } } // Check if it is .extend() if (t.isCallExpression(path.node.init) && t.isMemberExpression(path.node.init.callee) && t.isIdentifier(path.node.init.callee.property) && path.node.init.callee.property.name === "extend") { const schema = this.processZodNode(path.node.init); if (schema) { this.zodSchemas[schemaName] = schema; } } // Existing code else { const schema = this.processZodNode(path.node.init); if (schema) { this.zodSchemas[schemaName] = schema; } } } }, // For type aliases that reference Zod schemas TSTypeAliasDeclaration: (path) => { if (t.isIdentifier(path.node.id)) { const typeName = path.node.id.name; if (t.isTSTypeReference(path.node.typeAnnotation) && t.isTSQualifiedName(path.node.typeAnnotation.typeName) && t.isIdentifier(path.node.typeAnnotation.typeName.left) && path.node.typeAnnotation.typeName.left.name === "z" && t.isIdentifier(path.node.typeAnnotation.typeName.right) && path.node.typeAnnotation.typeName.right.name === "infer") { // Extract schema name from z.infer<typeof SchemaName> if (path.node.typeAnnotation.typeParameters && path.node.typeAnnotation.typeParameters.params.length > 0) { const param = path.node.typeAnnotation.typeParameters.params[0]; if (t.isTSTypeQuery(param) && t.isIdentifier(param.exprName)) { const referencedSchemaName = param.exprName.name; // Save mapping: TypeName -> SchemaName this.typeToSchemaMapping[typeName] = referencedSchemaName; logger.debug(`Mapped type '${typeName}' to schema '${referencedSchemaName}'`); // Process the referenced schema if not already processed if (!this.zodSchemas[referencedSchemaName]) { this.processFileForZodSchema(filePath, referencedSchemaName); } // Use the referenced schema for this type if (this.zodSchemas[referencedSchemaName]) { this.zodSchemas[typeName] = this.zodSchemas[referencedSchemaName]; } } } } if (path.node.id.name === schemaName) { // Try to find if this is a z.infer<typeof SchemaName> pattern if (t.isTSTypeReference(path.node.typeAnnotation) && t.isIdentifier(path.node.typeAnnotation.typeName) && path.node.typeAnnotation.typeName.name === "infer" && path.node.typeAnnotation.typeParameters && path.node.typeAnnotation.typeParameters.params.length > 0) { const param = path.node.typeAnnotation.typeParameters.params[0]; if (t.isTSTypeQuery(param) && t.isIdentifier(param.exprName)) { const referencedSchemaName = param.exprName.name; // Find the referenced schema this.processFileForZodSchema(filePath, referencedSchemaName); if (this.zodSchemas[referencedSchemaName]) { this.zodSchemas[schemaName] = this.zodSchemas[referencedSchemaName]; } } } } } }, }); } catch (error) { logger.error(`Error processing file ${filePath} for schema ${schemaName}: ${error}`); } } /** * Process all exported schemas in a file, not just the one we're looking for */ processAllSchemasInFile(filePath) { try { const content = fs.readFileSync(filePath, "utf-8"); const ast = parseTypeScriptFile(content); traverse(ast, { ExportNamedDeclaration: (path) => { if (t.isVariableDeclaration(path.node.declaration)) { path.node.declaration.declarations.forEach((declaration) => { if (t.isIdentifier(declaration.id) && declaration.init && t.isCallExpression(declaration.init) && t.isMemberExpression(declaration.init.callee) && t.isIdentifier(declaration.init.callee.object) && declaration.init.callee.object.name === "z") { const schemaName = declaration.id.name; if (!this.zodSchemas[schemaName] && !this.processingSchemas.has(schemaName)) { this.processingSchemas.add(schemaName); const schema = this.processZodNode(declaration.init); if (schema) { this.zodSchemas[schemaName] = schema; } this.processingSchemas.delete(schemaName); } } }); } }, }); } catch (error) { logger.error(`Error processing all schemas in file ${filePath}: ${error}`); } } /** * Process a Zod node and convert it to OpenAPI schema */ processZodNode(node) { // Handle drizzle-zod helper functions (e.g., createInsertSchema, createSelectSchema) if (t.isCallExpression(node) && t.isIdentifier(node.callee) && this.drizzleZodImports.has(node.callee.name)) { return DrizzleZodProcessor.processSchema(node); } // Handle reference to another schema (e.g. UserBaseSchema.extend) if (t.isCallExpression(node) && t.isMemberExpression(node.callee) && t.isIdentifier(node.callee.object) && t.isIdentifier(node.callee.property) && node.callee.property.name === "extend") { const baseSchemaName = node.callee.object.name; // Check if the base schema already exists if (!this.zodSchemas[baseSchemaName]) { // Try to find the basic pattern this.convertZodSchemaToOpenApi(baseSchemaName); } return this.processZodChain(node); } // Handle z.coerce.TYPE() patterns if (t.isCallExpression(node) && t.isMemberExpression(node.callee) && t.isMemberExpression(node.callee.object) && t.isIdentifier(node.callee.object.object) && node.callee.object.object.name === "z" && t.isIdentifier(node.callee.object.property) && node.callee.object.property.name === "coerce" && t.isIdentifier(node.callee.property)) { const coerceType = node.callee.property.name; // Create a synthetic node for the underlying type using Babel types const syntheticNode = t.callExpression(t.memberExpression(t.identifier("z"), t.identifier(coerceType)), []); return this.processZodPrimitive(syntheticNode); } // Handle z.object({...}) if (t.isCallExpression(node) && t.isMemberExpression(node.callee) && t.isIdentifier(node.callee.object) && node.callee.object.name === "z" && t.isIdentifier(node.callee.property)) { const methodName = node.callee.property.name; if (methodName === "object" && node.arguments.length > 0) { return this.processZodObject(node); } else if (methodName === "union" && node.arguments.length > 0) { return this.processZodUnion(node); } else if (methodName === "intersection" && node.arguments.length > 0) { return this.processZodIntersection(node); } else if (methodName === "tuple" && node.arguments.length > 0) { return this.processZodTuple(node); } else if (methodName === "discriminatedUnion" && node.arguments.length > 1) { return this.processZodDiscriminatedUnion(node); } else if (methodName === "literal" && node.arguments.length > 0) { return this.processZodLiteral(node); } else { return this.processZodPrimitive(node); } } // Handle schema reference with method calls, e.g., Image.optional(), UserSchema.nullable() if (t.isCallExpression(node) && t.isMemberExpression(node.callee) && t.isIdentifier(node.callee.object) && t.isIdentifier(node.callee.property) && node.callee.object.name !== "z" // Make sure it's not a z.* call ) { const schemaName = node.callee.object.name; const methodName = node.callee.property.name; // Process base schema first if not already processed if (!this.zodSchemas[schemaName]) { this.convertZodSchemaToOpenApi(schemaName); } // If the schema exists, create a reference and apply the method if (this.zodSchemas[schemaName]) { let schema = { allOf: [{ $ref: `#/components/schemas/${schemaName}` }], }; // Apply method-specific transformations switch (methodName) { case "optional": case "nullable": case "nullish": // Don't add nullable flag here as it would be at the wrong level // The fact that it's optional is handled by not including it in required array break; case "describe": if (node.arguments.length > 0 && t.isStringLiteral(node.arguments[0])) { schema.description = node.arguments[0].value; } break; default: // For other methods, process as a chain return this.processZodChain(node); } return schema; } } // Handle chained methods, e.g., z.string().email().min(5) if (t.isCallExpression(node) && t.isMemberExpression(node.callee) && t.isCallExpression(node.callee.object)) { return this.processZodChain(node); } // Handle schema references like z.lazy(() => AnotherSchema) if (t.isCallExpression(node) && t.isMemberExpression(node.callee) && t.isIdentifier(node.callee.object) && node.callee.object.name === "z" && t.isIdentifier(node.callee.property) && node.callee.property.name === "lazy" && node.arguments.length > 0) { return this.processZodLazy(node); } // Handle potential factory function calls (e.g., createPaginatedSchema(UserSchema)) // This must be checked before falling back to "Unknown Zod schema node" if (t.isCallExpression(node) && t.isIdentifier(node.callee)) { logger.debug(`[processZodNode] Attempting to handle potential factory function: ${node.callee.name}`); // We need the current file context - try to get it from the processing context // Note: This is a limitation - we may not have file context during preprocessing // In that case, we'll return a placeholder and let the main processing handle it const currentFilePath = this.currentFilePath; const currentAST = this.currentAST; const importedModules = this.currentImports; if (currentFilePath && currentAST && importedModules) { const factoryNode = this.findFactoryFunction(node.callee.name, currentFilePath, currentAST, importedModules); if (factoryNode) { logger.debug(`[processZodNode] Found factory function, expanding...`); const schema = this.expandFactoryCall(factoryNode, node, currentFilePath); if (schema) { logger.debug(`[processZodNode] Successfully expanded factory function '${node.callee.name}'`); return schema; } } } logger.debug(`[processZodNode] Could not expand factory function '${node.callee.name}' - missing context or not a factory`); } // Handle standalone identifier references (e.g., userSchema used directly) if (t.isIdentifier(node)) { const schemaName = node.name; // Try to find and process the referenced schema if (!this.zodSchemas[schemaName]) { this.convertZodSchemaToOpenApi(schemaName); } // Return a reference to the schema return { $ref: `#/components/schemas/${schemaName}` }; } logger.debug("Unknown Zod schema node:", node); return { type: "object" }; } /** * Process a Zod lazy schema: z.lazy(() => Schema) */ processZodLazy(node) { // Get the function in z.lazy(() => Schema) if (node.arguments.length > 0 && t.isArrowFunctionExpression(node.arguments[0]) && node.arguments[0].body) { const returnExpr = node.arguments[0].body; // If the function returns an identifier, it's likely a reference to another schema if (t.isIdentifier(returnExpr)) { const schemaName = returnExpr.name; // Create a reference to the schema return { $ref: `#/components/schemas/${schemaName}` }; } // If the function returns a complex expression, try to process it return this.processZodNode(returnExpr); } return { type: "object" }; } /** * Process a Zod literal schema: z.literal("value") */ processZodLiteral(node) { if (node.arguments.length === 0) { return { type: "string" }; } const arg = node.arguments[0]; if (t.isStringLiteral(arg)) { return { type: "string", enum: [arg.value], }; } else if (t.isNumericLiteral(arg)) { return { type: "number", enum: [arg.value], }; } else if (t.isBooleanLiteral(arg)) { return { type: "boolean", enum: [arg.value], }; } return { type: "string" }; } /** * Process a Zod discriminated union: z.discriminatedUnion("type", [schema1, schema2]) */ processZodDiscriminatedUnion(node) { if (node.arguments.length < 2) { return { type: "object" }; } // Get the discriminator field name let discriminator = ""; if (t.isStringLiteral(node.arguments[0])) { discriminator = node.arguments[0].value; } // Get the schemas array const schemasArray = node.arguments[1]; if (!t.isArrayExpression(schemasArray)) { return { type: "object" }; } const schemas = schemasArray.elements .map((element) => this.processZodNode(element)) .filter((schema) => schema !== null); if (schemas.length === 0) { return { type: "object" }; } // Create a discriminated mapping for oneOf return { type: "object", discriminator: discriminator ? { propertyName: discriminator, } : undefined, oneOf: schemas, }; } /** * Process a Zod tuple schema: z.tuple([z.string(), z.number()]) */ processZodTuple(node) { if (node.arguments.length === 0 || !t.isArrayExpression(node.arguments[0])) { return { type: "array", items: { type: "string" } }; } const tupleItems = node.arguments[0].elements.map((element) => this.processZodNode(element)); // In OpenAPI, we can represent this as an array with prefixItems (OpenAPI 3.1+) // For OpenAPI 3.0.x, we'll use items with type: array return { type: "array", items: tupleItems.length > 0 ? tupleItems[0] : { type: "string" }, // For OpenAPI 3.1+: prefixItems: tupleItems }; } /** * Process a Zod intersection schema: z.intersection(schema1, schema2) */ processZodIntersection(node) { if (node.arguments.length < 2) { return { type: "object" }; } const schema1 = this.processZodNode(node.arguments[0]); const schema2 = this.processZodNode(node.arguments[1]); // In OpenAPI, we can use allOf to represent intersection return { allOf: [schema1, schema2], }; } /** * Process a Zod union schema: z.union([schema1, schema2]) */ processZodUnion(node) { if (node.arguments.length === 0 || !t.isArrayExpression(node.arguments[0])) { return { type: "object" }; } const unionItems = node.arguments[0].elements.map((element) => this.processZodNode(element)); // Check for common pattern: z.union([z.string(), z.null()]) which should be nullable string if (unionItems.length === 2) { const isNullable = unionItems.some((item) => item.type === "null" || (item.enum && item.enum.length === 1 && item.enum[0] === null)); if (isNullable) { const nonNullItem = unionItems.find((item) => item.type !== "null" && !(item.enum && item.enum.length === 1 && item.enum[0] === null)); if (nonNullItem) { return { ...nonNullItem, nullable: true, }; } } } // Check if all union items are of the same type with different enum values // This is common for string literals like: z.union([z.literal("a"), z.literal("b")]) const allSameType = unionItems.length > 0 && unionItems.every((item) => item.type === unionItems[0].type && item.enum); if (allSameType) { // Combine all enum values const combinedEnums = unionItems.flatMap((item) => item.enum || []); return { type: unionItems[0].type, enum: combinedEnums, }; } // Otherwise, use oneOf for general unions return { oneOf: unionItems, }; } /** * Process a Zod object schema: z.object({...}) */ processZodObject(node) { if (node.arguments.length === 0 || !t.isObjectExpression(node.arguments[0])) { return { type: "object" }; } const objectExpression = node.arguments[0]; const properties = {}; const required = []; objectExpression.properties.forEach((prop, index) => { if (t.isObjectProperty(prop)) { let propName; // Handle both identifier and string literal keys if (t.isIdentifier(prop.key)) { propName = prop.key.name; } else if (t.isStringLiteral(prop.key)) { propName = prop.key.value; } else { logger.debug(`Skipping property ${index} - unsupported key type`); return; // Skip if key is not identifier or string literal } if (t.isCallExpression(prop.value) && t.isMemberExpression(prop.value.callee) && t.isIdentifier(prop.value.callee.object)) { const schemaName = prop.value.callee.object.name; // @ts-ignore const methodName = prop.value.callee.property.name; // Process base schema first if (!this.zodSchemas[schemaName]) { this.convertZodSchemaToOpenApi(schemaName); } // For describe method, use reference with description if (methodName === "describe" && this.zodSchemas[schemaName]) { if (prop.value.arguments.length > 0 && t.isStringLiteral(prop.value.arguments[0])) { properties[propName] = { allOf: [{ $ref: `#/components/schemas/${schemaName}` }],