Envoi et traitement de fichiers CSV via une API REST
Envoyez un fichier CSV dans un champ de formulaire multipart nommé file à POST /upload, puis lisez
les enregistrements importés depuis GET /data. Ce guide construit ces points de terminaison avec
Express et Multer, en utilisant csv-parse pour transformer le CSV en enregistrements JSON validés.
Un import réussi remplace le jeu de données précédent ; un import rejeté le laisse intact.
L’exemple stocke un unique jeu de données partagé en mémoire et n’écoute que sur votre machine locale. Il s’agit d’une petite démonstration d’import : redémarrer le serveur efface les données, et il n’y a ni connexion ni stockage par utilisateur.
Prérequis
Utilisez Node.js 24.x ou 26.x, Corepack avec Yarn 4.12.0 disponible, et un terminal compatible Bash.
Les commandes nécessitent aussi cURL 7.76.0 ou plus récent pour --fail-with-body. L’exemple a été
testé sous macOS avec Node.js 24.21.0 et 26.8.2, Yarn 4.12.0 et cURL 8.7.1.
Notre format CSV comporte l’en-tête name,age, dans cet ordre, et au moins une ligne de données. Les
noms ne doivent pas être vides ; les âges doivent contenir un à trois chiffres décimaux et être
compris entre zéro et 130. L’importateur supprime les espaces en début et en fin de valeur et
convertit les âges en nombres JSON. Utilisez une entrée UTF-8 séparée par des virgules ; une marque
d’ordre des octets UTF-8, les virgules entre guillemets et les retours à la ligne entre guillemets
sont pris en charge. Les lignes vides sont ignorées.
Configuration d’un serveur Node.js et Express
Exécutez ceci depuis le répertoire où vous souhaitez créer le nouveau dossier csv-upload-api. Le
sous-shell laisse votre répertoire courant inchangé. Si ce dossier existe déjà, la commande s’arrête
sans installer de paquets ni écraser ses fichiers ; choisissez un nouvel emplacement pour recommencer
la configuration.
(
mkdir csv-upload-api &&
cd csv-upload-api &&
printf '{"name":"csv-upload-api","private":true,"packageManager":"yarn@4.12.0"}\n' > package.json &&
touch yarn.lock &&
corepack yarn config set nodeLinker node-modules &&
corepack yarn add --exact express@5.2.1 multer@2.4.0 csv-parse@7.0.2
)
Le fichier de verrouillage vide fait de ce dossier un projet Yarn indépendant, y compris lorsque vous
le créez à l’intérieur d’un autre projet. Conservez le fichier yarn.lock généré avec votre exemple
pour garder les versions de dépendances résolues. Multer 2.4.0 inclut le correctif d’une
vulnérabilité de nettoyage du disque lors d’envois interrompus ; maintenez les correctifs de
cette dépendance à jour lorsque vous adaptez l’exemple.
Implémentation du point de terminaison d’envoi de fichiers
Créez server.js dans le nouveau répertoire csv-upload-api avec le code complet ci-dessous. Multer
accepte un seul fichier, jusqu’à 5 MiB, et aucun champ de formulaire supplémentaire. Son
middleware single('file')
écrit un fichier temporaire et l’expose sous la forme req.file.
const fs = require('node:fs')
const path = require('node:path')
const { pipeline } = require('node:stream/promises')
const { CsvError, parse } = require('csv-parse')
const express = require('express')
const multer = require('multer')
const app = express()
const port = Number(process.env.PORT || 3100)
const upload = multer({
dest: path.join(__dirname, 'uploads'),
limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 0, parts: 1 },
})
class InvalidCsv extends Error {}
let storedData = []
async function parseUpload(filePath) {
try {
const results = []
await pipeline(
fs.createReadStream(filePath),
parse({
bom: true,
skip_empty_lines: true,
max_record_size: 64 * 1024,
columns(header) {
if (header.length !== 2 || header[0] !== 'name' || header[1] !== 'age') {
throw new InvalidCsv('Expected the header name,age.')
}
return header
},
}),
async (rows) => {
for await (const row of rows) {
const name = row.name.trim()
const age = row.age.trim()
if (name === '' || !/^\d{1,3}$/.test(age) || Number(age) > 130) {
throw new InvalidCsv('Each row needs a name and an integer age from 0 to 130.')
}
if (results.length === 10000) {
throw new InvalidCsv('At most 10,000 records are allowed.')
}
results.push({ name, age: Number(age) })
}
},
)
if (results.length === 0) {
throw new InvalidCsv('Include at least one data row.')
}
return results
} finally {
await fs.promises.unlink(filePath)
}
}
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Send a CSV file in the file field.' })
}
const results = await parseUpload(req.file.path)
storedData = results
res.json({ recordsStored: results.length })
})
app.get('/data', (req, res) => {
res.json(storedData)
})
// Express recognizes error middleware by its four arguments; register it after the routes.
app.use((error, req, res, next) => {
if (res.headersSent) return next(error)
if (error instanceof multer.MulterError) {
const tooLarge = error.code === 'LIMIT_FILE_SIZE'
return res.status(tooLarge ? 413 : 400).json({
error: tooLarge ? 'The file exceeds 5 MiB.' : 'Send one file field and no other fields.',
})
}
if (error instanceof InvalidCsv) {
return res.status(400).json({ error: error.message })
}
if (error instanceof CsvError) {
return res.status(400).json({ error: 'Invalid CSV syntax or record too large.' })
}
console.error('CSV import failed because of an unexpected server error.')
res.status(500).json({ error: 'Unable to import the file.' })
})
app.listen(port, '127.0.0.1', () => {
console.log(`CSV API listening on http://127.0.0.1:${port}`)
})
Le point de terminaison vérifie le contenu, quels que soient le nom de fichier ou le type MIME
fournis par le client. Ces indications ne prouvent pas qu’un fichier contient du CSV valide. Un
fichier nommé data.csv doit tout de même passer l’analyseur et les vérifications de lignes.
Analyse et stockage des données CSV
Le callback columns de l’analyseur vérifie l’en-tête avant de
créer les objets d’enregistrement. Exiger exactement name,age rejette les colonnes en double,
manquantes et inattendues. L’analyseur rejette les longueurs de ligne incohérentes et les
champs entre guillemets non fermés, et la boucle valide les valeurs avant de les ajouter
au nouveau jeu de données.
pipeline attend la fin de la lecture, de l’analyse et de la validation des lignes. Le bloc
finally supprime ensuite le fichier temporaire, y compris lorsque l’analyse échoue. Ce n’est qu’après
cela que /upload remplace storedData.
Express 5 transmet les promesses rejetées des routes asynchrones au middleware d’erreur ;
ainsi, une défaillance du système de fichiers renvoie une réponse 500 générique plutôt qu’un message
de l’analyseur ou un chemin local.
Outre la limite de 5 MiB par fichier, max_record_size
borne les tampons d’enregistrement de l’analyseur, et la boucle plafonne la sortie à 10 000
enregistrements. L’analyse utilise des flux, mais les enregistrements terminés occupent tout de même
la mémoire. Pendant un remplacement, le jeu de données précédent et les nouveaux enregistrements
peuvent être présents simultanément. Les envois simultanés sont indépendants ; le dernier à se
terminer avec succès l’emporte.
Test de l’API
Depuis le répertoire contenant csv-upload-api, démarrez le serveur dans un terminal :
(cd csv-upload-api && node server.js)
Laissez-le tourner et utilisez un second terminal dans ce même répertoire parent. Cette commande crée
data.csv dans le projet. noclobber l’empêche d’écraser un fichier existant, y compris lors
d’une réexécution non interactive.
(
cd csv-upload-api &&
set -o noclobber &&
cat > data.csv <<'CSV'
name,age
"Doe, Jane",34
Tim,42
CSV
)
Envoyez ce fichier. -F construit une requête multipart ; file doit correspondre au nom de
champ du serveur. Laissez cURL fournir la délimitation multipart plutôt que de définir vous-même
l’en-tête Content-Type de la requête.
curl --fail-with-body --silent --show-error \
-F 'file=@csv-upload-api/data.csv;type=text/csv' http://127.0.0.1:3100/upload
La réponse est {"recordsStored":2}. Récupérez les enregistrements stockés :
curl --fail-with-body --silent --show-error http://127.0.0.1:3100/data
[{"name":"Doe, Jane","age":34},{"name":"Tim","age":42}]
Envoyez maintenant une ligne sans âge. Cette requête lit le CSV depuis l’entrée standard ; elle ne crée donc ni ne remplace aucun fichier local :
curl --fail-with-body --silent --show-error --write-out '\nHTTP %{http_code}\n' \
-F 'file=@-;filename=invalid.csv;type=text/csv' http://127.0.0.1:3100/upload <<'CSV'
name,age
Tim
CSV
Attendez-vous à une réponse HTTP 400 et à {"error":"Invalid CSV syntax or record too large."}. cURL se termine avec le code 22
à cause de --fail-with-body ; ce rejet est le résultat attendu. Exécutez de nouveau la commande GET /data :
Jane et Tim devraient toujours y figurer. Répéter l’envoi réussi remplace le jeu de données par les
deux mêmes enregistrements, sans ajouter de doublons.
D’autres vérifications utiles portent sur un fichier vide ou un CSV ne contenant que l’en-tête, qui
renvoie 400, et sur un fichier de plus de 5 MiB, qui renvoie 413. Une partie file manquante, un
nom de champ différent, plusieurs fichiers ou des champs texte supplémentaires renvoient également
400. Les erreurs causées par un CSV invalide sont des erreurs client ; une défaillance de stockage
inattendue renvoie 500.
Dans Postman, choisissez POST et http://127.0.0.1:3100/upload. Sous Body → form-data, ajoutez
une seule clé nommée file, définissez son type sur File et sélectionnez data.csv. Laissez
Postman générer l’en-tête Content-Type de la requête, y compris sa délimitation. Arrêtez le serveur avec
Ctrl+C lorsque vous avez terminé.
Considérations pour la production
Avant d’exposer ce point de terminaison, ajoutez une authentification et contrôlez les autorisations pour l’import comme pour la lecture de chaque jeu de données. Remplacez le tableau partagé par un stockage durable et utilisez une transaction ou un import préparé à part afin qu’une ligne incorrecte ne puisse pas laisser un jeu de données partiellement mis à jour. L’authentification seule n’isole pas les enregistrements d’un utilisateur de ceux d’un autre.
Conservez les envois temporaires hors des répertoires servis publiquement. Le nettoyage présenté ici gère les échecs ordinaires de requête et d’analyse ; un plantage du processus peut laisser des fichiers derrière lui, si bien qu’un service déployé a aussi besoin d’une politique de suppression des envois abandonnés. Appliquez des délais d’expiration de requête, des limites de débit et des limites de concurrence : un plafond par fichier ne borne pas l’utilisation combinée de la mémoire et du disque par de nombreuses requêtes simultanées. Les imports plus volumineux nécessitent généralement une tâche en arrière-plan et un point de terminaison d’état de l’import.
