Documenter des API d’envoi de fichiers avec Swagger et OpenAPI
Un point de terminaison d’envoi décrit en OpenAPI nécessite deux exigences distinctes : un corps de requête requis et une propriété de fichier requise dans ce corps. Ce tutoriel crée une API Express locale dont l’interface Swagger UI vous permet d’envoyer des fichiers et de télécharger les mêmes octets, avec une documentation des champs, des limites, de la clé d’API et des erreurs que le serveur utilise réellement.
Que sont Swagger et OpenAPI ?
OpenAPI décrit le contrat HTTP ; Swagger UI transforme ce document en client interactif.
Ici, swagger-jsdoc génère un document OpenAPI 3.0.4 à partir de définitions partagées et de commentaires
placés à côté des routes. L’API de fichiers comporte trois opérations : POST /upload, POST /uploads et
GET /download/{filename}. Ses réponses d’envoi contiennent des métadonnées, tandis que sa réponse de
téléchargement contient les octets stockés.
La distinction entre requestBody.required: true et le required: [file] de l’objet est importante :
le premier exige un corps ; le second exige cette propriété. Les envois multiples nécessitent aussi
required: [files], minItems: 1 et maxItems: 10. En OpenAPI 3.0, chaque fichier utilise
type: string avec format: binary. Ces déclarations décrivent les requêtes ; Express et Multer doivent
tout de même les faire respecter. Consultez les définitions OpenAPI du corps de requête et des schémas.
Configurer Swagger dans votre projet
Utilisez un shell compatible Bash, Node.js 26 et Corepack avec Yarn 4 disponible. Installez cURL si vous souhaitez utiliser la commande de téléchargement de secours. L’exemple a été testé sous Linux avec Node.js 26.8.1 et Yarn 4.12.0. Node exécute ces fichiers TypeScript grâce à sa suppression de types intégrée ; ce mécanisme ne vérifie pas les types.
Exécutez ceci depuis un répertoire où vous voulez créer un nouveau projet file-api-swagger. Les commandes
enchaînées s’arrêtent si le répertoire existe déjà ou si le changement de répertoire échoue. Le
fichier de verrouillage vide signale un projet Yarn distinct, y compris lorsque vous le créez dans
une autre copie de travail.
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
Conservez le yarn.lock généré pour des installations reproductibles. Épingler swagger-ui-dist fige aussi la
version du bundle front-end de Swagger UI servi au navigateur, que swagger-ui-express résout sinon via sa plage de
dépendances.
La version 2.4.0 de Multer inclut un correctif de
sécurité ; vérifiez les avis de sécurité plus récents avant de réutiliser ces versions épinglées
dans une application déployée.
Créez les deux fichiers suivants dans file-api-swagger. Chaque bloc correspond au fichier complet.
Définir les schémas partagés dans openapi.ts
Les schémas de réponse exigent chaque champ renvoyé par le serveur. Toutes les opérations
documentées héritent de l’exigence X-API-Key. Une URL de serveur relative maintient Swagger UI sur la
même origine, même lorsque le système d’exploitation attribue un port différent.
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 fait échouer le démarrage en cas d’erreur d’analyse des annotations, comme le décrit
swagger-jsdoc. Cette option ne compare pas le comportement des
routes avec le document. Les vérifications présentées plus loin dans ce tutoriel comblent cette
lacune.
Documenter les points de terminaison d’envoi de fichiers
Enregistrez ce fichier complet sous server.ts. Il authentifie la requête avant d’analyser les envois,
stocke les fichiers sous des noms générés par Multer dans uploads/ et n’expose ce répertoire que via
la route de téléchargement authentifiée. Les limites de Multer
bornent chaque fichier et chaque requête ; les champs texte sont rejetés.
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 route d’envoi simple accepte une seule partie nommée file. La route par lot accepte d’une à
10 parties, toutes nommées files et non files[]. Les deux rejettent les champs texte
supplémentaires et les champs de fichier non reconnus. Un fichier doit faire
5 MiB au maximum : Multer 2.4.0
accepte exactement 5 242 880 octets et rejette un fichier plus volumineux.
Cette limite est décrite dans le contrat, et non encodée sous forme de maxLength, qui est une
contrainte de longueur de chaîne plutôt qu’une instruction portable de limite en octets pour le
multipart. Les fichiers nommés vides sont acceptés.
Les types MIME autorisés pour les parties sont JPEG, PNG et PDF. Il s’agit de déclarations du
client : un fichier texte envoyé avec Content-Type: application/pdf passe ce filtre. Ni le filtre ni encoding.contentType
ne prouvent qu’un fichier est un PDF ou une image valide.
Envoyer des fichiers via Swagger UI
Depuis le répertoire du projet, démarrez le serveur avec une clé utilisée uniquement pour cette démo locale :
FILE_API_KEY=local-demo-key node server.ts
Ouvrez l’URL affichée. Le port par défaut est 0, qui demande au système d’exploitation un port
disponible ; vous pouvez définir PORT pour en choisir un. Les clés manquantes, les ports invalides
et les ports occupés font échouer le démarrage.
Le callback vérifie l’argument d’erreur, car Express 5 lui transmet les échecs d’écoute.
- Sélectionnez Authorize, saisissez
local-demo-keydans Value:, puis sélectionnez Authorize et Close. - Dépliez
POST /upload, sélectionnez Try it out, choisissez un petit fichier JPEG, PNG ou PDF pourfile, puis sélectionnez Execute. - Vérifiez que la réponse du serveur est
200et qu’elle contientfile.id,file.name,file.sizeetfile.mimetype. Copiezfile.idpour l’étape de téléchargement. - Pour
POST /uploads, choisissez le premier fichier et utilisez Add string item pour chaque fichier supplémentaire, puis sélectionnez Execute. Le tableaufilesde la réponse contient un objet de métadonnées pour chaque fichier accepté.
Le document généré est disponible à l’adresse /openapi.json sur la même origine. Swagger UI charge ce
point de terminaison grâce à la configuration d’URL du wrapper.
Le document public décrit l’en-tête de la clé, mais ne contient pas la valeur de la clé.
Documenter les points de terminaison de téléchargement de fichiers
Dépliez GET /download/{filename} dans Swagger UI, sélectionnez
Try it out et collez l’ID généré dans filename.
Sélectionnez Execute, puis
Download file dans la réponse. Enregistrez ce fichier et comparez-le avec
votre original. Le nom de la pièce jointe est l’ID généré ; il n’aura donc pas l’extension d’origine.
Pour un fichier vide, Swagger UI 5.33.0 reçoit 200 avec Content-Length: 0, mais n’affiche pas de
lien de téléchargement. Vous pouvez plutôt enregistrer cette réponse avec cURL. Exécutez la commande
suivante dans un second terminal et collez l’URL de téléchargement complète, y compris le port et
l’ID généré. Cette commande remplace downloaded.bin s’il existe déjà ; comparez-le avec votre original
une fois la commande réussie.
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 réponse réussie utilise application/octet-stream et le schéma binaire OpenAPI. Le serveur
vérifie le format de l’ID et le résout sous le répertoire d’envoi privé. Envoyer de nouveau un
fichier portant le même nom côté client crée un nouvel ID ; l’envoi précédent n’est pas écrasé. Les
fichiers stockés survivent au redémarrage du serveur. Arrêtez le serveur avec Ctrl+C et supprimez le
répertoire uploads/ de ce projet de démonstration lorsque vous n’avez plus besoin de ses fichiers ;
il n’existe aucune politique de rétention automatique.
Comparer le contrat aux réponses réelles
Vous pouvez aussi importer dans Postman l’URL /openapi.json du serveur en cours d’exécution
et fournir l’en-tête X-API-Key. Définissez l’URL de base de la collection importée sur l’origine du
serveur local, port attribué compris ; le document utilise une URL de serveur relative.
Quel que soit le client utilisé, laissez-le générer la délimitation (boundary) multipart. Si vous
définissez vous-même un simple en-tête Content-Type: multipart/form-data, cette délimitation est omise.
Ne vous limitez pas à vérifier la réponse réussie. Pour les cas suivants, le corps d’erreur JSON
contient exactement une chaîne error. Utilisez un client HTTP direct pour les requêtes invalides
que Swagger UI vous empêche d’envoyer.
| Requête | Statut attendu |
|---|---|
| Envoi ou téléchargement sans la clé, ou avec une clé incorrecte | 401 |
| Envoi multipart sans fichier nommé | 400 |
Deux parties file sur /upload, ou 11 parties files sur /uploads | 400 |
| Un champ texte supplémentaire ou un champ de fichier non reconnu | 400 |
| Un fichier de plus de 5 242 880 octets | 413 |
Une partie déclarée comme text/plain, ou un corps d’envoi non multipart | 415 |
| Téléchargement avec un ID invalide ou un ID hexadécimal inconnu de 32 caractères | 404 |
Envoyez également un fichier vide avec un type MIME déclaré autorisé et un fichier binaire contenant des octets nuls et des octets non UTF-8, puis comparez les deux téléchargements octet par octet. Une validation de schéma réussie ne peut à elle seule établir que le serveur a préservé ces octets. Si vous modifiez les champs, la limite du nombre de fichiers ou la forme de la réponse, modifiez à la fois l’implémentation et le document OpenAPI, puis répétez ces requêtes.
Considérations de sécurité
Cette clé donne accès à l’ensemble du stockage de démonstration. La déclaration de sécurité OpenAPI indique seulement aux clients comment l’envoyer ; c’est le middleware qui effectue la vérification. Un service multi-utilisateur a besoin d’identifiants distincts et d’une autorisation par fichier. La démo n’a ni quota de stockage total ni limite de débit des requêtes.
Limitez cet exemple HTTP à l’interface de bouclage (loopback). Un service déployé nécessite HTTPS, une politique de rétention et une validation du contenu adaptée à ce qu’il fait des envois. Analysez syntaxiquement les fichiers ou soumettez-les à une analyse de sécurité avant de les traiter ou de les servir pour affichage direct (inline). Conserver des noms de fichiers générés et des téléchargements authentifiés en pièce jointe est utile, mais cela ne transforme pas les métadonnées MIME fournies par le client en validation de contenu fiable.
