M Logo
๐Ÿ“ Rust Backend with Axum ยท Part 25 of 42

Rust Web Server with Axum 0.8: A Storage Trait for Local Disk and Memory

November 3, 2026

Episode 25 of the Rust Backend series, where we build the backend of a blog step by step. After episode 24 our upload handler writes images into a folder, and ServeDir reads them back. The handler knows exactly where files live. Today: a Storage trait, so it doesn't have to. All the code below is the final, tested code from the video, ready to copy.

We continue in the same blog_api project from episode 24. No new crates today. (The series plan had Amazon S3 here; this episode shows the same idea with two stores that need nothing external. An S3 store would be a third implementation of the same trait.)

1. The Storage trait

A storage layer has three jobs: put a file, get it back, delete it. Where the bytes really go, the handler should not care: today a folder, tomorrow a cloud. New file src/storage.rs:

rust
use std::future::Future;
use std::io;

pub trait Storage: Send + Sync + 'static {
    fn put(&self, name: &str, data: &[u8]) -> impl Future<Output = io::Result<()>> + Send;
    fn get(&self, name: &str) -> impl Future<Output = io::Result<Vec<u8>>> + Send;
    fn delete(&self, name: &str) -> impl Future<Output = io::Result<()>> + Send;
}

The same pattern as our Repo trait in episode 11: async functions written as impl Future + Send, so they work in a multi-threaded server.

2. LocalStorage, with a safe name check

The first implementation keeps a root folder and talks to the disk with tokio::fs. Before it touches the disk it checks the name: empty, a dot at the start, or any slash, and it says no. Nobody climbs out of the folder with ../.

rust
use std::path::PathBuf;

pub struct LocalStorage {
    root: PathBuf,
}

impl LocalStorage {
    pub fn new(root: &str) -> LocalStorage {
        std::fs::create_dir_all(root).unwrap();
        LocalStorage { root: PathBuf::from(root) }
    }

    fn path(&self, name: &str) -> io::Result<PathBuf> {
        if name.is_empty() || name.starts_with('.') || name.contains('/') || name.contains('\\') {
            return Err(io::Error::new(io::ErrorKind::InvalidInput, "bad file name"));
        }
        Ok(self.root.join(name))
    }
}

impl Storage for LocalStorage {
    async fn put(&self, name: &str, data: &[u8]) -> io::Result<()> {
        tokio::fs::write(self.path(name)?, data).await
    }

    async fn get(&self, name: &str) -> io::Result<Vec<u8>> {
        tokio::fs::read(self.path(name)?).await
    }

    async fn delete(&self, name: &str) -> io::Result<()> {
        tokio::fs::remove_file(self.path(name)?).await
    }
}

3. MemStorage

A second implementation lives only in memory: a HashMap inside a Mutex. Good for tests, and it proves the handler doesn't care.

rust
use std::collections::HashMap;
use std::sync::Mutex;

#[derive(Default)]
pub struct MemStorage {
    files: Mutex<HashMap<String, Vec<u8>>>,
}

impl Storage for MemStorage {
    async fn put(&self, name: &str, data: &[u8]) -> io::Result<()> {
        self.files.lock().unwrap().insert(name.to_string(), data.to_vec());
        Ok(())
    }

    async fn get(&self, name: &str) -> io::Result<Vec<u8>> {
        self.files.lock().unwrap().get(name).cloned().ok_or(io::Error::from(io::ErrorKind::NotFound))
    }

    async fn delete(&self, name: &str) -> io::Result<()> {
        self.files.lock().unwrap().remove(name);
        Ok(())
    }
}

4. The handler asks for the store

upload_image gets the store as an Extension, and calls put. It is generic over the Storage type, just like it is over Repo:

rust
pub async fn upload_image<R: Repo, S: Storage>(
    State(repo): State<R>,
    user: AuthUser,
    Path(id): Path<i64>,
    Extension(store): Extension<Arc<S>>,
    mut multipart: Multipart,
) -> Result<(StatusCode, Json<Image>), ApiError> {
    // ... permission check, read the field, image_kind ...
    store.put(&name, &bytes).await?;
    let image = repo.add_image(id, &format!("/files/{name}")).await?;
    return Ok((StatusCode::CREATED, Json(image)));
}

Extension is the second way to share something with handlers, next to State. Our state is already the repository, so the store comes in as a layer.

5. Reading files back

ServeDir only knew the disk. A route of our own works for any store: get, then the right content type. A missing file (or a refused name) is a 404:

rust
pub async fn get_file<S: Storage>(Extension(store): Extension<Arc<S>>, Path(name): Path<String>) -> Result<Response, ApiError> {
    let bytes = store.get(&name).await.map_err(|err| match err.kind() {
        io::ErrorKind::NotFound | io::ErrorKind::InvalidInput => ApiError::FileNotFound,
        _ => ApiError::Io(err),
    })?;
    let content_type = if name.ends_with(".png") { "image/png" } else { "image/jpeg" };
    Ok(([(header::CONTENT_TYPE, content_type)], bytes).into_response())
}
rust
#[error("file not found")]
FileNotFound,
// ...
ApiError::FileNotFound => StatusCode::NOT_FOUND,

6. Wire it up

The app takes a store next to the repo, gives it to the handlers with an Extension layer, and the files route replaces ServeDir:

rust
pub fn app<R: Repo, S: Storage>(repo: R, store: S) -> Router {
    // cors, governor, auth_routes as before ...
    Router::new()
        // ... other routes
        .route(
            "/posts/{id}/images",
            post(handlers::upload_image::<R, S>).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
        )
        .route("/files/{name}", get(handlers::get_file::<S>))
        .merge(auth_routes)
        .with_state(repo)
        .layer(Extension(Arc::new(store)))
        // cors and header layers ...
}

main picks the store from a new STORAGE variable (Config reads it, default local). The two stores are different types, so each branch builds its own app, and a small serve function does the rest:

rust
let repo = SqliteRepo { pool };
match cfg.storage.as_str() {
    "memory" => serve(app(repo, MemStorage::default()), &addr).await,
    _ => serve(app(repo, LocalStorage::new("uploads")), &addr).await,
}
rust
async fn serve(app: Router, addr: &str) {
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    tracing::info!(%addr, "listening");
    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();
}

7. The local disk

Run, log in, make a post, and upload:

bash
cargo run
curl -i -H "authorization: Bearer $TOKEN" -F file=@$HOME/lce/files/cat.png localhost:8080/posts/1/images
ls uploads; curl -s localhost:8080$(sqlite3 blog.db "select url from images") | file -

The file is in uploads/, and its URL serves a PNG image. And someone asking for a name with ../?

bash
curl -si 'localhost:8080/files/..%2Fblog.db'
text
HTTP/1.1 404 Not Found

{"error":"file not found"}

LocalStorage refuses the name, and our database file stays private.

8. The same code, in memory

bash
STORAGE=memory cargo run
curl -i -H "authorization: Bearer $TOKEN" -F file=@$HOME/lce/files/cat.png localhost:8080/posts/1/images

201 Created, and the URL works. But ls uploads shows no new file. Restart the server, and the file is gone:

bash
curl -si localhost:8080$URL
text
HTTP/1.1 404 Not Found

The handler code didn't change at all; only the store did.

The final code

src/config.rs

rust
use std::sync::OnceLock;

pub struct Config {
    pub host: String,
    pub port: u16,
    pub database_url: String,
    pub jwt_secret: String,
    pub storage: String,
}

impl Config {
    fn from_env() -> Config {
        dotenvy::dotenv().ok();
        let host = std::env::var("HOST").unwrap_or("0.0.0.0".to_string());
        let port = std::env::var("PORT").unwrap_or("3000".to_string());
        let port = port
            .parse()
            .unwrap_or_else(|e| panic!("PORT {port:?} is not a port number: {e}"));
        let database_url = std::env::var("DATABASE_URL").unwrap_or("sqlite://blog.db".to_string());
        let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
        let storage = std::env::var("STORAGE").unwrap_or("local".to_string());
        Config { host, port, database_url, jwt_secret, storage }
    }
}

static CONFIG: OnceLock<Config> = OnceLock::new();

pub fn config() -> &'static Config {
    CONFIG.get_or_init(Config::from_env)
}

src/error.rs

rust
use axum::extract::rejection::{JsonRejection, QueryRejection};
use axum::extract::multipart::MultipartError;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use thiserror::Error;
use std::collections::BTreeMap;

#[derive(Debug, Error)]
pub enum ApiError {
    #[error("post {0} not found")]
    NotFound(i64),
    #[error(transparent)]
    BadJson(#[from] JsonRejection),
    #[error(transparent)]
    BadQuery(#[from] QueryRejection),
    #[error("database error")]
    Database(#[from] sqlx::Error),
    #[error("{0}")]
    Conflict(String),
    #[error("validation failed")]
    Validation(#[from] validator::ValidationErrors),
    #[error("internal error")]
    Hash(#[from] argon2::password_hash::Error),
    #[error("internal error")]
    Jwt(#[from] jsonwebtoken::errors::Error),
    #[error("invalid email or password")]
    Unauthorized,
    #[error("you are not allowed to do this")]
    Forbidden,
    #[error("file not found")]
    FileNotFound,
    #[error("{0}")]
    Multipart(#[from] MultipartError),
    #[error("send a file in the form field file")]
    MissingFile,
    #[error("internal error")]
    Io(#[from] std::io::Error),
    #[error("only PNG and JPEG images")]
    UnsupportedMedia,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        tracing::warn!(error = %self, "request failed");
        let status = match &self {
            ApiError::NotFound(_) => StatusCode::NOT_FOUND,
            ApiError::Conflict(_) => StatusCode::CONFLICT,
            ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
            ApiError::Forbidden => StatusCode::FORBIDDEN,
            ApiError::FileNotFound => StatusCode::NOT_FOUND,
            ApiError::UnsupportedMedia => StatusCode::UNSUPPORTED_MEDIA_TYPE,
            ApiError::MissingFile => StatusCode::BAD_REQUEST,
            ApiError::Multipart(err) => err.status(),
            ApiError::BadJson(rejection) => rejection.status(),
            ApiError::BadQuery(rejection) => rejection.status(),
            ApiError::Validation(errors) => {
                let fields: BTreeMap<_, Vec<String>> = errors
                    .field_errors()
                    .iter()
                    .map(|(field, errs)| {
                        let msgs = errs.iter().map(|e| e.message.clone().unwrap_or(e.code.clone()).to_string()).collect();
                        (field.to_string(), msgs)
                    })
                    .collect();
                return (StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": "validation failed", "fields": fields })))
                    .into_response();
            }
            ApiError::Hash(err) => {
                tracing::error!(?err, "password hashing failed");
                StatusCode::INTERNAL_SERVER_ERROR
            }
            ApiError::Jwt(err) => {
                tracing::error!(?err, "token error");
                StatusCode::INTERNAL_SERVER_ERROR
            }
            ApiError::Database(err) => {
                tracing::error!(?err, "database error");
                StatusCode::INTERNAL_SERVER_ERROR
            }
            ApiError::Io(err) => {
                tracing::error!(?err, "io error");
                StatusCode::INTERNAL_SERVER_ERROR
            }
        };
        let message = match &self {
            ApiError::BadJson(rejection) => rejection.body_text(),
            ApiError::BadQuery(rejection) => rejection.body_text(),
            ApiError::Multipart(err) => err.body_text(),
            other => other.to_string(),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

src/handlers.rs

rust
use axum::extract::{rejection::QueryRejection, Extension, Multipart, Path, Query, State};
use axum::{http::{header, StatusCode}, response::{IntoResponse, Response}, Json};

use crate::error::ApiError;
use crate::extract::{AdminUser, AuthUser, Role, ValidJson};
use crate::models::{Comment, Hit, Image, ListFilter, ListParams, NewComment, NewPost, NewUser, Page, Post, PostWithTags, SearchParams, User};
use crate::repo::Repo;
use crate::auth;
use crate::config::config;
use serde::Deserialize;
use serde_json::{json, Value};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use uuid::Uuid;
use crate::storage::Storage;
use std::sync::Arc;
use std::io;

#[tracing::instrument(skip_all)]
pub async fn create_post<R: Repo>(
    State(repo): State<R>,
    user: AuthUser,
    ValidJson(new): ValidJson<NewPost>,
) -> Result<(StatusCode, Json<PostWithTags>), ApiError> {
    let post = repo.create(user.id, new).await.map_err(|err| match err.as_database_error() {
        Some(e) if e.is_unique_violation() => ApiError::Conflict("a post with this title already exists".into()),
        _ => ApiError::Database(err),
    })?;
    tracing::debug!(id = post.post.id, "created");
    Ok((StatusCode::CREATED, Json(post)))
}

pub async fn get_post<R: Repo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<Json<Post>, ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(post))
}

pub async fn list_posts<R: Repo>(
    State(repo): State<R>,
    params: Result<Query<ListParams>, QueryRejection>,
) -> Result<Json<Page<Post>>, ApiError> {
    let Query(p) = params?;
    let page = p.page.unwrap_or(1).max(1);
    let per_page = p.per_page.unwrap_or(10).clamp(1, 100);
    let (items, total) = repo.list(ListFilter { page, per_page, tag: p.tag, sort: p.sort }).await?;
    Ok(Json(Page { items, page, per_page, total }))
}

pub async fn update_post<R: Repo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
    user: AuthUser,
    ValidJson(new): ValidJson<NewPost>,
) -> Result<Json<Post>, ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    if !can_edit(&user, &post) {
        return Err(ApiError::Forbidden);
    }
    let post = repo.update(id, new).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(post))
}

pub async fn delete_post<R: Repo>(State(repo): State<R>, Path(id): Path<i64>, user: AuthUser) -> Result<StatusCode, ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    if !can_edit(&user, &post) {
        return Err(ApiError::Forbidden);
    }
    repo.delete(id).await?;
    Ok(StatusCode::NO_CONTENT)
}

pub async fn list_comments<R: Repo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
) -> Result<Json<Vec<Comment>>, ApiError> {
    repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(repo.list_comments(id).await?))
}

pub async fn add_comment<R: Repo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
    _user: AuthUser,
    ValidJson(new): ValidJson<NewComment>,
) -> Result<(StatusCode, Json<Comment>), ApiError> {
    let comment = repo.add_comment(id, new).await.map_err(|err| {
        match err.as_database_error() {
            Some(db_err) if db_err.is_foreign_key_violation() => ApiError::NotFound(id),
            _ => ApiError::Database(err),
        }
    })?;
    Ok((StatusCode::CREATED, Json(comment)))
}

pub async fn search_posts<R: Repo>(
    State(repo): State<R>,
    Query(p): Query<SearchParams>,
) -> Result<Json<Vec<Hit>>, ApiError> {
    Ok(Json(repo.search(&p.q).await?))
}

pub async fn restore_post<R: Repo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
    if !repo.restore(id).await? {
        return Err(ApiError::NotFound(id));
    }
    Ok(StatusCode::OK)
}

pub async fn signup<R: Repo>(
    State(repo): State<R>,
    ValidJson(new): ValidJson<NewUser>,
) -> Result<(StatusCode, Json<User>), ApiError> {
    let hash = crate::auth::hash_password(new.password).await?;
    let user = repo.create_user(&new.email, &hash).await.map_err(|err| match err.as_database_error() {
        Some(e) if e.is_unique_violation() => ApiError::Conflict("email already registered".into()),
        _ => ApiError::Database(err),
    })?;
    Ok((StatusCode::CREATED, Json(user)))
}

#[derive(Deserialize)]
pub struct Login {
    pub email: String,
    pub password: String,
}

fn refresh_cookie(token: String) -> Cookie<'static> {
    Cookie::build(("refresh", token))
        .http_only(true)
        .secure(true)
        .same_site(SameSite::Strict)
        .path("/auth")
        .max_age(time::Duration::days(30))
        .build()
}

pub async fn login<R: Repo>(
    State(repo): State<R>,
    jar: CookieJar,
    Json(body): Json<Login>,
) -> Result<(CookieJar, Json<Value>), ApiError> {
    let user = repo.find_user_by_email(&body.email).await?.ok_or(ApiError::Unauthorized)?;
    if !auth::verify_password(body.password, user.password_hash).await? {
        return Err(ApiError::Unauthorized);
    }
    let access = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
    let refresh = auth::new_refresh_token();
    repo.store_refresh(&auth::hash_token(&refresh), user.id, auth::now() + auth::REFRESH_SECS).await?;
    Ok((jar.add(refresh_cookie(refresh)), Json(json!({ "token": access }))))
}

pub async fn me<R: Repo>(State(repo): State<R>, user: AuthUser) -> Result<Json<User>, ApiError> {
    let user = repo.get_user(user.id).await?.ok_or(ApiError::Unauthorized)?;
    Ok(Json(user))
}

pub async fn refresh<R: Repo>(State(repo): State<R>, jar: CookieJar) -> Result<(CookieJar, Json<Value>), ApiError> {
    let old = jar.get("refresh").ok_or(ApiError::Unauthorized)?.value().to_string();
    let user_id = repo.take_refresh(&auth::hash_token(&old), auth::now()).await?.ok_or(ApiError::Unauthorized)?;
    let user = repo.get_user(user_id).await?.ok_or(ApiError::Unauthorized)?;
    let access = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
    let new = auth::new_refresh_token();
    repo.store_refresh(&auth::hash_token(&new), user.id, auth::now() + auth::REFRESH_SECS).await?;
    Ok((jar.add(refresh_cookie(new)), Json(json!({ "token": access }))))
}

pub async fn logout<R: Repo>(State(repo): State<R>, jar: CookieJar) -> Result<(CookieJar, StatusCode), ApiError> {
    if let Some(cookie) = jar.get("refresh") {
        repo.delete_refresh(&auth::hash_token(cookie.value())).await?;
    }
    Ok((jar.remove(Cookie::build("refresh").path("/auth")), StatusCode::NO_CONTENT))
}

pub fn can_edit(user: &AuthUser, post: &Post) -> bool {
    user.role == Role::Admin || post.author_id == Some(user.id)
}

pub async fn list_users<R: Repo>(State(repo): State<R>, _admin: AdminUser) -> Result<Json<Vec<User>>, ApiError> {
    Ok(Json(repo.list_users().await?))
}

pub async fn upload_image<R: Repo, S: Storage>(
    State(repo): State<R>,
    user: AuthUser,
    Path(id): Path<i64>,
    Extension(store): Extension<Arc<S>>,
    mut multipart: Multipart,
) -> Result<(StatusCode, Json<Image>), ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    if !can_edit(&user, &post) {
        return Err(ApiError::Forbidden);
    }
    while let Some(field) = multipart.next_field().await? {
        if field.name() != Some("file") {
            continue;
        }
        let bytes = field.bytes().await?;
        let ext = image_kind(&bytes).ok_or(ApiError::UnsupportedMedia)?;
        let name = format!("{}.{ext}", Uuid::new_v4());
        store.put(&name, &bytes).await?;
        let image = repo.add_image(id, &format!("/files/{name}")).await?;
        return Ok((StatusCode::CREATED, Json(image)));
    }
    Err(ApiError::MissingFile)
}

fn image_kind(bytes: &[u8]) -> Option<&'static str> {
    if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
        Some("png")
    } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
        Some("jpg")
    } else {
        None
    }
}

pub async fn get_file<S: Storage>(Extension(store): Extension<Arc<S>>, Path(name): Path<String>) -> Result<Response, ApiError> {
    let bytes = store.get(&name).await.map_err(|err| match err.kind() {
        io::ErrorKind::NotFound | io::ErrorKind::InvalidInput => ApiError::FileNotFound,
        _ => ApiError::Io(err),
    })?;
    let content_type = if name.ends_with(".png") { "image/png" } else { "image/jpeg" };
    Ok(([(header::CONTENT_TYPE, content_type)], bytes).into_response())
}

src/main.rs

rust
mod auth;
mod config;
mod error;
mod extract;
mod handlers;
mod models;
mod repo;
mod storage;

use axum::{routing::{get, post}, Extension, Router};
use repo::{Repo, SqliteRepo};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use std::str::FromStr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
use axum::extract::DefaultBodyLimit;
use axum::http::{header, HeaderValue, Method};
use tower_http::cors::CorsLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use std::sync::Arc;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use std::net::SocketAddr;
use storage::{LocalStorage, MemStorage, Storage};

pub fn app<R: Repo, S: Storage>(repo: R, store: S) -> Router {
    let cors = CorsLayer::new()
        .allow_origin(HeaderValue::from_static("http://localhost:5500"))
        .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
        .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
        .allow_credentials(true);
    let governor = Arc::new(GovernorConfigBuilder::default().per_second(2).burst_size(5).finish().unwrap());
    let auth_routes = Router::new()
        .route("/signup", post(handlers::signup::<R>))
        .route("/login", post(handlers::login::<R>))
        .route("/auth/refresh", post(handlers::refresh::<R>))
        .layer(GovernorLayer::new(governor));
    Router::new()
        .route("/posts", get(handlers::list_posts::<R>).post(handlers::create_post::<R>))
        .route(
            "/posts/{id}",
            get(handlers::get_post::<R>).put(handlers::update_post::<R>).delete(handlers::delete_post::<R>),
        )
        .route("/posts/{id}/comments", get(handlers::list_comments::<R>).post(handlers::add_comment::<R>))
        .route("/search", get(handlers::search_posts::<R>))
        .route("/posts/{id}/restore", post(handlers::restore_post::<R>))
        .route("/me", get(handlers::me::<R>))
        .route("/admin/users", get(handlers::list_users::<R>))
        .route("/auth/logout", post(handlers::logout::<R>))
        .route(
            "/posts/{id}/images",
            post(handlers::upload_image::<R, S>).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
        )
        .route("/files/{name}", get(handlers::get_file::<S>))
        .merge(auth_routes)
        .with_state(repo)
        .layer(Extension(Arc::new(store)))
        .layer(cors)
        .layer(SetResponseHeaderLayer::if_not_present(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")))
        .layer(SetResponseHeaderLayer::if_not_present(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")))
        .layer(SetResponseHeaderLayer::if_not_present(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")))
        .layer(TraceLayer::new_for_http())
}

#[tokio::main]
async fn main() {
    let cfg = config::config();
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
        .init();
    let addr = format!("{}:{}", cfg.host, cfg.port);
    let options = SqliteConnectOptions::from_str(&cfg.database_url)
        .unwrap()
        .create_if_missing(true)
        .journal_mode(SqliteJournalMode::Wal);
    let pool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect_with(options)
        .await
        .unwrap();
    sqlx::migrate!().run(&pool).await.unwrap();
    let repo = SqliteRepo { pool };
    match cfg.storage.as_str() {
        "memory" => serve(app(repo, MemStorage::default()), &addr).await,
        _ => serve(app(repo, LocalStorage::new("uploads")), &addr).await,
    }
}

async fn serve(app: Router, addr: &str) {
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    tracing::info!(%addr, "listening");
    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();
}

src/storage.rs

rust
use std::future::Future;
use std::io;
use std::path::PathBuf;
use std::collections::HashMap;
use std::sync::Mutex;

pub trait Storage: Send + Sync + 'static {
    fn put(&self, name: &str, data: &[u8]) -> impl Future<Output = io::Result<()>> + Send;
    fn get(&self, name: &str) -> impl Future<Output = io::Result<Vec<u8>>> + Send;
    fn delete(&self, name: &str) -> impl Future<Output = io::Result<()>> + Send;
}

pub struct LocalStorage {
    root: PathBuf,
}

impl LocalStorage {
    pub fn new(root: &str) -> LocalStorage {
        std::fs::create_dir_all(root).unwrap();
        LocalStorage { root: PathBuf::from(root) }
    }

    fn path(&self, name: &str) -> io::Result<PathBuf> {
        if name.is_empty() || name.starts_with('.') || name.contains('/') || name.contains('\\') {
            return Err(io::Error::new(io::ErrorKind::InvalidInput, "bad file name"));
        }
        Ok(self.root.join(name))
    }
}

impl Storage for LocalStorage {
    async fn put(&self, name: &str, data: &[u8]) -> io::Result<()> {
        tokio::fs::write(self.path(name)?, data).await
    }

    async fn get(&self, name: &str) -> io::Result<Vec<u8>> {
        tokio::fs::read(self.path(name)?).await
    }

    async fn delete(&self, name: &str) -> io::Result<()> {
        tokio::fs::remove_file(self.path(name)?).await
    }
}

#[derive(Default)]
pub struct MemStorage {
    files: Mutex<HashMap<String, Vec<u8>>>,
}

impl Storage for MemStorage {
    async fn put(&self, name: &str, data: &[u8]) -> io::Result<()> {
        self.files.lock().unwrap().insert(name.to_string(), data.to_vec());
        Ok(())
    }

    async fn get(&self, name: &str) -> io::Result<Vec<u8>> {
        self.files.lock().unwrap().get(name).cloned().ok_or(io::Error::from(io::ErrorKind::NotFound))
    }

    async fn delete(&self, name: &str) -> io::Result<()> {
        self.files.lock().unwrap().remove(name);
        Ok(())
    }
}

Recap

  • A Storage trait with put, get and delete hides where files live
  • LocalStorage refuses names with slashes or a leading dot, so nobody leaves the folder
  • MemStorage keeps files in a Mutex<HashMap>: handy for tests
  • Handlers get the store with Extension<Arc<S>> and are generic over S: Storage
  • An S3 or other cloud store is just one more impl Storage

Next episode: sending email. A welcome mail for every new user. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam