Servico de Upload de Arquivos

Construa um servico de upload de arquivos em Rust com Axum, validacao de tipos, limites de tamanho, download e listagem de arquivos.

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

  1. 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.

  2. Thumbnails de imagens – Integre a crate image para gerar thumbnails automaticamente ao receber uploads de imagens, armazenando-os em um subdiretorio thumbnails/.

  3. Verificacao de integridade – Calcule o hash SHA-256 de cada arquivo no upload e oferca um endpoint para verificar a integridade de arquivos baixados.

  4. Quotas por usuario – Adicione autenticacao e implemente limites de armazenamento por usuario (ex: 100 MB), impedindo uploads quando a quota e atingida.

  5. 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