import fs from 'node:fs/promises' import path from 'node:path' import prettier from 'prettier' const DEFAULT_SHARED_IMPORT = './shared' const DEFAULT_SHARED_FILE_NAME = 'shared.js' const DEFAULT_INDEX_FILE_NAME = 'index.js' const AUTO_GENERATED_BANNER = '// Auto-generated. Do not edit manually.' const HTTP_METHOD_ORDER = ['get', 'post', 'put', 'delete', 'patch'] const BLOB_RESPONSE_TYPE = 'blob' const JS_RESERVED_WORDS = new Set([ 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'implements', 'import', 'in', 'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private', 'protected', 'public', 'return', 'super', 'switch', 'static', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', ]) export const generateApiFiles = async ({ projectRoot, swaggerUrl, swaggerTimeoutMs, outputDir, generateIndexFile: shouldGenerateIndexFile = true, requestImport, paramStyle, modules, cleanOutput, resolveDuplicateExports, }) => { const swagger = await fetchSwaggerJson(swaggerUrl, swaggerTimeoutMs) const moduleMap = buildModuleMap(swagger) const selectedModules = resolveSelectedModules(moduleMap, modules) const selectedModuleFileNames = new Set(selectedModules.map((moduleInfo) => `${moduleInfo.fileName}.js`)) const shouldRemoveStaleGeneratedFiles = cleanOutput && modules.length === 0 const retainedModuleExports = shouldGenerateIndexFile ? await readRetainedModuleExports({ outputDir, skipFileNames: selectedModuleFileNames, skipRemovableAutoGeneratedFiles: shouldRemoveStaleGeneratedFiles, }) : [] const reservedNamespaceFunctionNames = shouldGenerateIndexFile ? new Set( [ ...selectedModules.map((moduleInfo) => moduleInfo.fileName), ...retainedModuleExports.map((item) => item.fileName), ].map((fileName) => toCamelCase(fileName)), ) : new Set() for (const moduleInfo of selectedModules) { assignFunctionNames(moduleInfo.operations, reservedNamespaceFunctionNames) } if (shouldGenerateIndexFile) { const namespaceConflicts = findNamespaceExportConflicts([ ...buildPlannedModuleExports(selectedModules), ...retainedModuleExports, ]) if (namespaceConflicts.length) { throw createDuplicateModuleExportsError(namespaceConflicts, { reason: 'namespace-conflict', }) } const duplicateConflicts = findDuplicateModuleExports([ ...buildPlannedModuleExports(selectedModules), ...retainedModuleExports, ]) if (duplicateConflicts.length) { const resolution = await resolveDuplicateExports?.({ conflicts: duplicateConflicts, }) if (resolution?.action === 'rename') { const renameEntries = applyDuplicateExportRenameStrategy(selectedModules, duplicateConflicts) const remainingNamespaceConflicts = findNamespaceExportConflicts([ ...buildPlannedModuleExports(selectedModules), ...retainedModuleExports, ]) const remainingConflicts = findDuplicateModuleExports([ ...buildPlannedModuleExports(selectedModules), ...retainedModuleExports, ]) if (remainingNamespaceConflicts.length || remainingConflicts.length) { throw createDuplicateModuleExportsError(remainingConflicts, { reason: 'rename-failed', supplementalConflicts: remainingNamespaceConflicts, }) } if (renameEntries.length) { console.log('resolved duplicate exports by appending module names:') for (const renameEntry of renameEntries) { console.log( ` ${renameEntry.from} -> ${renameEntry.to} (${renameEntry.moduleName}${renameEntry.path ? ` | ${renameEntry.path}` : ''})`, ) } } } else { throw createDuplicateModuleExportsError(duplicateConflicts, { reason: resolution?.action === 'abort' ? 'user-aborted' : 'unresolved', }) } } } await fs.mkdir(outputDir, { recursive: true }) if (shouldRemoveStaleGeneratedFiles) { await cleanStaleGeneratedFiles({ outputDir, nextModuleFileNames: [...selectedModuleFileNames], projectRoot, }) } else if (cleanOutput && modules.length > 0) { console.log('clean skipped: module subset generation') } await writeGeneratedFile( path.join(outputDir, DEFAULT_SHARED_FILE_NAME), generateSharedFile({ swaggerUrl, }), ) for (const moduleInfo of selectedModules) { const filePath = path.join(outputDir, `${moduleInfo.fileName}.js`) await writeGeneratedFile( filePath, generateModuleFile({ moduleInfo, paramStyle, schemas: swagger.components?.schemas || {}, requestImport, swaggerUrl, }), ) console.log(`generated: ${path.relative(projectRoot, filePath)}`) } const indexFilePath = path.join(outputDir, DEFAULT_INDEX_FILE_NAME) if (shouldGenerateIndexFile) { await writeGeneratedFile( indexFilePath, await generateIndexFile({ outDir: outputDir, swaggerUrl, }), ) console.log(`generated: ${path.relative(projectRoot, indexFilePath)}`) } else { await removeAutoGeneratedIndexFile({ indexFilePath, projectRoot, }) } console.log(`done: ${selectedModules.length} module(s)`) } const fetchSwaggerJson = async (source, timeoutMs = 20_000) => { const normalizedSource = String(source || '').trim() if (!normalizedSource) { throw new Error('swaggerUrl is required') } if (/^https?:\/\//i.test(normalizedSource)) { return fetchSwaggerJsonFromHttp(normalizedSource, timeoutMs) } if (/^file:\/\//i.test(normalizedSource)) { return readSwaggerJsonFile(new URL(normalizedSource)) } return readSwaggerJsonFile(normalizedSource) } const fetchSwaggerJsonFromHttp = async (url, timeoutMs) => { let response try { response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), }) } catch (error) { if (error?.name === 'TimeoutError') { throw new Error(`Swagger fetch timed out after ${timeoutMs}ms: ${url}`) } throw new Error(`Swagger fetch failed: ${url}\n${error.message}`) } if (!response.ok) { throw new Error(`Swagger fetch failed: ${response.status} ${response.statusText} (${url})`) } try { return await response.json() } catch (error) { throw new Error(`Swagger response is not valid JSON: ${url}\n${error.message}`) } } const readSwaggerJsonFile = async (filePath) => { let content try { content = await fs.readFile(filePath, 'utf8') } catch (error) { throw new Error(`Swagger file read failed: ${String(filePath)}\n${error.message}`) } try { return JSON.parse(content) } catch (error) { throw new Error(`Swagger file is not valid JSON: ${String(filePath)}\n${error.message}`) } } const cleanStaleGeneratedFiles = async ({ outputDir, nextModuleFileNames, projectRoot }) => { const dirEntries = await fs.readdir(outputDir, { withFileTypes: true }) const preservedFileNames = new Set([ DEFAULT_INDEX_FILE_NAME, DEFAULT_SHARED_FILE_NAME, ...nextModuleFileNames, ]) for (const entry of dirEntries) { if (!entry.isFile() || !entry.name.endsWith('.js') || preservedFileNames.has(entry.name)) { continue } const filePath = path.join(outputDir, entry.name) const fileContent = await fs.readFile(filePath, 'utf8') if (!fileContent.startsWith(AUTO_GENERATED_BANNER)) { continue } await fs.rm(filePath) console.log(`removed: ${path.relative(projectRoot, filePath)}`) } } const removeAutoGeneratedIndexFile = async ({ indexFilePath, projectRoot }) => { try { const content = await fs.readFile(indexFilePath, 'utf8') if (!content.startsWith(AUTO_GENERATED_BANNER)) { return } await fs.rm(indexFilePath) console.log(`removed: ${path.relative(projectRoot, indexFilePath)}`) } catch (error) { if (error?.code === 'ENOENT') { return } throw error } } const readRetainedModuleExports = async ({ outputDir, skipFileNames = new Set(), skipRemovableAutoGeneratedFiles = false, }) => { try { const dirEntries = await fs.readdir(outputDir, { withFileTypes: true }) const moduleFiles = dirEntries.filter((entry) => { return ( entry.isFile() && entry.name.endsWith('.js') && entry.name !== DEFAULT_INDEX_FILE_NAME && entry.name !== DEFAULT_SHARED_FILE_NAME && !skipFileNames.has(entry.name) ) }) return Promise.all( moduleFiles.map(async (entry) => { const filePath = path.join(outputDir, entry.name) const content = await fs.readFile(filePath, 'utf8') if (skipRemovableAutoGeneratedFiles && content.startsWith(AUTO_GENERATED_BANNER)) { return null } const fileName = entry.name.replace(/\.js$/i, '') const moduleName = readGeneratedModuleName(content) || fileName return { fileName, isSelected: false, moduleName, occurrences: extractGeneratedModuleExportNames(content).map((exportName) => buildFlatExportOccurrence({ exportName, fileName, isSelected: false, moduleName, path: null, }), ), } }), ).then((items) => items.filter(Boolean)) } catch (error) { if (error?.code === 'ENOENT') { return [] } throw error } } const buildPlannedModuleExports = (selectedModules) => { return selectedModules.map((moduleInfo) => { return { fileName: moduleInfo.fileName, isSelected: true, moduleName: moduleInfo.moduleName, occurrences: moduleInfo.operations.map((operationItem) => buildFlatExportOccurrence({ exportName: `${operationItem.functionName}Api`, fileName: moduleInfo.fileName, isSelected: true, moduleName: moduleInfo.moduleName, path: operationItem.path, }), ), } }) } const buildModuleMap = (swagger) => { const moduleMap = new Map() const tagDescriptionMap = buildTagDescriptionMap(swagger.tags) for (const [apiPath, pathItem] of Object.entries(swagger.paths || {})) { for (const method of HTTP_METHOD_ORDER) { const operation = pathItem?.[method] if (!operation) { continue } const moduleName = extractModuleName(apiPath, operation) const moduleKey = normalizeLookupKey(moduleName) const aliases = new Set([moduleName, toKebabCase(moduleName), ...(operation.tags || [])]) const moduleDescription = resolveModuleDescription({ moduleName, operation, tagDescriptionMap, }) if (!moduleMap.has(moduleKey)) { moduleMap.set(moduleKey, { aliases, fileName: toKebabCase(moduleName), moduleDescription, moduleName, operations: [], }) } const moduleInfo = moduleMap.get(moduleKey) for (const alias of aliases) { moduleInfo.aliases.add(alias) } if (!moduleInfo.moduleDescription && moduleDescription) { moduleInfo.moduleDescription = moduleDescription } moduleInfo.operations.push({ method, operation, path: apiPath, }) } } for (const moduleInfo of moduleMap.values()) { moduleInfo.operations.sort(compareOperations) } return moduleMap } const resolveSelectedModules = (moduleMap, requestedModules) => { if (!requestedModules.length) { return [...moduleMap.values()] } const selectedModules = [] const selectedKeys = new Set() for (const requestedModule of requestedModules) { const lookupKey = normalizeLookupKey(requestedModule) let matchedEntry = null for (const [moduleKey, moduleInfo] of moduleMap.entries()) { if (moduleKey === lookupKey) { matchedEntry = moduleInfo break } if ([...moduleInfo.aliases].some((alias) => normalizeLookupKey(alias) === lookupKey)) { matchedEntry = moduleInfo break } } if (!matchedEntry) { const availableModules = [...moduleMap.values()] .map((item) => item.moduleName) .sort((left, right) => left.localeCompare(right)) .join(', ') throw new Error(`Module "${requestedModule}" not found. Available modules: ${availableModules}`) } const selectedKey = normalizeLookupKey(matchedEntry.moduleName) if (!selectedKeys.has(selectedKey)) { selectedModules.push(matchedEntry) selectedKeys.add(selectedKey) } } return selectedModules } const writeGeneratedFile = async (filePath, content) => { const prettierOptions = (await prettier.resolveConfig(filePath)) || {} const formattedContent = await prettier.format(content, { ...prettierOptions, filepath: filePath, }) await fs.writeFile(filePath, formattedContent, 'utf8') } const generateSharedFile = ({ swaggerUrl }) => { return `${AUTO_GENERATED_BANNER} // Swagger: ${swaggerUrl} // Shared helpers: URL builder const appendQueryEntries = (entries, key, value) => { if (value === undefined || value === null) { return } if (Array.isArray(value)) { for (const item of value) { appendQueryEntries(entries, key, item) } return } if (value instanceof Date) { entries.push([key, value.toISOString()]) return } if (Object.prototype.toString.call(value) === '[object Object]') { for (const [nestedKey, nestedValue] of Object.entries(value)) { appendQueryEntries(entries, \`\${key}[\${nestedKey}]\`, nestedValue) } return } entries.push([key, value]) } export const stringifyParams = (params = {}) => { const entries = [] for (const [key, value] of Object.entries(params)) { appendQueryEntries(entries, key, value) } return entries .map(([key, value]) => \`\${encodeURIComponent(key)}=\${encodeURIComponent(String(value))}\`) .join('&') } export const buildUrl = (url, params = {}) => { const pathParamNames = [] const resolvedUrl = url.replace(/\\{([^}]+)\\}/g, (_, key) => { pathParamNames.push(key) const value = params[key] if (value === undefined || value === null) { throw new Error(\`Missing path param: \${key}\`) } return encodeURIComponent(value) }) const query = stringifyParams( Object.fromEntries( Object.entries(params).filter( ([key, value]) => !pathParamNames.includes(key) && value !== undefined && value !== null, ), ), ) return query ? \`\${resolvedUrl}?\${query}\` : resolvedUrl } ` } const generateIndexFile = async ({ outDir, swaggerUrl }) => { const dirEntries = await fs.readdir(outDir, { withFileTypes: true }) const moduleFileNames = dirEntries .filter((entry) => { return ( entry.isFile() && entry.name.endsWith('.js') && entry.name !== DEFAULT_INDEX_FILE_NAME && entry.name !== DEFAULT_SHARED_FILE_NAME ) }) .map((entry) => entry.name.replace(/\.js$/i, '')) .sort((left, right) => left.localeCompare(right)) const moduleExports = await Promise.all( moduleFileNames.map(async (fileName) => { return { fileName, exportNames: await readGeneratedModuleExportNames(path.join(outDir, `${fileName}.js`)), } }), ) assertNoDuplicateModuleExports(moduleExports) const lines = [ AUTO_GENERATED_BANNER, `// Swagger: ${swaggerUrl}`, '// Module entrypoint with namespace and flattened exports', '', ] for (const { fileName } of moduleExports) { lines.push(`export * as ${buildModuleNamespaceExportName(fileName)} from './${fileName}'`) } if (moduleExports.length) { lines.push('') } for (const { fileName } of moduleExports) { lines.push(`export * from './${fileName}'`) } lines.push('') return `${lines.join('\n')}` } const readGeneratedModuleExportNames = async (filePath) => { const content = await fs.readFile(filePath, 'utf8') return extractGeneratedModuleExportNames(content) } const extractGeneratedModuleExportNames = (content) => { const match = String(content || '').match(/export\s*\{([\s\S]*?)\}\s*;?\s*$/) if (!match) { return [] } return match[1] .split(',') .map((item) => item.trim()) .filter(Boolean) } const readGeneratedModuleName = (content) => { const match = String(content || '').match(/\/\/ Module:\s*(.+)/) return match?.[1]?.trim() || '' } const buildNamespaceExportOccurrence = ({ fileName, moduleName }) => { return { exportName: buildModuleNamespaceExportName(fileName), fileName, isSelected: false, moduleName, path: null, sourceType: 'namespace', } } const buildFlatExportOccurrence = ({ exportName, fileName, isSelected, moduleName, path }) => { return { exportName, fileName, isSelected, moduleName, path, sourceType: 'flat', } } const findDuplicateModuleExports = (moduleExports) => { const exportMap = new Map() for (const moduleExport of moduleExports) { for (const occurrence of moduleExport.occurrences) { if (!exportMap.has(occurrence.exportName)) { exportMap.set(occurrence.exportName, []) } exportMap.get(occurrence.exportName).push(occurrence) } } return [...exportMap.entries()] .map(([exportName, occurrences]) => ({ exportName, occurrences: occurrences.sort(compareDuplicateOccurrences), })) .filter((item) => item.occurrences.length > 1) .sort((left, right) => left.exportName.localeCompare(right.exportName)) } const findNamespaceExportConflicts = (moduleExports) => { const namespaceOccurrences = moduleExports.map((moduleExport) => buildNamespaceExportOccurrence({ fileName: moduleExport.fileName, moduleName: moduleExport.moduleName, }), ) const namespaceMap = new Map( namespaceOccurrences.map((occurrence) => [occurrence.exportName, occurrence]), ) const conflicts = new Map() for (const moduleExport of moduleExports) { for (const occurrence of moduleExport.occurrences) { const namespaceOccurrence = namespaceMap.get(occurrence.exportName) if (!namespaceOccurrence) { continue } if (!conflicts.has(occurrence.exportName)) { conflicts.set(occurrence.exportName, { exportName: occurrence.exportName, occurrences: [namespaceOccurrence], }) } conflicts.get(occurrence.exportName).occurrences.push(occurrence) } } return [...conflicts.values()] .map((conflict) => ({ ...conflict, occurrences: conflict.occurrences.sort(compareDuplicateOccurrences), })) .filter((conflict) => conflict.occurrences.length > 1) .sort((left, right) => left.exportName.localeCompare(right.exportName)) } const applyDuplicateExportRenameStrategy = (selectedModules, conflicts) => { const renameEntries = [] for (const conflict of conflicts) { for (const occurrence of conflict.occurrences) { if (!occurrence.isSelected) { continue } const moduleInfo = selectedModules.find((item) => item.fileName === occurrence.fileName) if (!moduleInfo) { continue } const operationItem = moduleInfo.operations.find((item) => { return `${item.functionName}Api` === occurrence.exportName && item.path === occurrence.path }) if (!operationItem) { continue } const nextFunctionName = buildConflictResolvedFunctionName( operationItem.functionName, moduleInfo.moduleName, ) if (operationItem.functionName === nextFunctionName) { continue } renameEntries.push({ from: `${operationItem.functionName}Api`, moduleName: moduleInfo.moduleName, path: operationItem.path, to: `${nextFunctionName}Api`, }) operationItem.functionName = nextFunctionName } } return renameEntries.sort((left, right) => left.to.localeCompare(right.to)) } const buildConflictResolvedFunctionName = (functionName, moduleName) => { return ensureIdentifier(`${functionName}${capitalize(toCamelCase(moduleName))}`) } const compareDuplicateOccurrences = (left, right) => { if (left.moduleName !== right.moduleName) { return left.moduleName.localeCompare(right.moduleName) } if (left.fileName !== right.fileName) { return left.fileName.localeCompare(right.fileName) } return (left.path || '').localeCompare(right.path || '') } const assertNoDuplicateModuleExports = (moduleExports) => { const normalizedModuleExports = moduleExports.map((moduleExport) => ({ fileName: moduleExport.fileName, isSelected: false, moduleName: moduleExport.fileName, occurrences: moduleExport.exportNames.map((exportName) => buildFlatExportOccurrence({ exportName, fileName: moduleExport.fileName, isSelected: false, moduleName: moduleExport.fileName, path: null, }), ), })) const namespaceConflicts = findNamespaceExportConflicts(normalizedModuleExports) const duplicateConflicts = findDuplicateModuleExports(normalizedModuleExports) if (!namespaceConflicts.length && !duplicateConflicts.length) { return } throw createDuplicateModuleExportsError(duplicateConflicts, { reason: namespaceConflicts.length ? 'namespace-conflict' : 'unresolved', supplementalConflicts: namespaceConflicts, }) } const createDuplicateModuleExportsError = ( conflicts, { reason = 'unresolved', supplementalConflicts = [] } = {}, ) => { const allConflicts = [...supplementalConflicts, ...conflicts] const reasonMessage = reason === 'user-aborted' ? 'Generation cancelled because duplicate exports were not resolved.' : reason === 'rename-failed' ? 'Automatic rename still leaves duplicate exports, so generation has been aborted.' : reason === 'namespace-conflict' ? 'A flat API export still conflicts with a module namespace export. Regenerate the affected modules or run a full generate to repair stale generated files.' : 'Current index export mode uses "export * from \'./module\'", so duplicate names are not allowed. Run this command in an interactive terminal to choose rename or exit.' return new Error( [ 'Duplicate API export names detected across generated modules.', reasonMessage, '', ...formatDuplicateConflictLines(allConflicts), ].join('\n'), ) } const formatDuplicateConflictLines = (conflicts) => { return conflicts.flatMap((conflict) => { return [ `Conflict export: ${conflict.exportName}`, ...conflict.occurrences.map((occurrence) => { const locationInfo = occurrence.sourceType === 'namespace' ? 'type=namespace-export' : occurrence.path ? `url=${occurrence.path}` : occurrence.isSelected ? 'url=unknown' : 'url=existing-generated-file' return ` - module=${occurrence.moduleName}, file=${occurrence.fileName}, ${locationInfo}` }), '', ] }) } const generateModuleFile = ({ moduleInfo, paramStyle, schemas, requestImport, swaggerUrl }) => { const needsBuildUrl = moduleInfo.operations.some((item) => { return getOperationParameters(item.operation).length > 0 }) const exportNames = moduleInfo.operations.map((item) => item.functionName) const lines = [ AUTO_GENERATED_BANNER, `// Swagger: ${swaggerUrl}`, `// Module: ${moduleInfo.moduleName}`, ] if (moduleInfo.moduleDescription) { lines.push(`// Module description: ${escapeComment(moduleInfo.moduleDescription)}`) } lines.push( `// Param style: ${paramStyle}`, '', `import request from '${requestImport}'`, ) if (needsBuildUrl) { lines.push(`import { buildUrl } from '${DEFAULT_SHARED_IMPORT}'`) } lines.push('') const operationBlocks = moduleInfo.operations.flatMap((item) => { return generateOperationBlock(item, schemas, paramStyle) }) const exportBlock = buildGroupedExportBlock(exportNames) return `${[...lines, ...operationBlocks, ...exportBlock].join('\n')}\n` } const generateOperationBlock = ( { functionName, method, operation, path: apiPath }, schemas, paramStyle, ) => { const queryParameters = getParameters(operation, 'query') const pathParameters = getParameters(operation, 'path') const allParams = [...pathParameters, ...queryParameters] const bodySchemaInfo = getRequestBodySchemaInfo(operation.requestBody, schemas) const hasParams = allParams.length > 0 const hasBody = Boolean(bodySchemaInfo) const summary = operation.summary || `${method.toUpperCase()} ${apiPath}` const jsDocLines = ['/**', ` * ${escapeComment(summary)}`] const signatureInfo = buildSignatureInfo({ hasBody, parameters: allParams, paramStyle, }) if (hasParams) { if (paramStyle === 'positional') { for (const parameter of signatureInfo.parameterBindings) { jsDocLines.push(` * ${buildPositionalParameterDocLine(parameter, schemas)}`) } } else { jsDocLines.push( ` * @param {Object} [params={}] ${pathParameters.length ? 'Path and query params' : 'Query params'}`, ) for (const parameter of allParams) { jsDocLines.push(` * ${buildParameterDocLine('params', parameter, schemas)}`) } } } if (hasBody) { const bodyType = getExpandedJsDocType(bodySchemaInfo.schema, schemas) const bodySchemaName = getSchemaDisplayName(bodySchemaInfo.schema, schemas) const bodyPropertyLines = buildRequestBodyPropertyDocLines({ rootName: signatureInfo.bodyArgName, schema: bodySchemaInfo.schema, schemas, }) if (hasParams) { jsDocLines.push(' *') } jsDocLines.push(` * @param {${bodyType}} ${signatureInfo.bodyArgName} Request body`) if (bodySchemaName) { jsDocLines.push(` * Request body schema: ${bodySchemaName}`) } if (bodyPropertyLines.length) { jsDocLines.push(' *') } for (const propertyLine of bodyPropertyLines) { jsDocLines.push(` * ${propertyLine}`) } } const responseSchemaInfo = getResponseSchemaInfo(operation.responses, schemas) const hasBlobResponse = isBlobDownloadOperation(operation, schemas, { apiPath, method, }) const returnType = hasBlobResponse ? 'Promise' : `Promise<${getExpandedJsDocType(responseSchemaInfo?.schema, schemas)}>` const responsePropertyLines = buildResponsePropertyDocLines(responseSchemaInfo?.schema, schemas) if (hasParams || hasBody) { jsDocLines.push(' *') } jsDocLines.push(` * @returns {${returnType}}`) if (responseSchemaInfo?.displayName) { jsDocLines.push(` * Response schema: ${responseSchemaInfo.displayName}`) } if (responsePropertyLines.length) { jsDocLines.push(' *') } for (const propertyLine of responsePropertyLines) { jsDocLines.push(` * ${propertyLine}`) } jsDocLines.push(' */') const signature = signatureInfo.signature const urlExpression = buildUrlExpression(apiPath, signatureInfo) const requestExpression = buildRequestExpression( method, urlExpression, hasBody, signatureInfo.bodyArgName, hasBlobResponse ? BLOB_RESPONSE_TYPE : null, ) return [...jsDocLines, `const ${functionName}Api = ${signature}${requestExpression}`, ''] } const buildFunctionSignature = (hasParams, hasBody) => { if (hasParams && hasBody) { return '(params = {}, data) => ' } if (hasParams) { return '(params = {}) => ' } if (hasBody) { return '(data) => ' } return '() => ' } const buildSignatureInfo = ({ hasBody, parameters, paramStyle }) => { const bodyArgName = 'data' if (paramStyle !== 'positional') { return { bodyArgName, parameterBindings: [], signature: buildFunctionSignature(parameters.length > 0, hasBody), urlParamsExpression: parameters.length > 0 ? 'params' : null, } } const parameterBindings = buildParameterBindings(parameters, hasBody ? [bodyArgName] : []) const signatureArgs = [...parameterBindings.map((item) => item.variableName)] if (hasBody) { signatureArgs.push(bodyArgName) } return { bodyArgName, parameterBindings, signature: signatureArgs.length ? `(${signatureArgs.join(', ')}) => ` : '() => ', urlParamsExpression: parameterBindings.length ? `{ ${parameterBindings.map(renderParamBindingEntry).join(', ')} }` : null, } } const buildRequestExpression = ( method, urlExpression, hasBody, bodyArgName = 'data', responseType = null, ) => { const requestConfigExpression = buildRequestConfigExpression({ responseType }) switch (method) { case 'get': return requestConfigExpression ? `request.get(${urlExpression}, ${requestConfigExpression})` : `request.get(${urlExpression})` case 'post': return hasBody ? requestConfigExpression ? `request.post(${urlExpression}, ${bodyArgName}, ${requestConfigExpression})` : `request.post(${urlExpression}, ${bodyArgName})` : requestConfigExpression ? `request.post(${urlExpression}, undefined, ${requestConfigExpression})` : `request.post(${urlExpression})` case 'put': return hasBody ? requestConfigExpression ? `request.put(${urlExpression}, ${bodyArgName}, ${requestConfigExpression})` : `request.put(${urlExpression}, ${bodyArgName})` : requestConfigExpression ? `request.put(${urlExpression}, undefined, ${requestConfigExpression})` : `request.put(${urlExpression})` case 'patch': return hasBody ? requestConfigExpression ? `request.patch(${urlExpression}, ${bodyArgName}, ${requestConfigExpression})` : `request.patch(${urlExpression}, ${bodyArgName})` : requestConfigExpression ? `request.patch(${urlExpression}, undefined, ${requestConfigExpression})` : `request.patch(${urlExpression})` case 'delete': if (hasBody) { return `request.delete(${urlExpression}, ${buildRequestConfigExpression({ dataArgName: bodyArgName, responseType, })})` } return requestConfigExpression ? `request.delete(${urlExpression}, ${requestConfigExpression})` : `request.delete(${urlExpression})` default: return hasBody ? requestConfigExpression ? `request.${method}(${urlExpression}, ${bodyArgName}, ${requestConfigExpression})` : `request.${method}(${urlExpression}, ${bodyArgName})` : requestConfigExpression ? `request.${method}(${urlExpression}, ${requestConfigExpression})` : `request.${method}(${urlExpression})` } } const buildRequestConfigExpression = ({ dataArgName = null, responseType = null } = {}) => { const entries = [] if (dataArgName) { entries.push(`data: ${dataArgName}`) } if (responseType) { entries.push(`responseType: ${JSON.stringify(responseType)}`) } return entries.length ? `{ ${entries.join(', ')} }` : null } const buildUrlExpression = (apiPath, signatureInfo) => { return signatureInfo.urlParamsExpression ? `buildUrl(\`${apiPath}\`, ${signatureInfo.urlParamsExpression})` : `\`${apiPath}\`` } const getOperationParameters = (operation) => { return [...getParameters(operation, 'path'), ...getParameters(operation, 'query')] } const getParameters = (operation, location) => { return (operation?.parameters || []).filter((parameter) => parameter.in === location) } const getRequestBodySchemaInfo = (requestBody, schemas) => { if (!requestBody?.content) { return null } const contentEntries = Object.entries(requestBody.content) const preferredContent = contentEntries.find(([contentType]) => contentType === 'application/json') || contentEntries[0] if (!preferredContent) { return null } const [, contentValue] = preferredContent return { schema: contentValue.schema, resolvedSchema: resolveSchema(contentValue.schema, schemas), } } const getResponseSchemaInfo = (responses, schemas) => { const preferredResponse = getPreferredResponse(responses) if (!preferredResponse?.content) { if (!preferredResponse?.schema) { return null } return { displayName: getSchemaDisplayName(preferredResponse.schema, schemas), resolvedSchema: resolveSchema(preferredResponse.schema, schemas), schema: preferredResponse.schema, } } const contentEntries = Object.entries(preferredResponse.content) const preferredContent = contentEntries.find(([contentType]) => isJsonContentType(contentType)) || contentEntries[0] if (!preferredContent?.[1]?.schema) { return null } const schema = preferredContent[1].schema return { displayName: getSchemaDisplayName(schema, schemas), resolvedSchema: resolveSchema(schema, schemas), schema, } } const getPreferredResponse = (responses) => { if (!responses) { return null } return ( responses['200'] || responses['201'] || responses.default || Object.values(responses).find((response) => response?.content || response?.schema) ) } const isBlobDownloadOperation = (operation, schemas, { apiPath = '', method = '' } = {}) => { if (isBlobDownloadResponse(operation.responses, schemas)) { return true } if ((operation.produces || []).some(isDownloadContentType)) { return true } return isImplicitBlobDownloadOperation({ apiPath, method, operation, }) } const isBlobDownloadResponse = (responses, schemas) => { const preferredResponse = getPreferredResponse(responses) if (!preferredResponse) { return false } if (hasContentDispositionHeader(preferredResponse.headers)) { return true } if (isFileSchema(preferredResponse.schema, schemas)) { return true } return Object.entries(preferredResponse.content || {}).some(([contentType, contentValue]) => { return isFileSchema(contentValue?.schema, schemas) || isDownloadContentType(contentType) }) } const hasContentDispositionHeader = (headers = {}) => { return Object.keys(headers).some((headerName) => { return headerName.toLowerCase() === 'content-disposition' }) } const isImplicitBlobDownloadOperation = ({ apiPath, method, operation }) => { const preferredResponse = getPreferredResponse(operation.responses) if (hasExplicitNonFileResponse(preferredResponse)) { return false } const textParts = [ apiPath, operation.operationId, operation.summary, operation.description, ...(operation.tags || []), ] const rawText = textParts.filter(Boolean).join(' ') const normalizedText = rawText.toLowerCase() const wordTokens = new Set(splitWords(rawText).map((word) => word.toLowerCase())) const hasDownloadKeyword = wordTokens.has('download') || wordTokens.has('export') || normalizedText.includes('下载') || normalizedText.includes('导出') const hasUploadOnlyKeyword = (wordTokens.has('upload') || wordTokens.has('import') || normalizedText.includes('上传') || normalizedText.includes('导入')) && !hasDownloadKeyword if (hasUploadOnlyKeyword) { return false } return hasDownloadKeyword || (method === 'get' && wordTokens.has('file')) } const hasExplicitNonFileResponse = (response) => { if (!response) { return false } if (response.schema) { return true } const contentEntries = Object.entries(response.content || {}) if (contentEntries.some(([contentType]) => isJsonContentType(contentType))) { return true } return contentEntries.some(([, contentValue]) => Boolean(contentValue?.schema)) } const isFileSchema = (schema, schemas, seenRefs = new Set()) => { if (!schema) { return false } if (schema.$ref) { if (seenRefs.has(schema.$ref)) { return false } const resolvedSchema = resolveSchema(schema, schemas) const nextSeenRefs = new Set(seenRefs) nextSeenRefs.add(schema.$ref) return isFileSchema(resolvedSchema, schemas, nextSeenRefs) } const schemaType = String(schema.type || '').toLowerCase() const schemaFormat = String(schema.format || '').toLowerCase() const contentEncoding = String(schema.contentEncoding || '').toLowerCase() if (schemaType === 'file') { return true } if (schemaType === 'string' && ['binary', 'file'].includes(schemaFormat)) { return true } if (schemaType === 'string' && contentEncoding === 'binary') { return true } return [...(schema.allOf || []), ...(schema.anyOf || []), ...(schema.oneOf || [])].some( (itemSchema) => isFileSchema(itemSchema, schemas, seenRefs), ) } const isDownloadContentType = (contentType) => { const normalizedContentType = normalizeContentType(contentType) if (!normalizedContentType || isJsonContentType(normalizedContentType)) { return false } return ( normalizedContentType === 'application/octet-stream' || normalizedContentType === 'application/pdf' || normalizedContentType === 'application/zip' || normalizedContentType === 'application/x-zip-compressed' || normalizedContentType === 'application/x-7z-compressed' || normalizedContentType === 'application/gzip' || normalizedContentType === 'application/x-tar' || normalizedContentType === 'application/msword' || normalizedContentType === 'application/csv' || normalizedContentType === 'text/csv' || normalizedContentType.startsWith('application/vnd.') || normalizedContentType.startsWith('image/') || normalizedContentType.startsWith('audio/') || normalizedContentType.startsWith('video/') || normalizedContentType.startsWith('font/') ) } const isJsonContentType = (contentType) => { const normalizedContentType = normalizeContentType(contentType) return normalizedContentType === 'application/json' || normalizedContentType.endsWith('+json') } const normalizeContentType = (contentType) => { return String(contentType || '') .split(';')[0] .trim() .toLowerCase() } const buildResponsePropertyDocLines = (schema, schemas) => { return buildStructuredPropertyDocLines({ title: 'Response fields:', itemTitle: 'Response item fields:', schema, schemas, }) } const buildRequestBodyPropertyDocLines = ({ rootName, schema, schemas }) => { return buildStructuredPropertyDocLines({ title: 'Request body fields:', itemTitle: 'Request body item fields:', schema, schemas, rootPath: rootName, }) } const buildStructuredPropertyDocLines = ({ title, itemTitle, schema, schemas, rootPath = '', }) => { if (!schema) { return [] } const schemaContext = getSchemaTraversalContext(schema, schemas) const resolvedSchema = schemaContext.resolvedSchema if (resolvedSchema?.type === 'object' && resolvedSchema.properties) { return [ title, ...buildStructuredObjectPropertyDocLines({ schema, schemas, depth: 0, parentPath: rootPath, seenRefs: schemaContext.seenRefs, }), ] } if (resolvedSchema?.type === 'array') { const itemLines = buildNestedStructuredPropertyDocLines({ schema: resolvedSchema.items, schemas, depth: 0, seenRefs: schemaContext.seenRefs, parentPath: rootPath, omitRootArrayMarker: rootPath === '', }) if (itemLines.length) { return [itemTitle, ...itemLines] } } return [] } const buildStructuredObjectPropertyDocLines = ({ schema, schemas, depth = 0, seenRefs = new Set(), parentPath = '', }) => { const schemaContext = getSchemaTraversalContext(schema, schemas, seenRefs) const resolvedSchema = schemaContext.resolvedSchema if (!resolvedSchema?.properties) { return [] } const requiredKeys = new Set(resolvedSchema?.required || []) const lines = [] for (const [propertyName, propertySchema] of Object.entries(resolvedSchema?.properties || {})) { const pathPrefix = parentPath ? `${parentPath}.` : '' const propertyPath = `${pathPrefix}${propertyName}` const type = getJsDocType(propertySchema, schemas) const description = escapeComment(propertySchema.description || '') const suffix = requiredKeys.has(propertyName) ? '' : '?' lines.push( `${buildResponseDocIndent(depth)}- ${propertyPath}${suffix}: ${formatResponseFieldType(type)}${description ? ` ${description}` : ''}`, ) lines.push( ...buildNestedStructuredPropertyDocLines({ schema: propertySchema, schemas, depth: depth + 1, seenRefs: schemaContext.seenRefs, parentPath: propertyPath, }), ) } return lines } const buildNestedStructuredPropertyDocLines = ({ schema, schemas, depth = 0, seenRefs = new Set(), parentPath, omitRootArrayMarker = false, }) => { const schemaContext = getSchemaTraversalContext(schema, schemas, seenRefs) const resolvedSchema = schemaContext.resolvedSchema if (!resolvedSchema || schemaContext.isCircular) { return [] } if (resolvedSchema.type === 'object' && resolvedSchema.properties) { return buildStructuredObjectPropertyDocLines({ schema, schemas, depth, seenRefs: schemaContext.seenRefs, parentPath, }) } if (resolvedSchema.type === 'array') { return buildNestedStructuredPropertyDocLines({ schema: resolvedSchema.items, schemas, depth, seenRefs: schemaContext.seenRefs, parentPath: parentPath ? `${parentPath}[]` : omitRootArrayMarker ? '' : '[]', omitRootArrayMarker, }) } return [] } const buildParameterDocLine = (rootName, parameter, schemas) => { const type = getJsDocType(parameter.schema, schemas) const accessor = parameter.required ? `${rootName}.${parameter.name}` : `[${rootName}.${parameter.name}]` const description = escapeComment(parameter.description || '') return `@param {${type}} ${accessor}${description ? ` ${description}` : ''}` } const buildPositionalParameterDocLine = (parameter, schemas) => { const type = getJsDocType(parameter.schema, schemas) const accessor = parameter.required ? parameter.variableName : `[${parameter.variableName}]` const description = escapeComment(parameter.description || '') return `@param {${type}} ${accessor}${description ? ` ${description}` : ''}` } const getSchemaDisplayName = (schema, schemas) => { if (!schema) { return '' } if (schema.$ref) { return schema.$ref.split('/').pop() } if (schema.type === 'array') { const itemDisplayName = getSchemaDisplayName(schema.items, schemas) if (itemDisplayName) { return `${itemDisplayName}[]` } return `${getJsDocType(schema.items, schemas)}[]` } return '' } const getExpandedJsDocType = (schema, schemas) => { return buildExpandedJsDocType(schema, schemas) } const buildExpandedJsDocType = ( schema, schemas, seenRefs = new Set(), forceNullable = false, ) => { const schemaContext = getSchemaTraversalContext(schema, schemas, seenRefs) const resolvedSchema = schemaContext.resolvedSchema const nullable = forceNullable || schema?.nullable || resolvedSchema?.nullable if (!resolvedSchema) { return 'any' } if (schemaContext.isCircular) { return getJsDocType(schema, schemas) } if (resolvedSchema.type === 'array') { return appendNullable( `Array<${buildExpandedJsDocType(resolvedSchema.items, schemas, schemaContext.seenRefs)}>`, nullable, ) } if (resolvedSchema.type === 'object') { return getExpandedObjectJsDocType(schema, schemas, schemaContext.seenRefs, nullable) } switch (resolvedSchema.type) { case 'integer': case 'number': return appendNullable('number', nullable) case 'boolean': return appendNullable('boolean', nullable) case 'string': return appendNullable('string', nullable) default: return appendNullable('any', nullable) } } const getExpandedObjectJsDocType = ( schema, schemas, seenRefs = new Set(), forceNullable = false, ) => { const schemaContext = getSchemaTraversalContext(schema, schemas, seenRefs) const resolvedSchema = schemaContext.resolvedSchema const nullable = forceNullable || schema?.nullable || resolvedSchema?.nullable if (!resolvedSchema) { return 'any' } if (resolvedSchema.type !== 'object' || !resolvedSchema.properties) { return getJsDocType(schema, schemas) } const requiredKeys = new Set(resolvedSchema.required || []) const propertyEntries = Object.entries(resolvedSchema.properties).map( ([propertyName, propertySchema]) => { const renderedName = renderJsDocObjectPropertyName(propertyName) const optionalToken = requiredKeys.has(propertyName) ? '' : '?' return `${renderedName}${optionalToken}: ${buildExpandedJsDocType( propertySchema, schemas, schemaContext.seenRefs, )}` }, ) return appendNullable(`{ ${propertyEntries.join(', ')} }`, nullable) } const getJsDocType = (schema, schemas) => { const resolvedSchema = resolveSchema(schema, schemas) if (!resolvedSchema) { return 'any' } if (schema?.$ref) { const refType = resolvedSchema.type === 'array' ? `Array<${getJsDocType(resolvedSchema.items, schemas)}>` : resolvedSchema.type === 'object' ? 'Object' : getJsDocType(resolvedSchema, schemas) return appendNullable(refType, schema.nullable || resolvedSchema.nullable) } switch (resolvedSchema.type) { case 'integer': case 'number': return appendNullable('number', resolvedSchema.nullable) case 'boolean': return appendNullable('boolean', resolvedSchema.nullable) case 'string': return appendNullable('string', resolvedSchema.nullable) case 'array': return appendNullable( `Array<${getJsDocType(resolvedSchema.items, schemas)}>`, resolvedSchema.nullable, ) case 'object': return appendNullable('Object', resolvedSchema.nullable) default: return appendNullable('any', resolvedSchema.nullable) } } const renderJsDocObjectPropertyName = (propertyName) => { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(propertyName) ? propertyName : JSON.stringify(propertyName) } const formatResponseFieldType = (type) => { return type.startsWith('{') ? type : `{${type}}` } const buildResponseDocIndent = (depth = 0) => { return ' '.repeat(Math.max(0, depth)) } const appendNullable = (type, nullable) => { return nullable ? `${type} | null` : type } const getSchemaTraversalContext = (schema, schemas, seenRefs = new Set()) => { if (!schema) { return { isCircular: false, resolvedSchema: null, seenRefs, } } if (!schema.$ref) { return { isCircular: false, resolvedSchema: resolveSchema(schema, schemas), seenRefs, } } const resolvedSchema = resolveSchema(schema, schemas) if (seenRefs.has(schema.$ref)) { return { isCircular: true, resolvedSchema, seenRefs, } } const nextSeenRefs = new Set(seenRefs) nextSeenRefs.add(schema.$ref) return { isCircular: false, resolvedSchema, seenRefs: nextSeenRefs, } } const resolveSchema = (schema, schemas) => { if (!schema) { return null } if (schema.$ref) { const schemaName = schema.$ref.split('/').pop() return schemas[schemaName] || null } return schema } const compareOperations = (left, right) => { if (left.path !== right.path) { return left.path.localeCompare(right.path) } return HTTP_METHOD_ORDER.indexOf(left.method) - HTTP_METHOD_ORDER.indexOf(right.method) } const assignFunctionNames = (operations, reservedNames = new Set()) => { const usedNames = new Set(reservedNames) for (const item of operations) { const baseName = ensureIdentifier(toCamelCase(getEndpointName(item.path))) let functionName = baseName if (usedNames.has(functionName)) { functionName = ensureIdentifier(`${item.method}${capitalize(baseName)}`) } let sequence = 2 while (usedNames.has(functionName)) { functionName = `${baseName}${sequence}` sequence += 1 } item.functionName = functionName usedNames.add(functionName) } } const extractModuleName = (apiPath, operation) => { const segments = apiPath.split('/').filter(Boolean) if (segments[0] === 'api' && /^v\d+$/i.test(segments[1] || '')) { return segments[2] || operation.tags?.[0] || 'default' } if (segments[0] === 'api') { return segments[1] || operation.tags?.[0] || 'default' } return operation.tags?.[0] || segments[0] || 'default' } const buildTagDescriptionMap = (tags = []) => { const descriptionMap = new Map() for (const tag of tags) { const tagName = String(tag?.name || '').trim() const tagDescription = String(tag?.description || '').trim() if (!tagName || !tagDescription) { continue } descriptionMap.set(normalizeLookupKey(tagName), tagDescription) } return descriptionMap } const resolveModuleDescription = ({ moduleName, operation, tagDescriptionMap }) => { const candidates = [...(operation.tags || []), moduleName] for (const candidate of candidates) { const description = tagDescriptionMap.get(normalizeLookupKey(candidate)) if (description) { return description } } return '' } const getEndpointName = (apiPath) => { const segments = apiPath.split('/').filter(Boolean) for (let index = segments.length - 1; index >= 0; index -= 1) { if (!segments[index].startsWith('{')) { return segments[index] } } return 'api' } const normalizeLookupKey = (value) => { return String(value || '') .trim() .toLowerCase() .replace(/[^a-z0-9]/g, '') } const ensureIdentifier = (value) => { const sanitized = value.replace(/[^a-zA-Z0-9_$]/g, '') if (!sanitized) { return 'api' } if (/^[0-9]/.test(sanitized)) { return `api${capitalize(sanitized)}` } return sanitized } const toSafeVariableName = (value) => { const identifier = ensureIdentifier(toCamelCase(value) || 'param') if (JS_RESERVED_WORDS.has(identifier)) { return `param${capitalize(identifier)}` } return identifier } const buildParameterBindings = (parameters, reservedNames = []) => { const usedNames = new Set(reservedNames) return parameters.map((parameter) => { const baseName = toSafeVariableName(parameter.name) let variableName = baseName let sequence = 2 while (usedNames.has(variableName)) { variableName = `${baseName}${sequence}` sequence += 1 } usedNames.add(variableName) return { ...parameter, variableName, } }) } const renderParamBindingEntry = (parameter) => { return `${JSON.stringify(parameter.name)}: ${parameter.variableName}` } const buildGroupedExportBlock = (exportNames) => { if (!exportNames.length) { return [] } return ['export {', ...exportNames.map((exportName) => ` ${exportName}Api,`), '}', ''] } const buildModuleNamespaceExportName = (fileName) => { return `${toCamelCase(fileName)}Api` } const splitWords = (value) => { return String(value || '') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') .split(/[^a-zA-Z0-9]+|\s+/) .map((item) => item.trim()) .filter(Boolean) } const toCamelCase = (value) => { const [firstWord = 'api', ...restWords] = splitWords(value) return [ firstWord.charAt(0).toLowerCase() + firstWord.slice(1), ...restWords.map((word) => capitalize(word)), ].join('') } const toKebabCase = (value) => { return splitWords(value) .map((word) => word.toLowerCase()) .join('-') } const capitalize = (value) => { return value ? value.charAt(0).toUpperCase() + value.slice(1) : value } const escapeComment = (value) => { return String(value || '') .replace(/\*\//g, '* /') .replace(/\s*[\r\n]+\s*/g, ' ') .replace(/\s{2,}/g, ' ') .trim() }