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 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, 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 = await readRetainedModuleExports({ outputDir, skipFileNames: selectedModuleFileNames, skipRemovableAutoGeneratedFiles: shouldRemoveStaleGeneratedFiles, }) const reservedNamespaceFunctionNames = new Set( [...selectedModules.map((moduleInfo) => moduleInfo.fileName), ...retainedModuleExports.map((item) => item.fileName)].map( (fileName) => toCamelCase(fileName), ), ) for (const moduleInfo of selectedModules) { assignFunctionNames(moduleInfo.operations, reservedNamespaceFunctionNames) } 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) await writeGeneratedFile( indexFilePath, await generateIndexFile({ outDir: outputDir, swaggerUrl, }), ) console.log(`generated: ${path.relative(projectRoot, indexFilePath)}`) 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 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() 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 || [])]) if (!moduleMap.has(moduleKey)) { moduleMap.set(moduleKey, { aliases, fileName: toKebabCase(moduleName), moduleName, operations: [], }) } const moduleInfo = moduleMap.get(moduleKey) for (const alias of aliases) { moduleInfo.aliases.add(alias) } 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}`, `// 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 = getJsDocType(bodySchemaInfo.schema, schemas) jsDocLines.push(` * @param {${bodyType}} ${signatureInfo.bodyArgName} Request body`) for (const propertyLine of buildSchemaPropertyDocLines( signatureInfo.bodyArgName, bodySchemaInfo.schema, schemas, )) { jsDocLines.push(` * ${propertyLine}`) } } const responseSchemaInfo = getResponseSchemaInfo(operation.responses, schemas) const returnType = `Promise<${getExpandedResponseJsDocType(responseSchemaInfo?.schema, schemas)}>` const returnDescription = responseSchemaInfo?.displayName ? ` Returns ${responseSchemaInfo.displayName}` : '' jsDocLines.push(` * @returns {${returnType}}${returnDescription}`) for (const propertyLine of buildResponsePropertyDocLines(responseSchemaInfo?.schema, schemas)) { jsDocLines.push(` * ${propertyLine}`) } jsDocLines.push(' */') const signature = signatureInfo.signature const urlExpression = buildUrlExpression(apiPath, signatureInfo) const requestExpression = buildRequestExpression( method, urlExpression, hasBody, signatureInfo.bodyArgName, ) 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') => { switch (method) { case 'get': return `request.get(${urlExpression})` case 'post': return hasBody ? `request.post(${urlExpression}, ${bodyArgName})` : `request.post(${urlExpression})` case 'put': return hasBody ? `request.put(${urlExpression}, ${bodyArgName})` : `request.put(${urlExpression})` case 'patch': return hasBody ? `request.patch(${urlExpression}, ${bodyArgName})` : `request.patch(${urlExpression})` case 'delete': return hasBody ? `request.delete(${urlExpression}, { data: ${bodyArgName} })` : `request.delete(${urlExpression})` default: return hasBody ? `request.${method}(${urlExpression}, ${bodyArgName})` : `request.${method}(${urlExpression})` } } 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) => { if (!responses) { return null } const preferredResponse = responses['200'] || responses['201'] || responses.default || Object.values(responses).find((response) => response?.content) if (!preferredResponse?.content) { return null } const contentEntries = Object.entries(preferredResponse.content) const preferredContent = contentEntries.find(([contentType]) => contentType === 'application/json') || contentEntries[0] if (!preferredContent?.[1]?.schema) { return null } const schema = preferredContent[1].schema return { displayName: getSchemaDisplayName(schema, schemas), resolvedSchema: resolveSchema(schema, schemas), schema, } } const buildResponsePropertyDocLines = (schema, schemas) => { if (!schema) { return [] } const resolvedSchema = resolveSchema(schema, schemas) if (resolvedSchema?.type === 'object' && resolvedSchema.properties) { return buildResponseObjectPropertyDocLines(schema, schemas, 'Response fields:') } if (resolvedSchema?.type === 'array') { const itemSchema = resolvedSchema.items const resolvedItemSchema = resolveSchema(itemSchema, schemas) if (resolvedItemSchema?.type === 'object' && resolvedItemSchema.properties) { return buildResponseObjectPropertyDocLines(itemSchema, schemas, 'Response item fields:') } } return [] } const buildResponseObjectPropertyDocLines = (schema, schemas, title) => { const resolvedSchema = resolveSchema(schema, schemas) const requiredKeys = new Set(resolvedSchema?.required || []) const lines = [title] for (const [propertyName, propertySchema] of Object.entries(resolvedSchema?.properties || {})) { const type = getJsDocType(propertySchema, schemas) const description = escapeComment(propertySchema.description || '') const suffix = requiredKeys.has(propertyName) ? '' : '?' lines.push(`- ${propertyName}${suffix}: {${type}}${description ? ` ${description}` : ''}`) } return lines } 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 buildSchemaPropertyDocLines = (rootName, schema, schemas) => { const resolvedSchema = resolveSchema(schema, schemas) const requiredKeys = new Set(resolvedSchema?.required || []) const propertyLines = [] if (!resolvedSchema?.properties) { return propertyLines } for (const [propertyName, propertySchema] of Object.entries(resolvedSchema.properties)) { const type = getJsDocType(propertySchema, schemas) const accessor = requiredKeys.has(propertyName) ? `${rootName}.${propertyName}` : `[${rootName}.${propertyName}]` const description = escapeComment(propertySchema.description || '') propertyLines.push(`@param {${type}} ${accessor}${description ? ` ${description}` : ''}`) } return propertyLines } 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 getExpandedResponseJsDocType = (schema, schemas) => { const resolvedSchema = resolveSchema(schema, schemas) if (!resolvedSchema) { return 'any' } if (resolvedSchema.type === 'array') { return appendNullable( `Array<${getExpandedResponseObjectJsDocType(resolvedSchema.items, schemas)}>`, schema?.nullable || resolvedSchema.nullable, ) } if (resolvedSchema.type === 'object') { return getExpandedResponseObjectJsDocType(schema, schemas) } return getJsDocType(schema, schemas) } const getExpandedResponseObjectJsDocType = (schema, schemas) => { const resolvedSchema = resolveSchema(schema, schemas) 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}: ${getJsDocType(propertySchema, schemas)}` }, ) return appendNullable( `{ ${propertyEntries.join(', ')} }`, schema?.nullable || resolvedSchema.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 appendNullable = (type, nullable) => { return nullable ? `${type} | null` : type } 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 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() }