Dateiupload-APIs mit Swagger und OpenAPI dokumentieren
Ein OpenAPI-Upload-Endpunkt braucht zwei separate Pflichtangaben: einen erforderlichen Request-Body und eine erforderliche Dateieigenschaft darin. Diese Anleitung erstellt eine lokale Express-API, mit deren Swagger UI Sie Dateien hochladen und dieselben Bytes herunterladen können. Dokumentiert werden die Felder, Limits, der API-Schlüssel und die Fehler, die der Server tatsächlich verwendet.
Was sind Swagger und OpenAPI?
OpenAPI beschreibt den HTTP-Vertrag; Swagger UI macht dieses Dokument zu einem interaktiven Client.
Hier erzeugt swagger-jsdoc ein Dokument nach OpenAPI 3.0.4 aus gemeinsamen Definitionen und
Kommentaren neben den Routen. Die Datei-API bietet drei Vorgänge: POST /upload,
POST /uploads und GET /download/{filename}. Ihre Upload-Antworten enthalten
Metadaten, während die Download-Antwort die gespeicherten Bytes enthält.
Der Unterschied zwischen requestBody.required: true und required: [file] des Objekts
ist wichtig: Ersteres erfordert einen Body, Letzteres diese Eigenschaft. Mehrfach-Uploads benötigen
außerdem required: [files], minItems: 1 und
maxItems: 10. In OpenAPI 3.0 verwendet jede Datei type: string mit
format: binary. Diese Deklarationen beschreiben Anfragen; Express und Multer müssen
sie weiterhin durchsetzen. Siehe die OpenAPI-Definitionen für Request-Body und Schema.
Swagger im Projekt einrichten
Verwenden Sie eine Bash-kompatible Shell, Node.js 26 und Corepack mit verfügbarem Yarn 4. Installieren Sie cURL, wenn Sie den alternativen Download-Befehl nutzen möchten. Das Beispiel wurde unter Linux mit Node.js 26.8.1 und Yarn 4.12.0 getestet. Node führt diese TypeScript-Dateien mit seinem integrierten Type Stripping aus; dabei findet keine Typprüfung statt.
Führen Sie dies in einem Verzeichnis aus, in dem Sie ein neues Projekt namens
file-api-swagger erstellen möchten. Die verketteten Befehle stoppen, wenn das
Verzeichnis bereits existiert oder der Verzeichniswechsel fehlschlägt. Die leere Lockdatei
kennzeichnet es als separates Yarn-Projekt, auch wenn Sie es innerhalb eines anderen Checkouts
anlegen.
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
Behalten Sie die erzeugte Datei yarn.lock für reproduzierbare Installationen.
Das Festlegen der Version von swagger-ui-dist fixiert auch die Version der
Browseroberfläche, die swagger-ui-express sonst anhand seines Abhängigkeitsbereichs
auflöst. Die Version Multer 2.4.0 enthält eine Sicherheitskorrektur;
prüfen Sie neuere Sicherheitshinweise, bevor Sie diese Versionsbindungen in einer bereitgestellten
Anwendung wiederverwenden.
Erstellen Sie die folgenden zwei Dateien in file-api-swagger. Jeder Block enthält
die vollständige Datei.
Gemeinsame Schemas in openapi.ts definieren
Die Antwortschemas verlangen jedes Feld, das der Server zurückgibt. Alle dokumentierten Vorgänge
erben die Anforderung X-API-Key. Eine relative Server-URL hält Swagger UI auf
demselben Ursprung, selbst wenn das Betriebssystem einen anderen Port zuweist.
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 sorgt dafür, dass Fehler beim Parsen von Annotationen den Start
verhindern, wie von swagger-jsdoc beschrieben. Es vergleicht das
Verhalten der Routen nicht mit dem Dokument. Die Prüfungen weiter unten in dieser Anleitung
schließen diese Lücke.
Dateiupload-Endpunkte dokumentieren
Speichern Sie diese vollständige Datei als server.ts. Sie authentifiziert vor
dem Parsen der Uploads, speichert Dateien unter von Multer erzeugten Namen in
uploads/ und macht dieses Verzeichnis nur über die authentifizierte
Download-Route zugänglich. Die Multer-Limits begrenzen jede Datei
und jede Anfrage; Textfelder werden abgelehnt.
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/`)
}
})
Die Route für Einzel-Uploads akzeptiert einen Part namens file.
Die Route für Batch-Uploads akzeptiert einen bis 10 Parts, alle mit dem Namen
files, nicht files[]. Beide lehnen zusätzliche
Textfelder und unbekannte Dateifelder ab. Eine Datei darf höchstens 5 MiB groß sein:
Multer 2.4.0 akzeptiert genau 5.242.880 Bytes und lehnt größere
Dateien ab. Dieses Limit ist im Vertrag beschrieben, nicht als maxLength
festgelegt. Das wäre eine Beschränkung der Zeichenkettenlänge und keine portable Anweisung für
ein Multipart-Byte-Limit. Leere Dateien mit Namen werden akzeptiert.
Die erlaubten MIME-Typen der Parts sind JPEG, PNG und PDF. Es handelt sich um Angaben des Clients:
Eine Textdatei, die mit Content-Type: application/pdf gesendet wird, passiert diesen Filter.
Weder der Filter noch encoding.contentType belegt, dass eine Datei ein gültiges PDF
oder Bild ist.
Über Swagger UI hochladen
Starten Sie den Server im Projektverzeichnis mit einem Schlüssel, der nur für diese lokale Demo verwendet wird:
FILE_API_KEY=local-demo-key node server.ts
Öffnen Sie die ausgegebene URL. Der Standardport ist 0; damit wird beim
Betriebssystem ein verfügbarer Port angefordert. Sie können PORT setzen,
um einen Port auszuwählen. Fehlende Schlüssel, ungültige Ports und belegte Ports verhindern den
Start. Der Callback prüft das Fehlerargument, da Express 5 Fehler beim Lauschen darauf an ihn übergibt.
- Wählen Sie Authorize, geben Sie
local-demo-keyin Value: ein und wählen Sie dann Authorize und Close. - Klappen Sie
POST /uploadauf, wählen Sie Try it out und eine kleine JPEG-, PNG- oder PDF-Datei fürfile. Wählen Sie dann Execute. - Prüfen Sie, ob die Serverantwort
200lautet undfile.id,file.name,file.sizesowiefile.mimetypeenthält. Kopieren Siefile.idfür den Download-Schritt. - Wählen Sie für
POST /uploadsdie erste Datei und nutzen Sie Add string item für jede weitere Datei. Wählen Sie dann Execute. Das Arrayfilesder Antwort enthält für jede akzeptierte Datei ein Metadatenobjekt.
Das erzeugte Dokument ist unter /openapi.json auf demselben Ursprung verfügbar.
Swagger UI lädt diesen Endpunkt mithilfe der URL-Konfiguration des Wrappers.
Das öffentliche Dokument beschreibt den Schlüssel-Header, enthält aber nicht den Wert des Schlüssels.
Dateidownload-Endpunkte dokumentieren
Klappen Sie GET /download/{filename} in Swagger UI auf, wählen Sie
Try it out und fügen Sie die erzeugte ID in filename ein.
Wählen Sie Execute und dann
Download file in der Antwort. Speichern Sie diese Datei und vergleichen Sie sie
mit Ihrem Original. Der Name des Anhangs ist die erzeugte ID, daher fehlt die ursprüngliche
Dateiendung.
Bei einer leeren Datei erhält Swagger UI 5.33.0 200 mit
Content-Length: 0, zeigt aber keinen Download-Link an. Sie können diese Antwort
stattdessen mit cURL speichern. Führen Sie Folgendes in einem zweiten Terminal aus und fügen Sie
die vollständige Download-URL einschließlich Port und erzeugter ID ein. Dieser Befehl ersetzt
downloaded.bin, falls die Datei bereits existiert. Vergleichen Sie sie nach
erfolgreicher Ausführung des Befehls mit Ihrem Original.
read -r -p 'Paste the full download URL: ' download_url &&
curl -fsSL -H 'X-API-Key: local-demo-key' -o downloaded.bin "$download_url"
Die erfolgreiche Antwort verwendet application/octet-stream und das OpenAPI-Binärschema.
Der Server prüft das Format der ID und löst sie innerhalb des privaten Upload-Verzeichnisses auf.
Ein erneuter Upload mit demselben clientseitigen Dateinamen erzeugt eine neue ID; der vorherige
Upload wird nicht überschrieben. Gespeicherte Dateien bleiben nach einem Serverneustart erhalten.
Stoppen Sie den Server mit Ctrl+C und entfernen Sie das Verzeichnis uploads/
dieses Demoprojekts, wenn Sie die Dateien nicht mehr benötigen. Es gibt keine automatische
Aufbewahrungsrichtlinie.
Vertrag mit tatsächlichen Antworten vergleichen
Sie können auch die URL von /openapi.json auf dem laufenden Server in Postman importieren
und den Header X-API-Key angeben. Setzen Sie die Basis-URL der importierten
Collection auf den Ursprung des lokalen Servers einschließlich des zugewiesenen Ports; das
Dokument verwendet eine relative Server-URL. Lassen Sie unabhängig vom verwendeten Client diesen
die Multipart-Boundary erzeugen. Wenn Sie selbst einen bloßen Header
Content-Type: multipart/form-data setzen, fehlt diese Boundary.
Prüfen Sie nicht nur die erfolgreiche Antwort. In den folgenden Fällen enthält der JSON-Fehlerbody
genau eine Zeichenkette namens error. Verwenden Sie einen direkten
HTTP-Client für ungültige Anfragen, deren Versand Swagger UI verhindert.
| Anfrage | Erwarteter Status |
|---|---|
| Upload oder Download ohne Schlüssel oder mit falschem Schlüssel | 401 |
| Multipart-Upload ohne Datei mit Namen | 400 |
Zwei Parts namens file auf /upload oder 11 Parts namens files auf /uploads | 400 |
| Ein zusätzliches Textfeld oder ein unbekanntes Dateifeld | 400 |
| Eine Datei mit mehr als 5.242.880 Bytes | 413 |
Ein als text/plain deklarierter Part oder ein Upload-Body ohne Multipart-Format | 415 |
| Download mit ungültiger ID oder unbekannter hexadezimaler ID mit 32 Zeichen | 404 |
Laden Sie außerdem eine leere Datei mit einem erlaubten deklarierten MIME-Typ und eine Binärdatei mit Nullbytes sowie Bytes hoch, die nicht mit UTF-8 konform sind. Vergleichen Sie anschließend beide Downloads Byte für Byte. Eine erfolgreiche Schemavalidierung allein kann nicht belegen, dass der Server diese Bytes erhalten hat. Wenn Sie die Felder, das Anzahl-Limit oder die Antwortstruktur ändern, passen Sie sowohl die Implementierung als auch das OpenAPI-Dokument an und wiederholen Sie diese Anfragen.
Sicherheitsaspekte
Dieser Schlüssel gewährt Zugriff auf den gesamten Demospeicher. Die OpenAPI-Sicherheitsdeklaration teilt Clients nur mit, wie sie ihn senden sollen; die Middleware führt die Prüfung durch. Ein Dienst mit mehreren Nutzern benötigt separate Zugangsdaten und eine Autorisierung pro Datei. Die Demo hat weder ein Gesamtspeicherlimit noch eine Begrenzung der Anfragerate.
Beschränken Sie dieses HTTP-Beispiel auf Loopback. Ein bereitgestellter Dienst benötigt HTTPS, eine Aufbewahrungsrichtlinie und eine Inhaltsvalidierung, die zur Nutzung der Uploads passt. Parsen Sie Dateien oder führen Sie einen Sicherheitsscan durch, bevor Sie sie verarbeiten oder inline ausliefern. Erzeugte Dateinamen und authentifizierte Downloads als Anhang beizubehalten ist sinnvoll, macht MIME-Metadaten vom Client aber nicht zu einer vertrauenswürdigen Inhaltsvalidierung.
