diff --git a/README.md b/README.md index 4caf283..77e90aa 100644 --- a/README.md +++ b/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`,它只维护一段带开始和结束标记的受管区块。 diff --git a/src/core/generate.js b/src/core/generate.js index 7939aca..853fa6b 100644 --- a/src/core/generate.js +++ b/src/core/generate.js @@ -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' + : `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})` - : `request.post(${urlExpression})` + ? 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})` - : `request.put(${urlExpression})` + ? 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})` - : `request.patch(${urlExpression})` + ? 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})` - : `request.${method}(${urlExpression})` + ? 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) { - return null - } - - const preferredResponse = - responses['200'] || - responses['201'] || - responses.default || - Object.values(responses).find((response) => response?.content) + const preferredResponse = getPreferredResponse(responses) 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 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:', diff --git a/test/generate.test.js b/test/generate.test.js index 565f238..7a5f002 100644 --- a/test/generate.test.js +++ b/test/generate.test.js @@ -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\}/) + 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()