Subida y procesamiento de archivos CSV mediante una REST API
Envía un archivo CSV como un campo de formulario multipart llamado file
a POST /upload y luego lee los registros importados en
GET /data. Esta guía implementa esos endpoints con Express y Multer, usando
csv-parse para convertir CSV en registros JSON validados. Una importación
correcta reemplaza el conjunto de datos anterior; una importación rechazada lo deja intacto.
El ejemplo almacena un único conjunto de datos compartido en memoria y escucha únicamente en tu equipo local. Es una pequeña demostración de importación: reiniciar el servidor borra los datos, y no hay inicio de sesión ni almacenamiento por usuario.
Requisitos previos
Usa Node.js 24.x o 26.x, Corepack con Yarn 4.12.0 disponible y una terminal compatible con Bash.
Los comandos también necesitan cURL 7.76.0 o posterior para
--fail-with-body. El ejemplo se probó en macOS con
Node.js 24.21.0 y 26.8.2, Yarn 4.12.0 y cURL 8.7.1.
Nuestro formato CSV tiene el encabezado name,age, en ese orden, y al menos
una fila de datos. Los nombres no deben estar en blanco; las edades deben contener de uno a tres
dígitos decimales y estar entre cero y 130. El importador elimina los espacios en blanco al inicio
y al final de los valores y convierte las edades en números JSON. Usa datos de entrada en UTF-8
separados por comas; se admiten una marca de orden de bytes UTF-8, comas entre comillas y saltos
de línea entre comillas. Las líneas vacías se omiten.
Configura un servidor con Node.js y Express
Ejecuta lo siguiente desde el directorio donde quieras crear la carpeta
csv-upload-api. El subshell mantiene tu directorio actual sin cambios. Si esa
carpeta ya existe, el comando se detiene sin instalar paquetes ni sobrescribir sus archivos;
elige una ubicación nueva para repetir la configuración.
(
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
)
El archivo de bloqueo vacío hace que este sea un proyecto de Yarn independiente, incluso cuando
lo creas dentro de otro proyecto. Conserva el archivo yarn.lock generado
junto con tu ejemplo para mantener las versiones resueltas de las dependencias. Multer 2.4.0
incluye la corrección de una
vulnerabilidad en la limpieza del disco tras una subida abortada;
mantén esta dependencia actualizada con los parches disponibles al adaptar el ejemplo.
Implementa el endpoint de subida de archivos
Crea server.js dentro del nuevo directorio
csv-upload-api con el código completo que aparece a continuación. Multer acepta
un archivo de hasta 5 MiB y ningún campo de formulario adicional. Su
middleware single('file')
escribe un archivo temporal y lo expone como 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}`)
})
El endpoint comprueba el contenido independientemente del nombre de archivo o del tipo MIME
proporcionados por el cliente. Esas etiquetas no garantizan que un archivo contenga CSV válido.
Un archivo llamado data.csv también debe superar el análisis y las
comprobaciones de las filas.
Analiza y almacena los datos CSV
El callback columns del analizador comprueba
el encabezado antes de crear los objetos de registro. Exigir exactamente
name,age permite rechazar columnas duplicadas, ausentes o inesperadas.
El analizador rechaza las filas con longitudes inconsistentes y los
campos entre comillas sin cerrar, y el bucle valida los valores
antes de añadirlos al nuevo conjunto de datos.
pipeline espera a que terminen la lectura, el análisis y la validación de
las filas. Después, el bloque finally elimina el archivo temporal, incluso
cuando el análisis falla. Solo después de eso, /upload reemplaza
storedData.
Express 5 reenvía las promesas rechazadas de las rutas asíncronas
al middleware de errores, por lo que un fallo del sistema de archivos devuelve una respuesta
500 genérica en lugar de un mensaje del analizador o una ruta local.
Además del límite de 5 MiB por archivo,
max_record_size
limita los búferes de registros del analizador, y el bucle limita la salida a 10.000 registros.
El análisis usa flujos de datos, pero los registros completos siguen ocupando memoria. Durante
un reemplazo, pueden coexistir el conjunto de datos anterior y los nuevos registros. Las subidas
simultáneas son independientes; prevalece la última que termine correctamente.
Prueba la API
Desde el directorio que contiene csv-upload-api, inicia el servidor en una terminal:
(cd csv-upload-api && node server.js)
Déjalo en ejecución y usa una segunda terminal en ese mismo directorio padre. Este comando crea
data.csv dentro del proyecto. noclobber impide que
sobrescriba un archivo existente, incluso al volver a ejecutarlo de forma no interactiva.
(
cd csv-upload-api &&
set -o noclobber &&
cat > data.csv <<'CSV'
name,age
"Doe, Jane",34
Tim,42
CSV
)
Sube ese archivo. -F crea una solicitud multipart;
file debe coincidir con el nombre del campo del servidor. Deja que cURL
proporcione el delimitador multipart en lugar de establecer tú el encabezado
Content-Type de la solicitud.
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 respuesta es {"recordsStored":2}. Recupera los registros almacenados:
curl --fail-with-body --silent --show-error http://127.0.0.1:3100/data
[{"name":"Doe, Jane","age":34},{"name":"Tim","age":42}]
Ahora envía una fila sin edad. Esta solicitud lee CSV desde la entrada estándar, por lo que no crea ni reemplaza un archivo 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
La respuesta esperada es HTTP 400 y {"error":"Invalid CSV syntax or record too large."}. cURL termina con el código 22
debido a --fail-with-body; este rechazo es el resultado previsto. Ejecuta de nuevo
el comando GET /data: Jane y Tim deberían seguir ahí. Repetir la subida
correcta reemplaza el conjunto de datos con los mismos dos registros, sin añadir duplicados.
Otras comprobaciones útiles son enviar un archivo vacío o un CSV que solo contenga el encabezado,
lo que devuelve 400, y enviar un archivo de más de 5 MiB, lo que devuelve 413. La ausencia de una
parte file, un nombre de campo distinto, varios archivos o campos de
texto adicionales también producen una respuesta 400. Los errores causados por un CSV inválido
son errores del cliente; un fallo inesperado del almacenamiento devuelve 500.
En Postman, elige POST y http://127.0.0.1:3100/upload.
En Body → form-data, añade una única clave llamada
file, establece su tipo como File y selecciona
data.csv. Deja que Postman genere el encabezado
Content-Type de la solicitud, incluido su delimitador. Detén el servidor con
Ctrl+C cuando termines.
Consideraciones para producción
Antes de exponer este endpoint, añade autenticación y comprueba la autorización tanto para importar como para leer cada conjunto de datos. Reemplaza el array compartido por almacenamiento persistente y usa una transacción o una importación por etapas para que una fila inválida no pueda dejar un conjunto de datos parcialmente actualizado. La autenticación por sí sola no aísla los registros de un usuario de los de otro.
Mantén las subidas temporales fuera de los directorios cuyo contenido se sirve públicamente. La limpieza de este ejemplo contempla los fallos habituales de las solicitudes y del analizador; un cierre inesperado del proceso puede dejar archivos sin eliminar, por lo que un servicio desplegado también necesita una política para eliminar las subidas abandonadas. Aplica tiempos de espera máximos para las solicitudes, límites de frecuencia y límites de concurrencia: un límite por archivo no acota el uso combinado de memoria y disco de muchas solicitudes simultáneas. Las importaciones más grandes suelen necesitar una tarea en segundo plano y un endpoint que informe del estado de la importación.
