Documentar API de subida de archivos con Swagger y OpenAPI
Un endpoint de subida de OpenAPI necesita dos requisitos distintos: un cuerpo de solicitud obligatorio y una propiedad de archivo obligatoria dentro de ese cuerpo. Esta guía crea una API local con Express cuya Swagger UI permite subir archivos y descargar los mismos bytes, con documentación de los campos, límites, la clave de API y los errores que el servidor utiliza realmente.
¿Qué son Swagger y OpenAPI?
OpenAPI describe el contrato HTTP; Swagger UI convierte ese documento en un cliente interactivo.
Aquí, swagger-jsdoc genera un documento OpenAPI 3.0.4 a partir de definiciones
compartidas y comentarios junto a las rutas. La API de archivos tiene tres operaciones:
POST /upload, POST /uploads y
GET /download/{filename}. Sus respuestas de subida contienen metadatos, mientras que su respuesta
de descarga contiene los bytes almacenados.
La distinción entre requestBody.required: true y required: [file] del objeto importa:
el primero exige un cuerpo; el segundo exige esa propiedad. Las subidas múltiples también necesitan
required: [files], minItems: 1 y maxItems: 10.
En OpenAPI 3.0, cada archivo usa type: string con format: binary.
Estas declaraciones describen las solicitudes; Express y Multer deben hacerlas cumplir de todos modos.
Consulta las definiciones de cuerpos de solicitud y esquemas de OpenAPI.
Configura Swagger en tu proyecto
Usa una terminal compatible con Bash, Node.js 26 y Corepack con Yarn 4 disponible. Instala cURL si quieres usar el comando alternativo de descarga. El ejemplo se probó en Linux con Node.js 26.8.1 y Yarn 4.12.0. Node ejecuta estos archivos TypeScript mediante su eliminación de tipos integrada; esto no comprueba los tipos.
Ejecuta lo siguiente desde un directorio donde quieras crear un proyecto
file-api-swagger nuevo. Los comandos encadenados se detienen si el directorio ya
existe o si falla el cambio de directorio. El archivo de bloqueo vacío lo identifica como un proyecto
Yarn independiente, incluso cuando lo creas dentro de otra copia de trabajo.
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
Conserva el archivo yarn.lock generado para obtener instalaciones reproducibles.
Fijar swagger-ui-dist también fija la versión de la interfaz del navegador, que
swagger-ui-express resolvería de otro modo mediante su rango de dependencias.
La versión 2.4.0 de Multer incluye una corrección de seguridad;
consulta los avisos más recientes antes de reutilizar estas versiones fijas en una aplicación
implementada en producción.
Crea los siguientes dos archivos en file-api-swagger. Cada bloque es el archivo completo.
Define los esquemas compartidos en openapi.ts
Los esquemas de respuesta exigen todos los campos que devuelve el servidor. Todas las operaciones
documentadas heredan el requisito de X-API-Key. Una URL de servidor relativa
mantiene Swagger UI en el mismo origen, incluso cuando el sistema operativo asigna otro puerto.
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 hace que los errores de análisis de las anotaciones impidan el inicio,
como describe swagger-jsdoc. No compara el comportamiento de las rutas
con el documento. Las comprobaciones posteriores de esta guía cubren esa carencia.
Documentación de endpoints de subida de archivos
Guarda este archivo completo como server.ts. Autentica antes de analizar las
subidas, almacena los archivos con nombres generados por Multer en uploads/ y
expone ese directorio solo mediante la ruta de descarga autenticada.
Los límites de Multer restringen cada archivo y cada solicitud;
los campos de texto se rechazan.
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/`)
}
})
La ruta individual acepta una parte llamada file. La ruta por lotes acepta
de una a 10 partes, todas llamadas files, no files[].
Ambas rechazan los campos de texto adicionales y los campos de archivo no reconocidos. Un archivo
debe tener como máximo 5 MiB: Multer 2.4.0 acepta exactamente
5.242.880 bytes y rechaza los archivos más grandes.
Ese límite se describe en el contrato, no se codifica como maxLength, que es una
restricción de longitud de cadena y no una instrucción portable para limitar los bytes de multipart.
Se aceptan archivos vacíos con nombre.
Los tipos MIME permitidos para las partes son JPEG, PNG y PDF. Son declaraciones del cliente: un
archivo de texto enviado con Content-Type: application/pdf pasa este filtro. Ni el filtro ni
encoding.contentType demuestran que un archivo sea un PDF o una imagen válidos.
Sube archivos mediante Swagger UI
Desde el directorio del proyecto, inicia el servidor con una clave usada solo para esta demo local:
FILE_API_KEY=local-demo-key node server.ts
Abre la URL que aparece en la terminal. El puerto predeterminado es 0,
que solicita al sistema operativo un puerto disponible; puedes establecer
PORT para elegir uno. Las claves ausentes, los puertos no válidos y los
puertos ocupados impiden el inicio. El callback comprueba el argumento de error porque
Express 5 le pasa los fallos de escucha.
- Selecciona Authorize, introduce
local-demo-keyen Value: y luego selecciona Authorize y Close. - Expande
POST /upload, selecciona Try it out, elige un JPEG, PNG o PDF pequeño parafiley selecciona Execute. - Comprueba que la respuesta del servidor sea
200y contengafile.id,file.name,file.sizeyfile.mimetype. Copiafile.idpara el paso de descarga. - Para
POST /uploads, elige el primer archivo y usa Add string item para cada archivo adicional; luego selecciona Execute. El arrayfilesde la respuesta contiene un objeto de metadatos por cada archivo aceptado.
El documento generado está disponible en /openapi.json en el mismo origen.
Swagger UI carga ese endpoint mediante la
configuración de URL del wrapper.
El documento público describe el encabezado de la clave, pero no contiene su valor.
Documentación de endpoints de descarga de archivos
Expande GET /download/{filename} en Swagger UI, selecciona
Try it out y pega el ID generado en filename.
Selecciona Execute y luego
Download file en la respuesta. Guarda ese archivo y compáralo con
el original. El nombre del archivo adjunto es el ID generado, por lo que no tendrá la extensión original.
Para un archivo vacío, Swagger UI 5.33.0 recibe 200 con
Content-Length: 0, pero no muestra un enlace de descarga. En su lugar, puedes guardar esa
respuesta con cURL. Ejecuta lo siguiente en una segunda terminal y pega la URL de descarga completa,
incluidos el puerto y el ID generado. Este comando reemplaza downloaded.bin si ya
existe; compáralo con el original cuando el comando se complete correctamente.
read -r -p 'Paste the full download URL: ' download_url &&
curl -fsSL -H 'X-API-Key: local-demo-key' -o downloaded.bin "$download_url"
La respuesta exitosa usa application/octet-stream y el esquema binario de OpenAPI. El servidor
comprueba el formato del ID y resuelve su ruta dentro del directorio privado de subidas. Volver a
subir un archivo con el mismo nombre de archivo del cliente crea un ID nuevo; no sobrescribe la
subida anterior. Los archivos almacenados persisten tras reiniciar el servidor. Detén el servidor
con Ctrl+C y elimina el directorio uploads/ de este proyecto de demostración
cuando ya no necesites sus archivos; no hay una política de retención automática.
Compara el contrato con las respuestas reales
También puedes importar en Postman la URL de /openapi.json en ejecución
y proporcionar el encabezado X-API-Key. Establece la URL base de la colección
importada en el origen del servidor local, incluido su puerto asignado; el documento usa una URL de
servidor relativa. Sea cual sea el cliente que uses, deja que genere el delimitador multipart.
Si estableces por tu cuenta un encabezado Content-Type: multipart/form-data sin más, se omite ese delimitador.
No compruebes solo la respuesta exitosa. Para los siguientes casos, el cuerpo JSON del error tiene
exactamente una cadena error. Usa un cliente HTTP directo para las
solicitudes no válidas que Swagger UI no te permite enviar.
| Solicitud | Estado esperado |
|---|---|
| Subida o descarga sin la clave, o con una clave incorrecta | 401 |
| Subida multipart sin un archivo con nombre | 400 |
Dos partes file en /upload, u 11 partes files en /uploads | 400 |
| Un campo de texto adicional o un campo de archivo no reconocido | 400 |
| Un archivo de más de 5.242.880 bytes | 413 |
Una parte declarada como text/plain, o un cuerpo de subida que no sea multipart | 415 |
| Descarga con un ID no válido o un ID hexadecimal desconocido de 32 caracteres | 404 |
Sube también un archivo vacío con un tipo MIME declarado permitido y un archivo binario que contenga bytes de valor cero y bytes no válidos en UTF-8; luego compara ambas descargas byte por byte. Una validación de esquema exitosa por sí sola no puede determinar que el servidor haya conservado esos bytes. Si cambias los campos, el límite de cantidad o la estructura de la respuesta, cambia tanto la implementación como el documento OpenAPI y repite estas solicitudes.
Consideraciones de seguridad
Esta clave concede acceso a todo el almacenamiento de la demo. La declaración de seguridad de OpenAPI solo indica a los clientes cómo enviarla; el middleware realiza la comprobación. Un servicio con varios usuarios necesita credenciales independientes y autorización por archivo. La demo no tiene un cupo total de almacenamiento ni un límite de frecuencia de solicitudes.
Mantén este ejemplo HTTP en la interfaz de loopback. Un servicio desplegado necesita HTTPS, una política de retención y una validación del contenido adecuada al uso que haga de las subidas. Analiza la estructura de los archivos o escanéalos en busca de amenazas antes de procesarlos o servirlos para su visualización en el navegador. Mantener los nombres de archivo generados y las descargas autenticadas como archivos adjuntos es útil, pero no convierte los metadatos MIME proporcionados por el cliente en una validación confiable del contenido.
