Documenting file upload APIs with Swagger and OpenAPI
An OpenAPI upload endpoint needs two separate requirements: a required request body and a required file property inside that body. This walkthrough builds a local Express API whose Swagger UI lets you upload files and download the same bytes, with documentation for the fields, limits, API key, and errors the server actually uses.
What are Swagger and OpenAPI?
OpenAPI describes the HTTP contract; Swagger UI turns that document into an interactive client.
Here, swagger-jsdoc generates an OpenAPI 3.0.4 document from shared definitions and comments beside
the routes. The file API has three operations: POST /upload, POST /uploads, and
GET /download/{filename}. Its upload responses contain metadata, while its download response
contains the stored bytes.
The distinction between requestBody.required: true and the object's required: [file] matters:
the first requires a body; the second requires that property. Multiple uploads also need
required: [files], minItems: 1, and maxItems: 10. In OpenAPI 3.0, each file uses
type: string with format: binary. These declarations describe requests; Express and Multer must
still enforce them. See the OpenAPI request-body and schema definitions.
Setting up Swagger in your project
Use a Bash-compatible shell, Node.js 26, and Corepack with Yarn 4 available. Install cURL if you want to use the fallback download command. The example was tested on Linux with Node.js 26.8.1 and Yarn 4.12.0. Node runs these TypeScript files using its built-in type stripping; this does not perform a type check.
Run this from a directory where you want a new file-api-swagger project. The chained commands
stop if the directory already exists or navigation fails. The empty lockfile marks this as a
separate Yarn project, including when you create it inside another 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
Keep the generated yarn.lock for repeatable installs. Pinning swagger-ui-dist also fixes the
browser UI version, which swagger-ui-express otherwise resolves through its dependency range.
The Multer 2.4.0 release includes a security
fix; check newer advisories before reusing these pins in a deployed application.
Create the following two files in file-api-swagger. Each block is the complete file.
Define the shared schemas in openapi.ts
The response schemas require every field returned by the server. All documented operations inherit
the X-API-Key requirement. A relative server URL keeps Swagger UI on the same origin even when
the operating system assigns a different port.
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 makes annotation parsing errors fail startup, as described by
swagger-jsdoc. It does not compare the routes' behavior
with the document. The checks later in this walkthrough address that gap.
Documenting file upload endpoints
Save this complete file as server.ts. It authenticates before parsing uploads, stores files under
Multer-generated names in uploads/, and exposes that directory only through the authenticated
download route. The Multer limits
bound each file and each request; text fields are rejected.
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/`)
}
})
The single route accepts one part named file. The batch route accepts one to 10 parts, all named
files, not files[]. Both reject extra text fields and unrecognized file fields. A file must be
at most 5 MiB: Multer 2.4.0
accepts exactly 5,242,880 bytes and rejects a larger file.
That limit is described in the contract, not encoded as maxLength, which is a string-length
constraint rather than a portable multipart byte-limit instruction. Empty named files are accepted.
The allowed part MIME types are JPEG, PNG, and PDF. They are client declarations: a text file sent
with Content-Type: application/pdf passes this filter. Neither the filter nor encoding.contentType
proves that a file is a valid PDF or image.
Upload through Swagger UI
From the project directory, start the server with a key used only for this local demo:
FILE_API_KEY=local-demo-key node server.ts
Open the printed URL. The default port is 0, which asks the operating system for an available
port; you can set PORT to choose one. Missing keys, invalid ports, and occupied ports fail startup.
The callback checks the error argument because Express 5 passes listen failures to it.
- Select Authorize, enter
local-demo-keyin Value:, then select Authorize and Close. - Expand
POST /upload, select Try it out, choose a small JPEG, PNG, or PDF forfile, and select Execute. - Check that the server response is
200and containsfile.id,file.name,file.size, andfile.mimetype. Copyfile.idfor the download step. - For
POST /uploads, choose the first file and use Add string item for each additional file, then select Execute. The response'sfilesarray contains one metadata object for each accepted file.
The generated document is available at /openapi.json on the same origin. Swagger UI loads that
endpoint using the wrapper's URL configuration.
The public document describes the key header but does not contain the key's value.
Documenting file download endpoints
Expand GET /download/{filename} in Swagger UI, select
Try it out, and paste the generated ID into filename.
Select Execute, then
Download file in the response. Save that file and compare it with
your original. The attachment name is the generated ID, so it will not have the original extension.
For an empty file, Swagger UI 5.33.0 receives 200 with Content-Length: 0 but does not show a
download link. You can save that response with cURL instead. Run the following in a second terminal
and paste the full download URL, including the port and generated ID. This command replaces
downloaded.bin if it already exists; compare it with your original after the command succeeds.
read -r -p 'Paste the full download URL: ' download_url &&
curl -fsSL -H 'X-API-Key: local-demo-key' -o downloaded.bin "$download_url"
The successful response uses application/octet-stream and the OpenAPI binary schema. The server
checks the ID's format and resolves it beneath the private upload directory. Uploading the same
client filename again creates a new ID; it does not overwrite the earlier upload. Stored files
survive a server restart. Stop the server with Ctrl+C and remove this demo project's uploads/
directory when you no longer need its files; there is no automatic retention policy.
Compare the contract with actual responses
You can also import the running /openapi.json URL into Postman
and supply the X-API-Key header. Set the imported collection's base URL to the local server's
origin, including its assigned port; the document uses a relative server URL.
Whichever client you use, let it generate the multipart boundary. Setting a bare
Content-Type: multipart/form-data header yourself omits that boundary.
Check more than the successful response. For the following cases, the JSON error body has exactly
one error string. Use a direct HTTP client for invalid requests that Swagger UI prevents you from
sending.
| Request | Expected status |
|---|---|
| Upload or download without the key, or with an incorrect key | 401 |
| Multipart upload without a named file | 400 |
Two file parts on /upload, or 11 files parts on /uploads | 400 |
| An extra text field or an unrecognized file field | 400 |
| A file larger than 5,242,880 bytes | 413 |
A part declared as text/plain, or a non-multipart upload body | 415 |
| Download with an invalid ID or an unknown 32-character hexadecimal ID | 404 |
Also upload an empty file with an allowed declared MIME type and a binary file containing zero and non-UTF-8 bytes, then compare both downloads byte for byte. A successful schema validation alone cannot establish that the server preserved those bytes. If you change the fields, count limit, or response shape, change both the implementation and the OpenAPI document and repeat these requests.
Security considerations
This key grants access to the entire demo store. The OpenAPI security declaration only tells clients how to send it; the middleware performs the check. A service with multiple users needs separate credentials and per-file authorization. The demo has no total storage quota or request rate limit.
Keep this HTTP example on loopback. A deployed service needs HTTPS, a retention policy, and content validation appropriate to what it does with uploads. Parse or scan files before processing them or serving them inline. Keeping generated filenames and authenticated attachment downloads is useful, but does not turn client-supplied MIME metadata into trusted content validation.
