Neste projeto vamos construir um servico de upload e download de arquivos usando Axum. O servico vai aceitar uploads multipart, validar tipos de arquivo permitidos, aplicar limites de tamanho, armazenar os arquivos no disco, e oferecer endpoints para download e listagem. Este e um componente essencial em muitas aplicacoes web, desde sistemas de gerenciamento de documentos ate plataformas de compartilhamento de imagens.
Manipulacao de arquivos e uma area onde Rust brilha: o controle preciso sobre alocacao de memoria e I/O torna o processamento de uploads muito eficiente, mesmo com arquivos grandes.
O Que Vamos Construir
Um servico de arquivos com as seguintes funcionalidades:
- POST /api/upload – Upload de arquivo via multipart/form-data
- GET /api/arquivos – Listar todos os arquivos armazenados
- GET /api/arquivos/:nome – Download de um arquivo especifico
- DELETE /api/arquivos/:nome – Remover um arquivo
- Validacao de tipos MIME permitidos (imagens, PDFs, texto)
- Limite de tamanho de arquivo (10 MB)
- Metadados de cada arquivo (nome, tamanho, tipo, data de upload)
- Armazenamento no sistema de arquivos local
Estrutura do Projeto
upload-service/
├── Cargo.toml
├── uploads/ # Diretorio criado automaticamente
└── src/
└── main.rs
Configurando o Projeto
cargo new upload-service
cd upload-service
Configure o Cargo.toml:
[package]
name = "upload-service"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
A feature multipart do Axum habilita o suporte a uploads de arquivos via multipart/form-data.
Passo 1: Definindo Modelos e Configuracao
Vamos definir as estruturas de dados e constantes de configuracao:
use chrono::{DateTime, Utc};
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
// Diretorio onde os arquivos serao armazenados
const DIRETORIO_UPLOADS: &str = "uploads";
// Tamanho maximo de arquivo em bytes (10 MB)
const TAMANHO_MAXIMO: usize = 10 * 1024 * 1024;
// Tipos MIME permitidos
const TIPOS_PERMITIDOS: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
"text/plain",
"text/csv",
];
// Metadados de um arquivo armazenado
#[derive(Debug, Clone, Serialize)]
pub struct MetadadosArquivo {
pub id: String,
pub nome_original: String,
pub nome_armazenado: String,
pub tipo_mime: String,
pub tamanho_bytes: usize,
pub tamanho_formatado: String,
pub enviado_em: DateTime<Utc>,
}
// Resposta apos upload bem-sucedido
#[derive(Debug, Serialize)]
pub struct RespostaUpload {
pub mensagem: String,
pub arquivo: MetadadosArquivo,
}
// Estado: nome_armazenado -> MetadadosArquivo
pub type EstadoApp = Arc<Mutex<HashMap<String, MetadadosArquivo>>>;
// Formata tamanho em bytes para formato legivel
fn formatar_tamanho(bytes: usize) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
// Infere o tipo MIME pela extensao do arquivo
fn inferir_tipo_mime(nome: &str) -> String {
match nome.rsplit('.').next().unwrap_or("").to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg".to_string(),
"png" => "image/png".to_string(),
"gif" => "image/gif".to_string(),
"webp" => "image/webp".to_string(),
"pdf" => "application/pdf".to_string(),
"txt" => "text/plain".to_string(),
"csv" => "text/csv".to_string(),
_ => "application/octet-stream".to_string(),
}
}
A constante TIPOS_PERMITIDOS define quais formatos sao aceitos, prevenindo upload de executaveis ou outros tipos potencialmente perigosos.
Passo 2: Handler de Upload com Multipart
O handler de upload processa o formulario multipart, valida o arquivo e o salva no disco:
use axum::{
extract::{Multipart, Path, State},
http::{header, StatusCode},
response::IntoResponse,
Json,
};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
// POST /api/upload
async fn fazer_upload(
State(estado): State<EstadoApp>,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<RespostaUpload>), (StatusCode, Json<serde_json::Value>)> {
// Garante que o diretorio de uploads existe
fs::create_dir_all(DIRETORIO_UPLOADS).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"erro": "Falha ao criar diretorio de uploads"})),
)
})?;
// Processa o primeiro campo do multipart
let campo = multipart.next_field().await.map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"erro": "Falha ao processar upload"})),
)
})?;
let campo = campo.ok_or((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"erro": "Nenhum arquivo enviado"})),
))?;
// Extrai nome do arquivo
let nome_original = campo
.file_name()
.unwrap_or("sem_nome")
.to_string();
// Infere tipo MIME
let tipo_mime = inferir_tipo_mime(&nome_original);
// Valida tipo MIME
if !TIPOS_PERMITIDOS.contains(&tipo_mime.as_str()) {
return Err((
StatusCode::UNSUPPORTED_MEDIA_TYPE,
Json(serde_json::json!({
"erro": format!("Tipo de arquivo nao permitido: {}", tipo_mime),
"tipos_permitidos": TIPOS_PERMITIDOS
})),
));
}
// Le todo o conteudo do arquivo
let dados = campo.bytes().await.map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"erro": "Falha ao ler dados do arquivo"})),
)
})?;
// Valida tamanho
if dados.len() > TAMANHO_MAXIMO {
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({
"erro": format!("Arquivo excede o limite de {}", formatar_tamanho(TAMANHO_MAXIMO)),
"tamanho_enviado": formatar_tamanho(dados.len())
})),
));
}
// Gera nome unico para armazenamento
let extensao = nome_original
.rsplit('.')
.next()
.unwrap_or("bin");
let id = Uuid::new_v4().to_string();
let nome_armazenado = format!("{}.{}", id, extensao);
let caminho = format!("{}/{}", DIRETORIO_UPLOADS, nome_armazenado);
// Salva no disco
let mut arquivo = fs::File::create(&caminho).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"erro": "Falha ao salvar arquivo"})),
)
})?;
arquivo.write_all(&dados).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"erro": "Falha ao escrever dados no disco"})),
)
})?;
// Registra metadados
let metadados = MetadadosArquivo {
id: id.clone(),
nome_original: nome_original.clone(),
nome_armazenado: nome_armazenado.clone(),
tipo_mime,
tamanho_bytes: dados.len(),
tamanho_formatado: formatar_tamanho(dados.len()),
enviado_em: Utc::now(),
};
let mut registros = estado.lock().unwrap();
registros.insert(nome_armazenado, metadados.clone());
Ok((
StatusCode::CREATED,
Json(RespostaUpload {
mensagem: "Arquivo enviado com sucesso".to_string(),
arquivo: metadados,
}),
))
}
O handler usa Multipart do Axum para processar dados multipart/form-data. O arquivo recebe um nome unico com UUID para evitar colisoes de nomes.
Passo 3: Handlers de Listagem, Download e Remocao
// GET /api/arquivos -- Lista todos os arquivos
async fn listar_arquivos(State(estado): State<EstadoApp>) -> impl IntoResponse {
let registros = estado.lock().unwrap();
let lista: Vec<MetadadosArquivo> = registros.values().cloned().collect();
Json(lista)
}
// GET /api/arquivos/:nome -- Download de um arquivo
async fn baixar_arquivo(
State(estado): State<EstadoApp>,
Path(nome): Path<String>,
) -> Result<impl IntoResponse, StatusCode> {
let registros = estado.lock().unwrap();
let metadados = registros.get(&nome).ok_or(StatusCode::NOT_FOUND)?;
let tipo_mime = metadados.tipo_mime.clone();
let nome_original = metadados.nome_original.clone();
drop(registros); // Libera o lock antes de I/O
let caminho = format!("{}/{}", DIRETORIO_UPLOADS, nome);
let conteudo = fs::read(&caminho).await.map_err(|_| StatusCode::NOT_FOUND)?;
let headers = [
(header::CONTENT_TYPE, tipo_mime),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", nome_original),
),
];
Ok((headers, conteudo))
}
// DELETE /api/arquivos/:nome -- Remove um arquivo
async fn remover_arquivo(
State(estado): State<EstadoApp>,
Path(nome): Path<String>,
) -> Result<StatusCode, StatusCode> {
let mut registros = estado.lock().unwrap();
if registros.remove(&nome).is_none() {
return Err(StatusCode::NOT_FOUND);
}
drop(registros);
let caminho = format!("{}/{}", DIRETORIO_UPLOADS, nome);
let _ = fs::remove_file(&caminho).await;
Ok(StatusCode::NO_CONTENT)
}
O handler de download configura os headers Content-Type e Content-Disposition para que o navegador trate corretamente o tipo de arquivo e sugira o nome original no salvamento.
Passo 4: Montando o main.rs Completo
Aqui esta o codigo completo do src/main.rs:
use axum::{
extract::{Multipart, Path, State},
http::{header, StatusCode},
response::IntoResponse,
routing::{delete, get, post},
Json, Router,
};
use chrono::{DateTime, Utc};
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
// === Configuracao ===
const DIRETORIO_UPLOADS: &str = "uploads";
const TAMANHO_MAXIMO: usize = 10 * 1024 * 1024;
const TIPOS_PERMITIDOS: &[&str] = &[
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf", "text/plain", "text/csv",
];
// === Modelos ===
#[derive(Debug, Clone, Serialize)]
pub struct MetadadosArquivo {
pub id: String,
pub nome_original: String,
pub nome_armazenado: String,
pub tipo_mime: String,
pub tamanho_bytes: usize,
pub tamanho_formatado: String,
pub enviado_em: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
pub struct RespostaUpload {
pub mensagem: String,
pub arquivo: MetadadosArquivo,
}
pub type EstadoApp = Arc<Mutex<HashMap<String, MetadadosArquivo>>>;
// === Funcoes auxiliares ===
fn formatar_tamanho(bytes: usize) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
fn inferir_tipo_mime(nome: &str) -> String {
match nome.rsplit('.').next().unwrap_or("").to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg".to_string(),
"png" => "image/png".to_string(),
"gif" => "image/gif".to_string(),
"webp" => "image/webp".to_string(),
"pdf" => "application/pdf".to_string(),
"txt" => "text/plain".to_string(),
"csv" => "text/csv".to_string(),
_ => "application/octet-stream".to_string(),
}
}
// === Handlers ===
async fn fazer_upload(
State(estado): State<EstadoApp>,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<RespostaUpload>), (StatusCode, Json<serde_json::Value>)> {
fs::create_dir_all(DIRETORIO_UPLOADS).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"erro": "Falha ao criar diretorio de uploads"})),
)
})?;
let campo = multipart.next_field().await.map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"erro": "Falha ao processar upload"})),
)
})?;
let campo = campo.ok_or((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"erro": "Nenhum arquivo enviado"})),
))?;
let nome_original = campo.file_name().unwrap_or("sem_nome").to_string();
let tipo_mime = inferir_tipo_mime(&nome_original);
if !TIPOS_PERMITIDOS.contains(&tipo_mime.as_str()) {
return Err((
StatusCode::UNSUPPORTED_MEDIA_TYPE,
Json(serde_json::json!({
"erro": format!("Tipo nao permitido: {}", tipo_mime),
"tipos_permitidos": TIPOS_PERMITIDOS
})),
));
}
let dados = campo.bytes().await.map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"erro": "Falha ao ler dados"})),
)
})?;
if dados.len() > TAMANHO_MAXIMO {
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({
"erro": format!("Arquivo excede {}", formatar_tamanho(TAMANHO_MAXIMO))
})),
));
}
let extensao = nome_original.rsplit('.').next().unwrap_or("bin");
let id = Uuid::new_v4().to_string();
let nome_armazenado = format!("{}.{}", id, extensao);
let caminho = format!("{}/{}", DIRETORIO_UPLOADS, nome_armazenado);
let mut arquivo = fs::File::create(&caminho).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"erro": "Falha ao salvar arquivo"})),
)
})?;
arquivo.write_all(&dados).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"erro": "Falha ao escrever dados"})),
)
})?;
let metadados = MetadadosArquivo {
id,
nome_original,
nome_armazenado: nome_armazenado.clone(),
tipo_mime,
tamanho_bytes: dados.len(),
tamanho_formatado: formatar_tamanho(dados.len()),
enviado_em: Utc::now(),
};
estado.lock().unwrap().insert(nome_armazenado, metadados.clone());
Ok((
StatusCode::CREATED,
Json(RespostaUpload {
mensagem: "Arquivo enviado com sucesso".to_string(),
arquivo: metadados,
}),
))
}
async fn listar_arquivos(State(estado): State<EstadoApp>) -> impl IntoResponse {
let registros = estado.lock().unwrap();
let lista: Vec<MetadadosArquivo> = registros.values().cloned().collect();
Json(lista)
}
async fn baixar_arquivo(
State(estado): State<EstadoApp>,
Path(nome): Path<String>,
) -> Result<impl IntoResponse, StatusCode> {
let registros = estado.lock().unwrap();
let metadados = registros.get(&nome).ok_or(StatusCode::NOT_FOUND)?;
let tipo_mime = metadados.tipo_mime.clone();
let nome_original = metadados.nome_original.clone();
drop(registros);
let caminho = format!("{}/{}", DIRETORIO_UPLOADS, nome);
let conteudo = fs::read(&caminho).await.map_err(|_| StatusCode::NOT_FOUND)?;
Ok(([
(header::CONTENT_TYPE, tipo_mime),
(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", nome_original)),
], conteudo))
}
async fn remover_arquivo(
State(estado): State<EstadoApp>,
Path(nome): Path<String>,
) -> Result<StatusCode, StatusCode> {
let mut registros = estado.lock().unwrap();
if registros.remove(&nome).is_none() {
return Err(StatusCode::NOT_FOUND);
}
drop(registros);
let caminho = format!("{}/{}", DIRETORIO_UPLOADS, nome);
let _ = fs::remove_file(&caminho).await;
Ok(StatusCode::NO_CONTENT)
}
// === Main ===
#[tokio::main]
async fn main() {
let estado: EstadoApp = Arc::new(Mutex::new(HashMap::new()));
let app = Router::new()
.route("/api/upload", post(fazer_upload))
.route("/api/arquivos", get(listar_arquivos))
.route(
"/api/arquivos/{nome}",
get(baixar_arquivo).delete(remover_arquivo),
)
.with_state(estado);
let endereco = "0.0.0.0:3000";
println!("Servico de upload rodando em http://{}", endereco);
let listener = tokio::net::TcpListener::bind(endereco).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Como Executar
Compile e inicie o servidor:
cargo run
Teste os endpoints com curl:
# Upload de um arquivo
curl -X POST http://localhost:3000/api/upload \
-F "arquivo=@foto.jpg"
# Resposta:
# {"mensagem":"Arquivo enviado com sucesso","arquivo":{"id":"...","nome_original":"foto.jpg",...}}
# Listar todos os arquivos
curl http://localhost:3000/api/arquivos
# Download de um arquivo (use o nome_armazenado retornado)
curl -O http://localhost:3000/api/arquivos/abc123.jpg
# Remover um arquivo
curl -X DELETE http://localhost:3000/api/arquivos/abc123.jpg
Desafios para Expandir
Upload multiplo – Modifique o handler para aceitar varios arquivos em um unico request multipart, processando e armazenando cada um individualmente e retornando uma lista de resultados.
Thumbnails de imagens – Integre a crate
imagepara gerar thumbnails automaticamente ao receber uploads de imagens, armazenando-os em um subdiretoriothumbnails/.Verificacao de integridade – Calcule o hash SHA-256 de cada arquivo no upload e oferca um endpoint para verificar a integridade de arquivos baixados.
Quotas por usuario – Adicione autenticacao e implemente limites de armazenamento por usuario (ex: 100 MB), impedindo uploads quando a quota e atingida.
Armazenamento em nuvem – Substitua o armazenamento local por um servico de object storage compativel com S3, usando a crate
aws-sdk-s3, mantendo a mesma interface da API.
Veja Tambem
- Modulo fs da Biblioteca Padrao – Operacoes de arquivo usadas no armazenamento
- Path e PathBuf – Manipulacao de caminhos de arquivo
- Modulo io da Biblioteca Padrao – Fundamentos de I/O em Rust
- Escrevendo Arquivos em Rust – Receitas para escrita de arquivos
- Rust para Desenvolvimento Web – Panorama do ecossistema web