yx_generate_api_js/src/core/generate.js

1937 lines
52 KiB
JavaScript

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<Blob>'
: `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()
}