next-openapi-gen
Version:
Automatically generate OpenAPI 3.0 documentation from Next.js projects, with support for Zod schemas and TypeScript types.
1,019 lines (1,018 loc) • 74.5 kB
JavaScript
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();
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);
// Create a map to store imported modules
const importedModules = {};
// Look for all exported Zod schemas
traverse(ast, {
// Track imports for resolving local and imported schemas
ImportDeclaration: (path) => {
// Keep track of imports to resolve external schemas
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;
}
});
},
// 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;
}
}
}
});
}
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;
}
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);
}
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}` }],
description: prop.value.arguments[0].value,
};
}
else {
properties[propName] = {
$ref: `#/components/schemas/${schemaName}`,
};
}
required.push(propName);
return;
}
// For other methods, process normally
const processedSchema = this.processZodNode(prop.value);
if (processedSchema) {
properties[propName] = processedSchema;
const isOptional = this.isOptional(prop.value) || this.hasOptionalMethod(prop.value);
if (!isOptional) {
required.push(propName);
}
}
return;
}
// Check if the property value is an identifier (reference to another schema)
if (t.isIdentifier(prop.value)) {
const referencedSchemaName = prop.value.name;
// Try to find and convert the referenced schema
if (!this.zodSchemas[referencedSchemaName]) {
this.convertZodSchemaToOpenApi(referencedSchemaName);
}
// Create a reference
properties[propName] = {
$ref: `#/components/schemas/${referencedSchemaName}`,
};
required.push(propName); // Assuming it's required unless marked optional
}
// For array of schemas (like z.array(PaymentMethodSchema))
if (t.isCallExpression(prop.value) &&
t.isMemberExpression(prop.value.callee) &&
t.isIdentifier(prop.value.callee.object) &&
prop.value.callee.object.name === "z" &&
t.isIdentifier(prop.value.callee.property) &&
prop.value.callee.property.name === "array" &&
prop.value.arguments.length > 0 &&
t.isIdentifier(prop.value.arguments[0])) {
const itemSchemaName = prop.value.arguments[0].name;
// Try to find and convert the referenced schema
if (!this.zodSchemas[itemSchemaName]) {
this.convertZodSchemaToOpenApi(itemSchemaName);
}
// Process as array with reference
const arraySchema = this.processZodNode(prop.value);
arraySchema.items = {
$ref: `#/components/schemas/${itemSchemaName}`,
};
properties[propName] = arraySchema;
const isOptional = this.isOptional(prop.value) || this.hasOptionalMethod(prop.value);
if (!isOptional) {
required.push(propName);
}
}
// Process property value (a Zod schema)
const propSchema = this.processZodNode(prop.value);
if (propSchema) {
properties[propName] = propSchema;
// If the property is not marked as optional, add it to required list
const isOptional =
// @ts-ignore
this.isOptional(prop.value) || this.hasOptionalMethod(prop.value);
if (!isOptional) {
required.push(propName);
}
}
}
});
const schema = {
type: "object",
properties,
};
if (required.length > 0) {
// @ts-ignore
schema.required = required;
}
return schema;
}
/**
* Process a Zod primitive schema: z.string(), z.number(), etc.
*/
processZodPrimitive(node) {
if (!t.isMemberExpression(node.callee) ||
!t.isIdentifier(node.callee.property)) {
return { type: "string" };
}
const zodType = node.callee.property.name;
let schema = {};
// Basic type mapping
switch (zodType) {
case "string":
schema = { type: "string" };
break;
case "number":
schema = { type: "number" };
break;
case "boolean":
schema = { type: "boolean" };
break;
case "date":
schema = { type: "string", format: "date-time" };
break;
case "bigint":
schema = { type: "integer", format: "int64" };
break;
case "any":
case "unknown":
schema = {}; // Empty schema matches anything
break;
case "null":
case "undefined":
schema = { type: "null" };
break;
case "array":
let itemsType = { type: "string" };
if (node.arguments.length > 0) {
// Check if argument is an identifier (schema reference)
if (t.isIdentifier(node.arguments[0])) {
const schemaName = node.arguments[0].name;
// Try to find and convert the referenced schema
if (!this.zodSchemas[schemaName]) {
this.convertZodSchemaToOpenApi(schemaName);
}
// @ts-ignore
itemsType = { $ref: `#/components/schemas/${schemaName}` };
}
else {
// @ts-ignore
itemsType = this.processZodNode(node.arguments[0]);
}
}
schema = { type: "array", items: itemsType };
break;
case "enum":
if (node.arguments.length > 0 &&
t.isArrayExpression(node.arguments[0])) {
const enumValues = node.arguments[0].elements
.filter((el) => t.isStringLiteral(el) || t.isNumericLiteral(el))
// @ts-ignore
.map((el) => el.value);
const firstValue = enumValues[0];
const valueType = typeof firstValue;
schema = {
type: valueType === "number" ? "number" : "string",
enum: enumValues,
};
}
else if (node.arguments.length > 0 &&
t.isObjectExpression(node.arguments[0])) {
// Handle z.enum({ KEY1: "value1", KEY2: "value2" })
const enumValues = [];
node.arguments[0].properties.forEach((prop) => {
if (t.isObjectProperty(prop) && t.isStringLiteral(prop.value)) {
enumValues.push(prop.value.value);
}
});
if (enumValues.length > 0) {
schema = {
type: "string",
enum: enumValues,
};
}
else {
schema = { type: "string" };
}
}
else {
schema = { type: "string" };
}
break;
case "record":
let valueType = { type: "string" };
if (node.arguments.length > 0) {
valueType = this.processZodNode(node.arguments[0]);
}
schema = {
type: "object",
additionalProperties: valueType,
};
break;