1421 lines
39 KiB
JavaScript
1421 lines
39 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 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()
|
|
}
|