Como documentar APIs de upload de arquivos com Swagger e OpenAPI
Um endpoint de upload no OpenAPI precisa de dois requisitos distintos: um corpo de requisição obrigatório e uma propriedade de arquivo obrigatória dentro desse corpo. Este passo a passo cria uma API Express local cujo Swagger UI permite que você faça upload de arquivos e baixe os mesmos bytes, com documentação dos campos, limites, chave de API e erros que o servidor realmente usa.
O que são Swagger e OpenAPI?
O OpenAPI descreve o contrato HTTP; o Swagger UI transforma esse documento em um cliente interativo.
Aqui, swagger-jsdoc gera um documento OpenAPI 3.0.4 a partir de definições compartilhadas e de comentários ao lado
das rotas. A API de arquivos tem três operações: POST /upload, POST /uploads e
GET /download/{filename}. As respostas de upload contêm metadados, enquanto a resposta de download
contém os bytes armazenados.
A distinção entre requestBody.required: true e o required: [file] do objeto é importante:
o primeiro exige um corpo; o segundo exige essa propriedade. Uploads múltiplos também precisam de
required: [files], minItems: 1 e maxItems: 10. No OpenAPI 3.0, cada arquivo usa
type: string com format: binary. Essas declarações descrevem requisições; o Express e o Multer
ainda precisam aplicá-las. Consulte as definições de corpo de requisição e de schema do OpenAPI.
Como configurar o Swagger no seu projeto
Use um shell compatível com Bash, Node.js 26 e Corepack com Yarn 4 disponível. Instale o cURL se quiser usar o comando alternativo de download. O exemplo foi testado no Linux com Node.js 26.8.1 e Yarn 4.12.0. O Node executa esses arquivos TypeScript usando a remoção de tipos integrada; isso não faz verificação de tipos.
Execute isto em um diretório onde você queira um projeto file-api-swagger novo. Os comandos
encadeados param se o diretório já existir ou se a navegação falhar. O lockfile vazio marca este como
um projeto Yarn separado, inclusive quando você o cria dentro de outro checkout.
mkdir file-api-swagger &&
cd file-api-swagger &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
touch yarn.lock &&
corepack yarn config set nodeLinker node-modules &&
corepack yarn add --exact express@5.2.1 multer@2.4.0 swagger-jsdoc@6.3.0 swagger-ui-express@5.0.1 swagger-ui-dist@5.33.0
Mantenha o yarn.lock gerado para instalações reproduzíveis. Fixar swagger-ui-dist também define a
versão da interface do navegador, que o swagger-ui-express de outra forma resolveria pelo seu intervalo de dependência.
A versão 2.4.0 do Multer inclui uma correção
de segurança; verifique avisos de segurança mais recentes antes de reutilizar essas versões fixadas
em uma aplicação implantada.
Crie os dois arquivos a seguir em file-api-swagger. Cada bloco é o arquivo completo.
Definir os schemas compartilhados em openapi.ts
Os schemas de resposta exigem todos os campos retornados pelo servidor. Todas as operações
documentadas herdam o requisito X-API-Key. Uma URL de servidor relativa mantém o Swagger UI na mesma
origem mesmo quando o sistema operacional atribui uma porta diferente.
import { join } from 'node:path'
import swaggerJsdoc from 'swagger-jsdoc'
function errorResponse(description: string): object {
return {
description,
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}
}
export const swaggerSpec = swaggerJsdoc({
failOnErrors: true,
definition: {
openapi: '3.0.4',
info: { title: 'File Upload API', version: '1.0.0' },
servers: [{ url: '/' }],
security: [{ ApiKeyAuth: [] }],
components: {
securitySchemes: {
ApiKeyAuth: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
},
schemas: {
File: {
type: 'object',
additionalProperties: false,
required: ['id', 'name', 'size', 'mimetype'],
properties: {
id: { type: 'string', pattern: '^[a-f0-9]{32}$' },
name: { type: 'string', description: 'Client filename, not a storage path.' },
size: { type: 'integer', minimum: 0, maximum: 5242880 },
mimetype: { type: 'string', enum: ['image/jpeg', 'image/png', 'application/pdf'] },
},
},
SingleUpload: {
type: 'object',
additionalProperties: false,
required: ['file'],
properties: { file: { $ref: '#/components/schemas/File' } },
},
MultipleUpload: {
type: 'object',
additionalProperties: false,
required: ['files'],
properties: {
files: {
type: 'array', minItems: 1, maxItems: 10,
items: { $ref: '#/components/schemas/File' },
},
},
},
Error: {
type: 'object',
additionalProperties: false,
required: ['error'],
properties: { error: { type: 'string' } },
},
},
responses: {
BadUpload: errorResponse('Missing file, wrong field, too many files, or malformed multipart body.'),
Unauthorized: errorResponse('Missing or incorrect X-API-Key.'),
TooLarge: errorResponse('A file exceeds 5 MiB (5,242,880 bytes).'),
UnsupportedType: errorResponse('Expected multipart/form-data with JPEG, PNG, or PDF part types.'),
NotFound: errorResponse('Unknown or invalid generated file ID.'),
ServerError: errorResponse('File operation failed.'),
},
},
},
apis: [join(import.meta.dirname, 'server.ts')],
})
failOnErrors faz com que erros de análise das anotações façam a inicialização falhar, conforme descrito
pelo swagger-jsdoc. Ele não compara o comportamento das rotas
com o documento. As verificações mais adiante neste passo a passo tratam dessa lacuna.
Documentação dos endpoints de upload de arquivos
Salve este arquivo completo como server.ts. Ele autentica antes de analisar os uploads, armazena os
arquivos com nomes gerados pelo Multer em uploads/ e expõe esse diretório somente pela rota de
download autenticada. Os limites do Multer
restringem cada arquivo e cada requisição; campos de texto são rejeitados.
import { timingSafeEqual } from 'node:crypto'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import express from 'express'
import type { ErrorRequestHandler, RequestHandler } from 'express'
import multer from 'multer'
import swaggerUi from 'swagger-ui-express'
import { swaggerSpec } from './openapi.ts'
const apiKey = process.env.FILE_API_KEY
if (!apiKey) {
console.error('Set FILE_API_KEY before starting the server.')
process.exit(1)
}
const portText = process.env.PORT ?? '0'
const port = Number(portText)
if (!/^\d+$/.test(portText) || !Number.isInteger(port) || port > 65535) {
console.error('PORT must be an integer from 0 to 65535.')
process.exit(1)
}
const expectedKey = Buffer.from(apiKey)
const requireApiKey: RequestHandler = (req, res, next) => {
const suppliedKey = Buffer.from(req.get('X-API-Key') ?? '')
if (suppliedKey.length !== expectedKey.length || !timingSafeEqual(suppliedKey, expectedKey)) {
res.status(401).json({ error: 'Unauthorized' })
return
}
next()
}
class UnsupportedFileTypeError extends Error {}
const uploadsDir = join(import.meta.dirname, 'uploads')
mkdirSync(uploadsDir, { recursive: true, mode: 0o700 })
const upload = multer({
dest: uploadsDir,
limits: { fileSize: 5 * 1024 * 1024, files: 10, fields: 0 },
fileFilter: (_req, file, callback) => {
if (!['image/jpeg', 'image/png', 'application/pdf'].includes(file.mimetype)) {
callback(new UnsupportedFileTypeError())
return
}
callback(null, true)
},
})
function metadata(file: Express.Multer.File): object {
return { id: file.filename, name: file.originalname, size: file.size, mimetype: file.mimetype }
}
const app = express()
app.get('/openapi.json', (_req, res) => res.json(swaggerSpec))
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(null, {
swaggerOptions: { url: '/openapi.json', validatorUrl: null },
}))
app.use(['/upload', '/uploads', '/download'], requireApiKey)
app.use(['/upload', '/uploads'], (req, res, next) => {
if (!req.is('multipart/form-data')) {
res.status(415).json({ error: 'Expected multipart/form-data' })
return
}
next()
})
/**
* @openapi
* /upload:
* post:
* operationId: uploadFile
* summary: Upload one file
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* additionalProperties: false
* required: [file]
* properties:
* file:
* type: string
* format: binary
* description: At most 5 MiB. Empty files are accepted.
* encoding:
* file:
* contentType: image/jpeg, image/png, application/pdf
* responses:
* '200':
* description: Stored file metadata.
* content:
* application/json:
* schema: { $ref: '#/components/schemas/SingleUpload' }
* '400': { $ref: '#/components/responses/BadUpload' }
* '401': { $ref: '#/components/responses/Unauthorized' }
* '413': { $ref: '#/components/responses/TooLarge' }
* '415': { $ref: '#/components/responses/UnsupportedType' }
* '500': { $ref: '#/components/responses/ServerError' }
*/
app.post('/upload', upload.single('file'), (req, res) => {
if (!req.file) {
res.status(400).json({ error: 'No file uploaded' })
return
}
res.json({ file: metadata(req.file) })
})
/**
* @openapi
* /uploads:
* post:
* operationId: uploadFiles
* summary: Upload up to ten files
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* additionalProperties: false
* required: [files]
* properties:
* files:
* type: array
* minItems: 1
* maxItems: 10
* items:
* type: string
* format: binary
* description: At most 5 MiB. Empty files are accepted.
* encoding:
* files:
* contentType: image/jpeg, image/png, application/pdf
* responses:
* '200':
* description: Stored metadata in upload order.
* content:
* application/json:
* schema: { $ref: '#/components/schemas/MultipleUpload' }
* '400': { $ref: '#/components/responses/BadUpload' }
* '401': { $ref: '#/components/responses/Unauthorized' }
* '413': { $ref: '#/components/responses/TooLarge' }
* '415': { $ref: '#/components/responses/UnsupportedType' }
* '500': { $ref: '#/components/responses/ServerError' }
*/
app.post('/uploads', upload.array('files', 10), (req, res) => {
if (!Array.isArray(req.files) || req.files.length === 0) {
res.status(400).json({ error: 'No files uploaded' })
return
}
res.json({ files: req.files.map(metadata) })
})
/**
* @openapi
* /download/{filename}:
* get:
* operationId: downloadFile
* summary: Download a stored file
* parameters:
* - in: path
* name: filename
* required: true
* description: Generated ID returned by an upload, not the client filename.
* schema: { type: string, pattern: '^[a-f0-9]{32}$' }
* responses:
* '200':
* description: Original bytes, downloaded with the generated ID as the filename.
* headers:
* Content-Disposition:
* schema: { type: string }
* description: Attachment using the generated file ID.
* content:
* application/octet-stream:
* schema: { type: string, format: binary }
* '401': { $ref: '#/components/responses/Unauthorized' }
* '404': { $ref: '#/components/responses/NotFound' }
* '500': { $ref: '#/components/responses/ServerError' }
*/
app.get('/download/:filename', (req, res, next) => {
const id = req.params.filename
if (!/^[a-f0-9]{32}$/.test(id)) {
res.status(404).json({ error: 'File not found' })
return
}
res.set('X-Content-Type-Options', 'nosniff')
res.download(id, { root: uploadsDir }, (error) => {
if (!error) return
if (res.headersSent) return next(error)
if ('code' in error && error.code === 'ENOENT') {
res.status(404).json({ error: 'File not found' })
return
}
next(error)
})
})
const handleError: ErrorRequestHandler = (error: unknown, _req, res, next) => {
if (res.headersSent) return next(error)
if (error instanceof UnsupportedFileTypeError) {
res.status(415).json({ error: 'Unsupported file type' })
return
}
if (error instanceof multer.MulterError) {
res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: 'Upload rejected' })
return
}
if (error instanceof Error && ['Multipart: Boundary not found', 'Unexpected end of form',
'Malformed part header'].includes(error.message)) {
res.status(400).json({ error: 'Invalid multipart body' })
return
}
console.error('File operation failed')
res.status(500).json({ error: 'File operation failed' })
}
app.use(handleError)
const server = app.listen(port, '127.0.0.1', (error?: Error) => {
if (error) {
console.error(`Could not listen on 127.0.0.1:${port}: ${error.message}`)
process.exitCode = 1
return
}
const address = server.address()
if (address && typeof address === 'object') {
console.log(`Swagger UI: http://127.0.0.1:${address.port}/api-docs/`)
}
})
A rota de arquivo único aceita uma parte chamada file. A rota em lote aceita de uma a 10 partes,
todas chamadas files, e não files[]. Ambas rejeitam campos de texto extras e campos de arquivo não
reconhecidos. Um arquivo deve ter no máximo 5 MiB: o Multer 2.4.0
aceita exatamente 5.242.880 bytes e rejeita um arquivo maior.
Esse limite é descrito no contrato, e não codificado como maxLength, que é uma restrição de
comprimento de string e não uma instrução portátil de limite de bytes para multipart. Arquivos
nomeados vazios são aceitos.
Os tipos MIME permitidos para as partes são JPEG, PNG e PDF. Eles são declarações do cliente: um
arquivo de texto enviado com Content-Type: application/pdf passa por esse filtro. Nem o filtro nem encoding.contentType
provam que um arquivo é um PDF ou uma imagem válida.
Fazer upload pelo Swagger UI
No diretório do projeto, inicie o servidor com uma chave usada somente nesta demonstração local:
FILE_API_KEY=local-demo-key node server.ts
Abra a URL exibida. A porta padrão é 0, que pede ao sistema operacional uma porta
disponível; você pode definir PORT para escolher uma. Chaves ausentes, portas inválidas e portas
ocupadas fazem a inicialização falhar.
O callback verifica o argumento de erro porque o Express 5 repassa a ele as falhas de listen.
- Selecione Authorize, digite
local-demo-keyem Value: e depois selecione Authorize e Close. - Expanda
POST /upload, selecione Try it out, escolha um JPEG, PNG ou PDF pequeno parafilee selecione Execute. - Verifique se a resposta do servidor é
200e contémfile.id,file.name,file.sizeefile.mimetype. Copiefile.idpara a etapa de download. - Em
POST /uploads, escolha o primeiro arquivo e use Add string item para cada arquivo adicional; depois selecione Execute. O arrayfilesda resposta contém um objeto de metadados para cada arquivo aceito.
O documento gerado fica disponível em /openapi.json na mesma origem. O Swagger UI carrega esse
endpoint usando a configuração de URL do wrapper.
O documento público descreve o cabeçalho da chave, mas não contém o valor da chave.
Documentação dos endpoints de download de arquivos
Expanda GET /download/{filename} no Swagger UI, selecione
Try it out e cole o ID gerado em filename.
Selecione Execute e depois
Download file na resposta. Salve esse arquivo e compare-o com
o original. O nome do anexo é o ID gerado, então ele não terá a extensão original.
Para um arquivo vazio, o Swagger UI 5.33.0 recebe 200 com Content-Length: 0, mas não exibe um
link de download. Nesse caso, você pode salvar a resposta com o cURL. Execute o comando a seguir em um
segundo terminal e cole a URL completa de download, incluindo a porta e o ID gerado. Este comando
substitui downloaded.bin se ele já existir; compare-o com o original depois que o comando for
concluído com sucesso.
read -r -p 'Paste the full download URL: ' download_url &&
curl -fsSL -H 'X-API-Key: local-demo-key' -o downloaded.bin "$download_url"
A resposta bem-sucedida usa application/octet-stream e o schema binário do OpenAPI. O servidor
verifica o formato do ID e o resolve dentro do diretório privado de uploads. Fazer um novo upload com
o mesmo nome de arquivo do cliente cria um novo ID; isso não sobrescreve o upload anterior. Os
arquivos armazenados sobrevivem à reinicialização do servidor. Pare o servidor com Ctrl+C e remova o
diretório uploads/ deste projeto de demonstração quando não precisar mais dos arquivos dele; não há
política de retenção automática.
Comparar o contrato com as respostas reais
Você também pode importar no Postman a URL /openapi.json do servidor em execução
e fornecer o cabeçalho X-API-Key. Defina a URL base da coleção importada como a origem do servidor
local, incluindo a porta atribuída; o documento usa uma URL de servidor relativa.
Seja qual for o cliente que você usar, deixe que ele gere o boundary do multipart. Definir por conta
própria um cabeçalho Content-Type: multipart/form-data simples omite esse boundary.
Verifique mais do que a resposta bem-sucedida. Nos casos a seguir, o corpo de erro JSON tem exatamente
uma string error. Use um cliente HTTP direto para as requisições inválidas que o Swagger UI impede
você de enviar.
| Requisição | Status esperado |
|---|---|
| Upload ou download sem a chave ou com uma chave incorreta | 401 |
| Upload multipart sem um arquivo nomeado | 400 |
Duas partes file em /upload ou 11 partes files em /uploads | 400 |
| Um campo de texto extra ou um campo de arquivo não reconhecido | 400 |
| Um arquivo maior que 5.242.880 bytes | 413 |
Uma parte declarada como text/plain ou um corpo de upload que não é multipart | 415 |
| Download com um ID inválido ou um ID hexadecimal desconhecido de 32 caracteres | 404 |
Faça também o upload de um arquivo vazio com um tipo MIME declarado permitido e de um arquivo binário contendo bytes de valor zero e bytes não UTF-8; depois compare os dois downloads byte a byte. Uma validação de schema bem-sucedida, por si só, não consegue comprovar que o servidor preservou esses bytes. Se você mudar os campos, o limite de quantidade ou o formato da resposta, altere tanto a implementação quanto o documento OpenAPI e repita essas requisições.
Considerações de segurança
Essa chave concede acesso a todo o armazenamento da demonstração. A declaração de segurança do OpenAPI apenas informa aos clientes como enviá-la; o middleware faz a verificação. Um serviço com vários usuários precisa de credenciais separadas e de autorização por arquivo. A demonstração não tem cota total de armazenamento nem limite de taxa de requisições.
Mantenha este exemplo HTTP no loopback. Um serviço implantado precisa de HTTPS, de uma política de retenção e de validação de conteúdo adequada ao que ele faz com os uploads. Faça o parsing dos arquivos ou uma varredura de segurança neles antes de processá-los ou de servi-los inline. Manter nomes de arquivo gerados e downloads autenticados como anexo é útil, mas não transforma metadados MIME fornecidos pelo cliente em validação de conteúdo confiável.
