添加文件下载方案

This commit is contained in:
DESKTOP-I3JPKHK\wy 2026-04-25 19:24:12 +08:00
parent 4dc4427f2a
commit a2d1fb3875
3 changed files with 450 additions and 24 deletions

View File

@ -410,6 +410,20 @@ const createApi = (id, data) => request.post(buildUrl(`/api/v1/course/{id}`, { i
适合你明确想要“函数参数看起来更直接”的场景。 适合你明确想要“函数参数看起来更直接”的场景。
## 文件下载接口
如果响应声明为 `type: string, format: binary`、Swagger 2 的 `type: file`,或者使用常见文件下载 MIME 类型,例如 `application/octet-stream`、`application/pdf`、Excel、CSV、图片、音视频等生成代码会自动带上 `responseType: 'blob'`
有些后端 Swagger 不会给导出接口声明响应 `content` / `schema`。这种情况下,如果接口路径、`operationId`、摘要或描述里能识别到 `导出`、`下载`、`export`、`download`,或者 GET 接口名里有独立的 `File` 单词,也会按文件下载处理。若响应已经明确声明为 JSON 或普通 schema则不会走这个兜底推断。
```js
const exportApi = (params = {}) =>
request.get(buildUrl(`/api/v1/report/export`, params), { responseType: 'blob' })
const createExportApi = (data) =>
request.post(`/api/v1/report/export`, data, { responseType: 'blob' })
```
## `sync` 会怎么改外部入口文件 ## `sync` 会怎么改外部入口文件
`sync` 不会粗暴覆盖整个 `externalIndexFile`,它只维护一段带开始和结束标记的受管区块。 `sync` 不会粗暴覆盖整个 `externalIndexFile`,它只维护一段带开始和结束标记的受管区块。

View File

@ -7,6 +7,7 @@ const DEFAULT_SHARED_FILE_NAME = 'shared.js'
const DEFAULT_INDEX_FILE_NAME = 'index.js' const DEFAULT_INDEX_FILE_NAME = 'index.js'
const AUTO_GENERATED_BANNER = '// Auto-generated. Do not edit manually.' const AUTO_GENERATED_BANNER = '// Auto-generated. Do not edit manually.'
const HTTP_METHOD_ORDER = ['get', 'post', 'put', 'delete', 'patch'] const HTTP_METHOD_ORDER = ['get', 'post', 'put', 'delete', 'patch']
const BLOB_RESPONSE_TYPE = 'blob'
const JS_RESERVED_WORDS = new Set([ const JS_RESERVED_WORDS = new Set([
'await', 'await',
'break', 'break',
@ -952,7 +953,13 @@ const generateOperationBlock = (
} }
const responseSchemaInfo = getResponseSchemaInfo(operation.responses, schemas) const responseSchemaInfo = getResponseSchemaInfo(operation.responses, schemas)
const returnType = `Promise<${getExpandedJsDocType(responseSchemaInfo?.schema, schemas)}>` const hasBlobResponse = isBlobDownloadOperation(operation, schemas, {
apiPath,
method,
})
const returnType = hasBlobResponse
? 'Promise<Blob>'
: `Promise<${getExpandedJsDocType(responseSchemaInfo?.schema, schemas)}>`
const responsePropertyLines = buildResponsePropertyDocLines(responseSchemaInfo?.schema, schemas) const responsePropertyLines = buildResponsePropertyDocLines(responseSchemaInfo?.schema, schemas)
if (hasParams || hasBody) { if (hasParams || hasBody) {
@ -982,6 +989,7 @@ const generateOperationBlock = (
urlExpression, urlExpression,
hasBody, hasBody,
signatureInfo.bodyArgName, signatureInfo.bodyArgName,
hasBlobResponse ? BLOB_RESPONSE_TYPE : null,
) )
return [...jsDocLines, `const ${functionName}Api = ${signature}${requestExpression}`, ''] return [...jsDocLines, `const ${functionName}Api = ${signature}${requestExpression}`, '']
@ -1032,33 +1040,80 @@ const buildSignatureInfo = ({ hasBody, parameters, paramStyle }) => {
} }
} }
const buildRequestExpression = (method, urlExpression, hasBody, bodyArgName = 'data') => { const buildRequestExpression = (
method,
urlExpression,
hasBody,
bodyArgName = 'data',
responseType = null,
) => {
const requestConfigExpression = buildRequestConfigExpression({ responseType })
switch (method) { switch (method) {
case 'get': case 'get':
return `request.get(${urlExpression})` return requestConfigExpression
? `request.get(${urlExpression}, ${requestConfigExpression})`
: `request.get(${urlExpression})`
case 'post': case 'post':
return hasBody return hasBody
? `request.post(${urlExpression}, ${bodyArgName})` ? requestConfigExpression
: `request.post(${urlExpression})` ? `request.post(${urlExpression}, ${bodyArgName}, ${requestConfigExpression})`
: `request.post(${urlExpression}, ${bodyArgName})`
: requestConfigExpression
? `request.post(${urlExpression}, undefined, ${requestConfigExpression})`
: `request.post(${urlExpression})`
case 'put': case 'put':
return hasBody return hasBody
? `request.put(${urlExpression}, ${bodyArgName})` ? requestConfigExpression
: `request.put(${urlExpression})` ? `request.put(${urlExpression}, ${bodyArgName}, ${requestConfigExpression})`
: `request.put(${urlExpression}, ${bodyArgName})`
: requestConfigExpression
? `request.put(${urlExpression}, undefined, ${requestConfigExpression})`
: `request.put(${urlExpression})`
case 'patch': case 'patch':
return hasBody return hasBody
? `request.patch(${urlExpression}, ${bodyArgName})` ? requestConfigExpression
: `request.patch(${urlExpression})` ? `request.patch(${urlExpression}, ${bodyArgName}, ${requestConfigExpression})`
: `request.patch(${urlExpression}, ${bodyArgName})`
: requestConfigExpression
? `request.patch(${urlExpression}, undefined, ${requestConfigExpression})`
: `request.patch(${urlExpression})`
case 'delete': case 'delete':
return hasBody if (hasBody) {
? `request.delete(${urlExpression}, { data: ${bodyArgName} })` return `request.delete(${urlExpression}, ${buildRequestConfigExpression({
dataArgName: bodyArgName,
responseType,
})})`
}
return requestConfigExpression
? `request.delete(${urlExpression}, ${requestConfigExpression})`
: `request.delete(${urlExpression})` : `request.delete(${urlExpression})`
default: default:
return hasBody return hasBody
? `request.${method}(${urlExpression}, ${bodyArgName})` ? requestConfigExpression
: `request.${method}(${urlExpression})` ? `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) => { const buildUrlExpression = (apiPath, signatureInfo) => {
return signatureInfo.urlParamsExpression return signatureInfo.urlParamsExpression
? `buildUrl(\`${apiPath}\`, ${signatureInfo.urlParamsExpression})` ? `buildUrl(\`${apiPath}\`, ${signatureInfo.urlParamsExpression})`
@ -1095,23 +1150,23 @@ const getRequestBodySchemaInfo = (requestBody, schemas) => {
} }
const getResponseSchemaInfo = (responses, schemas) => { const getResponseSchemaInfo = (responses, schemas) => {
if (!responses) { const preferredResponse = getPreferredResponse(responses)
return null
}
const preferredResponse =
responses['200'] ||
responses['201'] ||
responses.default ||
Object.values(responses).find((response) => response?.content)
if (!preferredResponse?.content) { if (!preferredResponse?.content) {
return null 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 contentEntries = Object.entries(preferredResponse.content)
const preferredContent = const preferredContent =
contentEntries.find(([contentType]) => contentType === 'application/json') || contentEntries[0] contentEntries.find(([contentType]) => isJsonContentType(contentType)) || contentEntries[0]
if (!preferredContent?.[1]?.schema) { if (!preferredContent?.[1]?.schema) {
return null return null
@ -1126,6 +1181,192 @@ const getResponseSchemaInfo = (responses, schemas) => {
} }
} }
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) => { const buildResponsePropertyDocLines = (schema, schemas) => {
return buildStructuredPropertyDocLines({ return buildStructuredPropertyDocLines({
title: 'Response fields:', title: 'Response fields:',

View File

@ -515,6 +515,177 @@ test('generation automatically avoids collisions with module namespace export na
} }
}) })
test('file download responses generate blob request config', async () => {
const tempDir = await createTempDir()
try {
const swaggerPath = path.join(tempDir, 'swagger.json')
const outputDir = path.join(tempDir, 'generated')
await writeJson(swaggerPath, {
openapi: '3.0.0',
paths: {
'/api/v1/File/Export': {
get: {
tags: ['File'],
parameters: [
{
name: 'id',
in: 'query',
schema: {
type: 'integer',
},
},
],
responses: {
200: {
description: 'OK',
content: {
'application/octet-stream': {
schema: {
type: 'string',
format: 'binary',
},
},
},
},
},
},
},
'/api/v1/File/CreateExport': {
post: {
tags: ['File'],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
name: {
type: 'string',
},
},
},
},
},
},
responses: {
200: {
description: 'OK',
content: {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {},
},
},
},
},
},
},
})
await generateApiFiles({
projectRoot: tempDir,
swaggerUrl: swaggerPath,
swaggerTimeoutMs: 1000,
outputDir,
requestImport: '../request',
paramStyle: 'object',
modules: [],
cleanOutput: true,
})
const fileContent = await readFile(path.join(outputDir, 'file.js'))
assert.match(fileContent, /@returns\s+\{Promise<Blob>\}/)
assert.match(
fileContent,
/const exportApi = \(params = \{\}\) =>\s+request\.get\(buildUrl\(`\/api\/v1\/File\/Export`, params\), \{\s*responseType: ["']blob["'],?\s*\}\)/s,
)
assert.match(
fileContent,
/const createExportApi = \(data\) =>\s+request\.post\(`\/api\/v1\/File\/CreateExport`, data, \{\s*responseType: ["']blob["'],?\s*\}\)/s,
)
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
})
test('untyped export endpoints infer blob request config from operation metadata', async () => {
const tempDir = await createTempDir()
try {
const swaggerPath = path.join(tempDir, 'swagger.json')
const outputDir = path.join(tempDir, 'generated')
await writeJson(swaggerPath, {
openapi: '3.0.0',
paths: {
'/api/v1/Report/GetUserPointRecordFile': {
get: {
tags: ['Report'],
summary: '导出积分排行榜',
responses: {
200: {
description: 'OK',
},
},
},
},
'/api/v1/Report/GetExportJob': {
get: {
tags: ['Report'],
summary: '获取导出任务状态',
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
status: {
type: 'string',
},
},
},
},
},
},
},
},
},
},
})
await generateApiFiles({
projectRoot: tempDir,
swaggerUrl: swaggerPath,
swaggerTimeoutMs: 1000,
outputDir,
requestImport: '../request',
paramStyle: 'object',
modules: [],
cleanOutput: true,
})
const reportContent = await readFile(path.join(outputDir, 'report.js'))
assert.match(
reportContent,
/const getUserPointRecordFileApi = \(\) =>\s+request\.get\(`\/api\/v1\/Report\/GetUserPointRecordFile`, \{\s*responseType: ["']blob["'],?\s*\}\)/s,
)
assert.match(
reportContent,
/const getExportJobApi = \(\) =>\s+request\.get\(`\/api\/v1\/Report\/GetExportJob`\)/s,
)
assert.doesNotMatch(
reportContent,
/request\.get\(`\/api\/v1\/Report\/GetExportJob`, \{\s*responseType: ["']blob["']/s,
)
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
})
test('response jsdoc expands nested array object fields', async () => { test('response jsdoc expands nested array object fields', async () => {
const tempDir = await createTempDir() const tempDir = await createTempDir()