Upload file in Rust e Axum

Mattepuffo's logo
Upload file in Rust e Axum

Upload file in Rust e Axum

In questo articolo vediamo come fare un upload in Rust e Axum.

Vi metterò solo il minimo indispensabile, quindi potrebbe essere che dobbiate integrare qualcosa da voi.

Partiamo dal Cargo.toml:

[dependencies]
axum = { version = "0.8.6", features = ["multipart"] }
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.47.1", features = ["full"] }
tower-http = { version = "0.6.6", features = ["cors"] }
sqlx = { version = "0.8.6", features = ["mysql", "runtime-tokio-rustls", "macros", "chrono", "rust_decimal"] }
anyhow = "1.0.99"
dotenvy = "0.15.7"
serde_json = "1.0.145"
chrono = { version = "0.4.42", features = ["serde"] }
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
tracing-appender = "0.2.3"
rust_decimal = "1.38.0"
rust_decimal_macros = "1.38.0"
bytes = "1.10.1"

Io vi ho messo tutte le dipendenze che ho, ma in realtà dovete attivare il multipart di Axum e installare bytes per l'upload in se.

Poi dentro utils.rs ho creato due funzioni, di cui una per l'upload e l'altra per impostare il nome del file:

use bytes::Bytes;
use std::path::Path;
use std::{fs, io::Write};
use tokio::task;

pub async fn save_uploaded_file(
    base_dir: &str,
    original_name: &str,
    file_bytes: &Bytes,
    allowed_types: &[&str],
    max_size: usize,
) -> Result<String, String> {
    let ext = Path::new(&original_name)
        .extension()
        .and_then(|s| s.to_str())
        .ok_or_else(|| "File senza estensione".to_string())?
        .to_lowercase();

    if !allowed_types.contains(&ext.as_str()) {
        return Err(format!("Tipo file non consentito: {}", ext));
    }

    if file_bytes.len() > max_size {
        return Err(format!(
            "File troppo grande: {} byte, massimo {} byte",
            file_bytes.len(),
            max_size
        ));
    }

    fs::create_dir_all(base_dir).map_err(|e| format!("Errore creazione cartella: {}", e))?;

    let save_path = format!("{}/{}", base_dir, original_name);
    let bytes = file_bytes.clone();

    task::spawn_blocking(move || -> Result<String, String> {
        let mut file =
            fs::File::create(&save_path).map_err(|e| format!("Errore creazione file: {}", e))?;
        file.write_all(&bytes)
            .map_err(|e| format!("Errore scrittura file: {}", e))?;
        Ok(save_path)
    })
    .await
    .map_err(|e| format!("Errore thread: {}", e))?
}

pub fn sanitize_name(name: &str) -> String {
    let mut s = name.trim().to_lowercase();

    let cerca = [
        "à", "è", "é", "ì", "ò", "ù", "'", "?", " ", "__", "&", "%", "#", "(", ")", "/", "+", "°",
    ];

    let sostituisci = [
        "a",
        "e",
        "e",
        "i",
        "o",
        "u",
        "-",
        "-",
        "-",
        "-",
        "e",
        "-per-cento-",
        "-",
        "",
        "",
        "-",
        "_",
        "_",
    ];

    for (c, r) in cerca.iter().zip(sostituisci.iter()) {
        s = s.replace(c, r);
    }

    s = s.replace("---", "-");

    s
}

Poi ho creato un service:

pub async fn upload_logo(
    original_name: &str,
    bytes: &Bytes,
) -> Result<String, String> {
    let base_dir = "IMG_UTENTI_GEST/ANAG_GEN";
    let file_name = utils::sanitize_name(original_name);
    utils::save_uploaded_file(
        base_dir,
        &file_name,
        bytes,
        &["png", "jpg", "jpeg"],
        2 * 1024 * 1024,
    )
    .await
}

E questo il controller:

pub async fn upload_logo(
    State(_state): State<AppState>,
    mut multipart: Multipart,
) -> impl IntoResponse {
    let mut id: Option<String> = None;
    let mut file_bytes: Option<(String, bytes::Bytes)> = None;

    while let Some(field) = multipart.next_field().await.unwrap_or(None) {
        let name = field.name().unwrap_or("").to_string();

        if name == "id" {
            id = Some(field.text().await.unwrap_or_default());
        } else if name == "file" {
            let file_name = field.file_name().unwrap_or("upload.bin").to_string();
            let bytes = field.bytes().await.unwrap_or_default();
            file_bytes = Some((file_name, bytes));
        }
    }

    if id.is_none() || file_bytes.is_none() {
        let response = UploadLogoResponse::Ko {
            res: "ko".to_string(),
            message: "Manca id o file".to_string(),
            error: "Parametri incompleti".to_string(),
        };
        return Json(response);
    }

    let id = id.unwrap();
    let (file_name, bytes) = file_bytes.unwrap();

    match anagrafiche_service::upload_logo(&id, &file_name, &bytes).await {
        Ok(path) => {
            let response = UploadLogoResponse::Ok {
                res: "ok".to_string(),
                message: "File caricato correttamente".to_string(),
                img: Some(path),
            };
            Json(response)
        }
        Err(e) => {
            error!("Errore in upload_logo {:?}", e);
            let response = UploadLogoResponse::Ko {
                res: "ko".to_string(),
                message: "Si è verificato un errore durante il caricamento".to_string(),
                error: e.to_string(),
            };
            Json(response)
        }
    }
}

Che risponde con una enum:

#[derive(Serialize)]
#[serde(untagged)]
pub enum UploadLogoResponse {
    Ok {
        res: String,
        message: String,
        img: Option,
    },
    Ko {
        res: String,
        message: String,
        error: String,
    },
}

Enjoy!


Condividi

Commentami!