添加文件下载方案
This commit is contained in:
parent
4dc4427f2a
commit
a2d1fb3875
14
README.md
14
README.md
|
|
@ -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` 不会粗暴覆盖整个 `externalIndexFile`,它只维护一段带开始和结束标记的受管区块。
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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',
|
||||
|
|
@ -952,7 +953,13 @@ const generateOperationBlock = (
|
|||
}
|
||||
|
||||
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)
|
||||
|
||||
if (hasParams || hasBody) {
|
||||
|
|
@ -982,6 +989,7 @@ const generateOperationBlock = (
|
|||
urlExpression,
|
||||
hasBody,
|
||||
signatureInfo.bodyArgName,
|
||||
hasBlobResponse ? BLOB_RESPONSE_TYPE : null,
|
||||
)
|
||||
|
||||
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) {
|
||||
case 'get':
|
||||
return `request.get(${urlExpression})`
|
||||
return requestConfigExpression
|
||||
? `request.get(${urlExpression}, ${requestConfigExpression})`
|
||||
: `request.get(${urlExpression})`
|
||||
case 'post':
|
||||
return hasBody
|
||||
? `request.post(${urlExpression}, ${bodyArgName})`
|
||||
? requestConfigExpression
|
||||
? `request.post(${urlExpression}, ${bodyArgName}, ${requestConfigExpression})`
|
||||
: `request.post(${urlExpression}, ${bodyArgName})`
|
||||
: requestConfigExpression
|
||||
? `request.post(${urlExpression}, undefined, ${requestConfigExpression})`
|
||||
: `request.post(${urlExpression})`
|
||||
case 'put':
|
||||
return hasBody
|
||||
? `request.put(${urlExpression}, ${bodyArgName})`
|
||||
? requestConfigExpression
|
||||
? `request.put(${urlExpression}, ${bodyArgName}, ${requestConfigExpression})`
|
||||
: `request.put(${urlExpression}, ${bodyArgName})`
|
||||
: requestConfigExpression
|
||||
? `request.put(${urlExpression}, undefined, ${requestConfigExpression})`
|
||||
: `request.put(${urlExpression})`
|
||||
case 'patch':
|
||||
return hasBody
|
||||
? `request.patch(${urlExpression}, ${bodyArgName})`
|
||||
? requestConfigExpression
|
||||
? `request.patch(${urlExpression}, ${bodyArgName}, ${requestConfigExpression})`
|
||||
: `request.patch(${urlExpression}, ${bodyArgName})`
|
||||
: requestConfigExpression
|
||||
? `request.patch(${urlExpression}, undefined, ${requestConfigExpression})`
|
||||
: `request.patch(${urlExpression})`
|
||||
case 'delete':
|
||||
return hasBody
|
||||
? `request.delete(${urlExpression}, { data: ${bodyArgName} })`
|
||||
if (hasBody) {
|
||||
return `request.delete(${urlExpression}, ${buildRequestConfigExpression({
|
||||
dataArgName: bodyArgName,
|
||||
responseType,
|
||||
})})`
|
||||
}
|
||||
|
||||
return requestConfigExpression
|
||||
? `request.delete(${urlExpression}, ${requestConfigExpression})`
|
||||
: `request.delete(${urlExpression})`
|
||||
default:
|
||||
return hasBody
|
||||
? `request.${method}(${urlExpression}, ${bodyArgName})`
|
||||
? 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})`
|
||||
|
|
@ -1095,23 +1150,23 @@ const getRequestBodySchemaInfo = (requestBody, schemas) => {
|
|||
}
|
||||
|
||||
const getResponseSchemaInfo = (responses, schemas) => {
|
||||
if (!responses) {
|
||||
const preferredResponse = getPreferredResponse(responses)
|
||||
|
||||
if (!preferredResponse?.content) {
|
||||
if (!preferredResponse?.schema) {
|
||||
return null
|
||||
}
|
||||
|
||||
const preferredResponse =
|
||||
responses['200'] ||
|
||||
responses['201'] ||
|
||||
responses.default ||
|
||||
Object.values(responses).find((response) => response?.content)
|
||||
|
||||
if (!preferredResponse?.content) {
|
||||
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]) => contentType === 'application/json') || contentEntries[0]
|
||||
contentEntries.find(([contentType]) => isJsonContentType(contentType)) || contentEntries[0]
|
||||
|
||||
if (!preferredContent?.[1]?.schema) {
|
||||
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) => {
|
||||
return buildStructuredPropertyDocLines({
|
||||
title: 'Response fields:',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
const tempDir = await createTempDir()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue