UNPKG

next-openapi-gen

Version:

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

772 lines (771 loc) 31.7 kB
import fs from "fs"; import path from "path"; import traverse from "@babel/traverse"; import * as t from "@babel/types"; import { parseTypeScriptFile } from "./utils.js"; import { ZodSchemaConverter } from "./zod-converter.js"; import { logger } from "./logger.js"; export class SchemaProcessor { schemaDir; typeDefinitions = {}; openapiDefinitions = {}; contentType = ""; directoryCache = {}; statCache = {}; processSchemaTracker = {}; processingTypes = new Set(); zodSchemaConverter; schemaType; isResolvingPickOmitBase = false; constructor(schemaDir, schemaType = "typescript") { this.schemaDir = path.resolve(schemaDir); this.schemaType = schemaType; if (schemaType === "zod") { this.zodSchemaConverter = new ZodSchemaConverter(schemaDir); } } /** * Get all defined schemas (for components.schemas section) */ getDefinedSchemas() { // If using Zod, also include all processed Zod schemas if (this.schemaType === "zod" && this.zodSchemaConverter) { const zodSchemas = this.zodSchemaConverter.getProcessedSchemas(); return { ...this.openapiDefinitions, ...zodSchemas, }; } return this.openapiDefinitions; } findSchemaDefinition(schemaName, contentType) { let schemaNode = null; // Assign type that is actually processed this.contentType = contentType; // Check if we should use Zod schemas if (this.schemaType === "zod") { 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 schemaNode; } 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); } }); } collectTypeDefinitions(ast, schemaName) { traverse.default(ast, { VariableDeclarator: (path) => { if (t.isIdentifier(path.node.id, { name: schemaName })) { const name = path.node.id.name; this.typeDefinitions[name] = path.node.init || path.node; } }, TSTypeAliasDeclaration: (path) => { if (t.isIdentifier(path.node.id, { name: schemaName })) { const name = path.node.id.name; this.typeDefinitions[name] = path.node.typeAnnotation; } }, TSInterfaceDeclaration: (path) => { if (t.isIdentifier(path.node.id, { name: schemaName })) { const name = path.node.id.name; this.typeDefinitions[name] = path.node; } }, TSEnumDeclaration: (path) => { if (t.isIdentifier(path.node.id, { name: schemaName })) { const name = path.node.id.name; this.typeDefinitions[name] = path.node; } }, // Collect exported zod schemas 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] = declaration.init; } } }); } }, }); } 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.schemaType === "zod" && !this.openapiDefinitions[typeName]) { const zodSchema = this.zodSchemaConverter.convertZodSchemaToOpenApi(typeName); if (zodSchema) { this.openapiDefinitions[typeName] = zodSchema; return zodSchema; } } const typeNode = this.typeDefinitions[typeName.toString()]; if (!typeNode) return {}; // 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.schemaType === "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); } 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); } } 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" }; } 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]); } } 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]); } } // 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); this.collectTypeDefinitions(ast, schemaName); // 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 true; return "example"; } } detectContentType(bodyType, explicitContentType) { if (explicitContentType) { return explicitContentType; } // Automatic detection based on type name if (bodyType && (bodyType.toLowerCase().includes("formdata") || bodyType.toLowerCase().includes("fileupload") || bodyType.toLowerCase().includes("multipart"))) { return "multipart/form-data"; } return "application/json"; } createMultipleResponsesSchema(responses, defaultDescription) { const result = {}; Object.entries(responses).forEach(([code, response]) => { if (typeof response === "string") { // Reference do components/responses result[code] = { $ref: `#/components/responses/${response}` }; } else { result[code] = { description: response.description || defaultDescription || "Response", content: { "application/json": { schema: response.schema || response, }, }, }; } }); return result; } createFormDataSchema(body) { if (!body.properties) { return body; } const formDataProperties = {}; Object.entries(body.properties).forEach(([key, value]) => { // Convert File types to binary format if (value.type === "object" && (key.toLowerCase().includes("file") || value.description?.toLowerCase().includes("file"))) { formDataProperties[key] = { type: "string", format: "binary", description: value.description, }; } else { formDataProperties[key] = value; } }); return { ...body, properties: formDataProperties, }; } /** * Create a default schema for path parameters when no schema is defined */ createDefaultPathParamsSchema(paramNames) { return paramNames.map((paramName) => { // Guess the parameter type based on the name let type = "string"; if (paramName === "id" || paramName.endsWith("Id") || paramName === "page" || paramName === "limit" || paramName === "size" || paramName === "count") { type = "number"; } const example = this.getExampleForParam(paramName, type); return { name: paramName, in: "path", required: true, schema: { type: type, }, example: example, description: `Path parameter: ${paramName}`, }; }); } createRequestParamsSchema(params, isPathParam = false) { const queryParams = []; if (params.properties) { for (let [name, value] of Object.entries(params.properties)) { const param = { in: isPathParam ? "path" : "query", name, schema: { type: value.type, }, required: isPathParam ? true : !!value.required, // Path parameters are always required }; if (value.enum) { param.schema.enum = value.enum; } if (value.description) { param.description = value.description; param.schema.description = value.description; } // Add examples for path parameters if (isPathParam) { const example = this.getExampleForParam(name, value.type); param.example = example; } queryParams.push(param); } } return queryParams; } createRequestBodySchema(body, description, contentType) { const detectedContentType = this.detectContentType(body?.type || "", contentType); let schema = body; // If it is multipart/form-data, convert schema if (detectedContentType === "multipart/form-data") { schema = this.createFormDataSchema(body); } const requestBody = { content: { [detectedContentType]: { schema: schema, }, }, }; if (description) { requestBody.description = description; } return requestBody; } createResponseSchema(responses, description) { return { 200: { description: description || "Successful response", content: { "application/json": { schema: responses, }, }, }, }; } getSchemaContent({ tag, paramsType, pathParamsType, bodyType, responseType, }) { let params = paramsType ? this.openapiDefinitions[paramsType] : {}; let pathParams = pathParamsType ? this.openapiDefinitions[pathParamsType] : {}; let body = bodyType ? this.openapiDefinitions[bodyType] : {}; let responses = responseType ? this.openapiDefinitions[responseType] : {}; if (paramsType && !params) { this.findSchemaDefinition(paramsType, "params"); params = this.openapiDefinitions[paramsType] || {}; } if (pathParamsType && !pathParams) { this.findSchemaDefinition(pathParamsType, "pathParams"); pathParams = this.openapiDefinitions[pathParamsType] || {}; } if (bodyType && !body) { this.findSchemaDefinition(bodyType, "body"); body = this.openapiDefinitions[bodyType] || {}; } if (responseType && !responses) { this.findSchemaDefinition(responseType, "response"); responses = this.openapiDefinitions[responseType] || {}; } if (this.schemaType === "zod") { const schemasToProcess = [ paramsType, pathParamsType, bodyType, responseType, ].filter(Boolean); schemasToProcess.forEach((schemaName) => { if (!this.openapiDefinitions[schemaName]) { this.findSchemaDefinition(schemaName, ""); } }); } return { tag, params, pathParams, body, responses, }; } }