Upload e processamento de arquivos CSV por uma API REST
Envie um arquivo CSV como campo de formulário multipart chamado file para POST /upload e depois
leia os registros importados em GET /data. Este passo a passo cria esses endpoints com Express
e Multer, usando csv-parse para transformar CSV em registros JSON validados. Uma importação
bem-sucedida substitui o conjunto de dados anterior; uma importação rejeitada o mantém intacto.
O exemplo armazena um único conjunto de dados compartilhado em memória e escuta apenas na sua máquina local. É uma pequena demonstração de importação: reiniciar o servidor apaga os dados, e não há login nem armazenamento por usuário.
Pré-requisitos
Use Node.js 24.x ou 26.x, Corepack com Yarn 4.12.0 disponível e um terminal compatível com Bash. Os
comandos também exigem cURL 7.76.0 ou mais recente para --fail-with-body. O exemplo foi testado
no macOS com Node.js 24.21.0 e 26.8.2, Yarn 4.12.0 e cURL 8.7.1.
Nosso formato CSV tem o cabeçalho name,age, nessa ordem, e pelo menos uma linha de dados. Os
nomes não podem estar em branco; as idades devem conter de um a três dígitos decimais e ficar entre
zero e 130. O importador remove os espaços em branco ao redor dos valores e converte as idades em
números JSON. Use entrada UTF-8 separada por vírgulas; há suporte a marca de ordem de bytes UTF-8,
vírgulas entre aspas e quebras de linha entre aspas. Linhas vazias são ignoradas.
Configurando um servidor Node.js e Express
Execute isto a partir de um diretório onde você quer criar a nova pasta csv-upload-api. O subshell
mantém seu diretório atual inalterado. Se essa pasta já existir, o comando para sem instalar pacotes
nem sobrescrever os arquivos dela; escolha um novo local para repetir a configuração.
(
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
)
O lockfile vazio torna este um projeto Yarn independente, inclusive quando você o cria dentro de
outro projeto. Mantenha o yarn.lock gerado junto com seu exemplo para preservar as versões
resolvidas das dependências. O Multer 2.4.0 inclui a correção de uma
vulnerabilidade de limpeza de disco em uploads abortados; mantenha essa dependência
atualizada ao adaptar o exemplo.
Implementando o endpoint de upload de arquivos
Crie server.js dentro do novo diretório csv-upload-api com o código completo abaixo. O Multer
aceita um arquivo, de até 5 MiB, e nenhum campo de formulário extra. Seu
middleware single('file')
grava um arquivo temporário e o expõe 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}`)
})
O endpoint verifica o conteúdo independentemente do nome de arquivo ou do tipo MIME informado pelo
cliente. Esses rótulos não comprovam que um arquivo contém CSV válido. Um arquivo chamado data.csv
ainda precisa passar pelo parser e pelas verificações de linha.
Analisando e armazenando dados CSV
O callback columns do parser verifica o cabeçalho antes de criar os
objetos de registro. Exigir exatamente name,age rejeita colunas duplicadas, ausentes e
inesperadas. O parser rejeita linhas com comprimentos inconsistentes e
campos entre aspas não fechados, e o loop valida os valores antes de adicioná-los ao novo
conjunto de dados.
pipeline aguarda a conclusão da leitura, do parsing e da validação das linhas. Em seguida, o
bloco finally remove o arquivo temporário, inclusive quando o parsing falha. Só depois disso
/upload substitui storedData.
O Express 5 encaminha promises rejeitadas de rotas assíncronas para o middleware de
erro, então uma falha no sistema de arquivos retorna uma resposta 500 genérica em vez de uma mensagem
do parser ou de um caminho local.
Além do limite de 5 MiB por arquivo, max_record_size
limita os buffers de registro do parser, e o loop restringe a saída a 10.000 registros. O parsing
usa streams, mas os registros concluídos ainda ocupam memória. Durante uma substituição, tanto o
conjunto de dados anterior quanto os novos registros podem estar presentes. Uploads simultâneos são
independentes; o último a terminar com sucesso prevalece.
Testando a API
No diretório que contém csv-upload-api, inicie o servidor em um terminal:
(cd csv-upload-api && node server.js)
Deixe-o em execução e use um segundo terminal nesse mesmo diretório pai. Este comando cria
data.csv dentro do projeto. noclobber faz com que ele se recuse a sobrescrever um arquivo
existente, inclusive em uma nova execução não interativa.
(
cd csv-upload-api &&
set -o noclobber &&
cat > data.csv <<'CSV'
name,age
"Doe, Jane",34
Tim,42
CSV
)
Faça o upload desse arquivo. -F monta uma requisição multipart; file deve
corresponder ao nome do campo no servidor. Deixe o cURL definir o boundary do multipart em vez de
você mesmo definir o cabeçalho Content-Type da requisição.
curl --fail-with-body --silent --show-error \
-F 'file=@csv-upload-api/data.csv;type=text/csv' http://127.0.0.1:3100/upload
A resposta é {"recordsStored":2}. Busque os registros armazenados:
curl --fail-with-body --silent --show-error http://127.0.0.1:3100/data
[{"name":"Doe, Jane","age":34},{"name":"Tim","age":42}]
Agora envie uma linha sem a idade. Esta requisição lê o CSV da entrada padrão, então não cria nem substitui um arquivo 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
Espere HTTP 400 e {"error":"Invalid CSV syntax or record too large."}. O cURL termina com o código de saída 22
por causa de --fail-with-body; essa rejeição é o resultado esperado. Execute o comando GET /data
novamente: Jane e Tim devem continuar lá. Repetir o upload bem-sucedido substitui o conjunto de
dados pelos mesmos dois registros, sem acrescentar duplicatas.
Outras verificações úteis são um arquivo vazio ou um CSV só com cabeçalho, que retorna 400, e um
arquivo maior que 5 MiB, que retorna 413. A ausência da parte file, um nome de campo
diferente, vários arquivos ou campos de texto extras também retornam 400. Erros causados por CSV
inválido são erros do cliente; uma falha inesperada de armazenamento retorna 500.
No Postman, escolha POST e http://127.0.0.1:3100/upload. Em Corpo (Body) → form-data,
adicione uma única chave chamada file, defina o tipo dela como Arquivo (File) e selecione
data.csv. Deixe o Postman gerar o cabeçalho Content-Type da requisição, incluindo o boundary.
Pare o servidor com Ctrl+C ao terminar.
Considerações para produção
Antes de expor este endpoint, adicione autenticação e autorize tanto a importação quanto a leitura de cada conjunto de dados. Substitua o array compartilhado por armazenamento durável e use uma transação ou uma importação em etapas para que uma linha inválida não possa deixar um conjunto de dados parcialmente atualizado. A autenticação sozinha não isola os registros de um usuário dos registros de outro.
Mantenha os uploads temporários fora de diretórios servidos publicamente. A limpeza feita aqui trata falhas comuns de requisição e do parser; uma queda do processo pode deixar arquivos para trás, então um serviço implantado também precisa de uma política para remover uploads abandonados. Aplique timeouts de requisição, limites de taxa e limites de concorrência: um limite por arquivo não restringe o uso combinado de memória e disco de muitas requisições simultâneas. Importações maiores geralmente precisam de uma tarefa em segundo plano e de um endpoint de status da importação.
