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

Rust Web Server with Axum 0.8: File Uploads with Multipart, 413 and 415

November 3, 2026

Episode 24 of the Rust Backend series, where we build the backend of a blog step by step. After episode 23 our API has CORS, security headers and a rate limit. But a blog post without pictures is a bit dry. Today: uploading images, limits for big files, and checking that an image really is one. 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 23. New today: axum's multipart feature, tower-http's fs feature, and the uuid crate. The test files are in ~/lce/files/: cat.png (about 200 KB), big.png (about 5 MB), and fake.png (a text file, renamed).

1. Multipart

Files are not sent as JSON. An HTML form with a file sends multipart/form-data: the body is split into parts, each with a name, and for files also a file name and a content type. With curl, -F sends exactly that.

2. The handler, and a feature flag

upload_image takes the post id, the AuthUser, and Multipart. Multipart reads the body, so it must be the last parameter (episode 19). First, as in episode 21, we check that this user may change the post:

rust
use axum::extract::{rejection::QueryRejection, Multipart, Path, Query, State};

pub async fn upload_image<R: Repo>(
    State(repo): State<R>,
    user: AuthUser,
    Path(id): Path<i64>,
    mut multipart: Multipart,
) -> Result<StatusCode, ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    if !can_edit(&user, &post) {
        return Err(ApiError::Forbidden);
    }
    Ok(StatusCode::CREATED)
}
bash
cargo build
text
error[E0432]: unresolved import `axum::extract::Multipart`

Multipart is not in axum::extract... unless we turn on its feature flag:

bash
cargo add axum --features multipart

3. Read the file field

We loop over the fields with next_field(). For the one named file, we read its bytes:

rust
while let Some(field) = multipart.next_field().await? {
    if field.name() != Some("file") {
        continue;
    }
    let bytes = field.bytes().await?;
    // save it ...
}
Err(ApiError::MissingFile)

Two new errors in error.rs: a MultipartError (it knows its own status and text, like JsonRejection), and a missing file, a 400:

rust
#[error("{0}")]
Multipart(#[from] MultipartError),
#[error("send a file in the form field file")]
MissingFile,
rust
ApiError::MissingFile => StatusCode::BAD_REQUEST,
ApiError::Multipart(err) => err.status(),
// and in the message match:
ApiError::Multipart(err) => err.body_text(),

4. Never trust the file name

The client sends a file name, but we never use it for the path. It could be ../../../etc/passwd, and the file would land anywhere on our disk. We make our own name, a random UUID:

bash
cargo add uuid --features v4
rust
let name = format!("{}.png", Uuid::new_v4());

5. The images table

We remember every image: which post, and which URL.

bash
sqlx migrate add create_images
sql
CREATE TABLE images (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    url TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
bash
sqlx migrate run
rust
#[derive(Serialize)]
pub struct Image {
    pub id: i64,
    pub post_id: i64,
    pub url: String,
}
rust
async fn add_image(&self, post_id: i64, url: &str) -> sqlx::Result<Image> {
    sqlx::query_as!(
        Image,
        r#"INSERT INTO images (post_id, url) VALUES (?, ?) RETURNING id as "id!", post_id, url"#,
        post_id,
        url
    )
    .fetch_one(&self.pool)
    .await
}

6. Save and serve

We save the bytes into the uploads folder with tokio::fs::write, and store the URL /files/<name>:

rust
let bytes = field.bytes().await?;
let name = format!("{}.png", Uuid::new_v4());
tokio::fs::write(format!("uploads/{name}"), &bytes).await?;
let image = repo.add_image(id, &format!("/files/{name}")).await?;
return Ok((StatusCode::CREATED, Json(image)));

The handler's return type becomes Result<(StatusCode, Json<Image>), ApiError>, and an IO error is a 500 with the details only in the log:

rust
#[error("internal error")]
Io(#[from] std::io::Error),

ServeDir from tower-http serves the uploads folder under /files. It needs the fs feature:

bash
cargo add tower-http --features fs
rust
use tower_http::services::ServeDir;

.route("/posts/{id}/images", post(handlers::upload_image::<R>))
.nest_service("/files", ServeDir::new("uploads"))

and main makes sure the folder exists: std::fs::create_dir_all("uploads").unwrap();.

7. Upload a cat

Run, log in as Ana ($TOKEN), make a post, and upload cat.png:

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

201 Created, and a URL with our random name. It is really served:

bash
curl -s localhost:8080$(sqlite3 blog.db "select url from images") | file -
text
/dev/stdin: PNG image data, ...

8. 413 for a big file

Now big.png, 5 MB:

bash
curl -i -H "authorization: Bearer $TOKEN" -F file=@$HOME/lce/files/big.png localhost:8080/posts/1/images
text
HTTP/1.1 413 Payload Too Large

axum accepts at most 2 MB of body by default. That protects us from a client that sends a hundred gigabytes. For this route only, we allow 10 MB with DefaultBodyLimit:

rust
use axum::extract::DefaultBodyLimit;

.route(
    "/posts/{id}/images",
    post(handlers::upload_image::<R>).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
)
bash
cargo run
curl -i -H "authorization: Bearer $TOKEN" -F file=@$HOME/lce/files/big.png localhost:8080/posts/1/images

201 Created.

9. A fake image

Now fake.png. Its name says png, and the client even claims it is an image, but it is a text file:

bash
curl -i -H "authorization: Bearer $TOKEN" -F "file=@$HOME/lce/files/fake.png;type=image/png" localhost:8080/posts/1/images

201 Created. We stored it anyway! The file name and the content type both come from the client, so neither proves anything.

Real image files start with magic bytes. A PNG starts with 0x89 and the letters PNG; a JPEG with FF D8 FF. We check them before we save:

rust
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
    }
}
rust
let bytes = field.bytes().await?;
let ext = image_kind(&bytes).ok_or(ApiError::UnsupportedMedia)?;
let name = format!("{}.{ext}", Uuid::new_v4());

and the file gets the right extension too. The new error is 415 Unsupported Media Type:

rust
#[error("only PNG and JPEG images")]
UnsupportedMedia,
bash
cargo run
curl -i -H "authorization: Bearer $TOKEN" -F "file=@$HOME/lce/files/fake.png;type=image/png" localhost:8080/posts/1/images
curl -i -H "authorization: Bearer $TOKEN" -F file=@$HOME/lce/files/cat.png localhost:8080/posts/1/images
text
HTTP/1.1 415 Unsupported Media Type

{"error":"only PNG and JPEG images"}

and the cat still gets 201 Created.

The final code

migrations/0010_create_images.sql

sql
-- Add migration script here
CREATE TABLE images (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    url TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

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("{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::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, Multipart, Path, Query, State};
use axum::{http::StatusCode, 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;

#[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>(
    State(repo): State<R>,
    user: AuthUser,
    Path(id): Path<i64>,
    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());
        tokio::fs::write(format!("uploads/{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
    }
}

src/main.rs

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

use axum::{routing::{get, post}, 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 tower_http::services::ServeDir;

pub fn app<R: Repo>(repo: R) -> 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>).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
        )
        .nest_service("/files", ServeDir::new("uploads"))
        .merge(auth_routes)
        .with_state(repo)
        .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);
    std::fs::create_dir_all("uploads").unwrap();
    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 app = app(SqliteRepo { pool });

    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/models.rs

rust
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};

#[derive(Serialize, Clone, sqlx::FromRow)]
pub struct Post {
    pub id: i64,
    pub title: String,
    pub body: String,
    pub slug: String,
    pub author_id: Option<i64>,
    pub created_at: String,
}

#[derive(Deserialize, Validate)]
pub struct NewPost {
    #[validate(length(min = 1, max = 200, message = "1 to 200 characters"), custom(function = "not_blank"))]
    pub title: String,
    #[validate(length(min = 1, max = 20000, message = "must not be empty"))]
    pub body: String,
    #[serde(default)]
    #[validate(length(max = 5, message = "at most 5 tags"))]
    pub tags: Vec<String>,
}

#[derive(Serialize, Clone)]
pub struct Comment {
    pub id: i64,
    pub post_id: i64,
    pub body: String,
    pub created_at: String,
}

#[derive(Deserialize, Validate)]
pub struct NewComment {
    #[validate(length(min = 1, max = 2000, message = "1 to 2000 characters"), custom(function = "not_blank"))]
    pub body: String,
}

pub fn slugify(title: &str) -> String {
    let mut slug = String::new();
    for c in title.trim().to_lowercase().chars() {
        if c.is_alphanumeric() {
            slug.push(c);
        } else if !slug.ends_with('-') {
            slug.push('-');
        }
    }
    slug.trim_end_matches('-').to_string()
}

#[derive(Serialize)]
pub struct PostWithTags {
    #[serde(flatten)]
    pub post: Post,
    pub tags: Vec<String>,
}

#[derive(Deserialize)]
pub struct ListParams {
    pub page: Option<u32>,
    pub per_page: Option<u32>,
    pub tag: Option<String>,
    #[serde(default)]
    pub sort: Sort,
}

#[derive(Deserialize, Clone, Copy, Default)]
#[serde(rename_all = "lowercase")]
pub enum Sort {
    #[default]
    Newest,
    Oldest,
    Title,
}

#[derive(Serialize)]
pub struct Page<T> {
    pub items: Vec<T>,
    pub page: u32,
    pub per_page: u32,
    pub total: i64,
}

pub struct ListFilter {
    pub page: u32,
    pub per_page: u32,
    pub tag: Option<String>,
    pub sort: Sort,
}

#[derive(Serialize)]
pub struct Hit {
    pub id: i64,
    pub title: String,
    pub snippet: String,
}

#[derive(Deserialize)]
pub struct SearchParams {
    pub q: String,
}

pub fn not_blank(value: &str) -> Result<(), ValidationError> {
    if value.trim().is_empty() {
        return Err(ValidationError::new("blank").with_message("must not be blank".into()));
    }
    Ok(())
}

#[derive(Deserialize, Validate)]
pub struct NewUser {
    #[validate(email(message = "must be a valid email"))]
    pub email: String,
    #[validate(length(min = 10, max = 200, message = "10 to 200 characters"))]
    pub password: String,
}

#[derive(Serialize, Clone)]
pub struct User {
    pub id: i64,
    pub email: String,
    pub role: String,
    pub created_at: String,
}

pub struct UserWithHash {
    pub id: i64,
    pub email: String,
    pub password_hash: String,
    pub role: String,
}

#[derive(Serialize)]
pub struct Image {
    pub id: i64,
    pub post_id: i64,
    pub url: String,
}

src/repo.rs

rust
use std::future::Future;

use sqlx::{QueryBuilder, Sqlite, SqlitePool};

use crate::models::{slugify, Comment, Hit, Image, ListFilter, NewComment, NewPost, Post, PostWithTags, Sort, User, UserWithHash};

pub trait Repo: Clone + Send + Sync + 'static {
    fn list(&self, f: ListFilter) -> impl Future<Output = sqlx::Result<(Vec<Post>, i64)>> + Send;
    fn get(&self, id: i64) -> impl Future<Output = sqlx::Result<Option<Post>>> + Send;
    fn create(&self, author_id: i64, new: NewPost) -> impl Future<Output = sqlx::Result<PostWithTags>> + Send;
    fn update(&self, id: i64, new: NewPost) -> impl Future<Output = sqlx::Result<Option<Post>>> + Send;
    fn delete(&self, id: i64) -> impl Future<Output = sqlx::Result<bool>> + Send;
    fn restore(&self, id: i64) -> impl Future<Output = sqlx::Result<bool>> + Send;
    fn list_comments(&self, post_id: i64) -> impl Future<Output = sqlx::Result<Vec<Comment>>> + Send;
    fn add_comment(&self, post_id: i64, new: NewComment) -> impl Future<Output = sqlx::Result<Comment>> + Send;
    fn search(&self, q: &str) -> impl Future<Output = sqlx::Result<Vec<Hit>>> + Send;
    fn create_user(&self, email: &str, password_hash: &str) -> impl Future<Output = sqlx::Result<User>> + Send;
    fn find_user_by_email(&self, email: &str) -> impl Future<Output = sqlx::Result<Option<UserWithHash>>> + Send;
    fn get_user(&self, id: i64) -> impl Future<Output = sqlx::Result<Option<User>>> + Send;
    fn store_refresh(&self, hash: &str, user_id: i64, expires_at: i64) -> impl Future<Output = sqlx::Result<()>> + Send;
    fn take_refresh(&self, hash: &str, now: i64) -> impl Future<Output = sqlx::Result<Option<i64>>> + Send;
    fn delete_refresh(&self, hash: &str) -> impl Future<Output = sqlx::Result<()>> + Send;
    fn list_users(&self) -> impl Future<Output = sqlx::Result<Vec<User>>> + Send;
    fn add_image(&self, post_id: i64, url: &str) -> impl Future<Output = sqlx::Result<Image>> + Send;
}

#[derive(Clone)]
pub struct SqliteRepo {
    pub pool: SqlitePool,
}

impl Repo for SqliteRepo {
    async fn list(&self, f: ListFilter) -> sqlx::Result<(Vec<Post>, i64)> {
        let mut q = QueryBuilder::<Sqlite>::new("SELECT p.id, p.title, p.body, p.slug, p.created_at, p.author_id FROM posts p WHERE p.deleted_at IS NULL");
        if let Some(tag) = &f.tag {
            q.push(" AND p.id IN (SELECT pt.post_id FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE t.name = ")
                .push_bind(tag.clone())
                .push(")");
        }
        q.push(match f.sort {
            Sort::Newest => " ORDER BY p.id DESC",
            Sort::Oldest => " ORDER BY p.id ASC",
            Sort::Title => " ORDER BY p.title ASC",
        });
        q.push(" LIMIT ").push_bind(f.per_page as i64);
        q.push(" OFFSET ").push_bind(((f.page - 1) * f.per_page) as i64);
        let posts = q.build_query_as::<Post>().fetch_all(&self.pool).await?;

        let mut c = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM posts p WHERE p.deleted_at IS NULL");
        if let Some(tag) = &f.tag {
            c.push(" AND p.id IN (SELECT pt.post_id FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE t.name = ")
                .push_bind(tag.clone())
                .push(")");
        }
        let total: i64 = c.build_query_scalar().fetch_one(&self.pool).await?;
        Ok((posts, total))
    }

    async fn search(&self, q: &str) -> sqlx::Result<Vec<Hit>> {
        let q = fts_query(q);
        sqlx::query_as!(
            Hit,
            r#"SELECT p.id as "id!", p.title, snippet(posts_fts, 1, '[', ']', '...', 8) as "snippet!: String"
               FROM posts_fts JOIN posts p ON p.id = posts_fts.rowid
               WHERE posts_fts MATCH ? AND p.deleted_at IS NULL
               ORDER BY bm25(posts_fts) LIMIT 20"#,
            q
        )
        .fetch_all(&self.pool)
        .await
    }

    async fn get(&self, id: i64) -> sqlx::Result<Option<Post>> {
        sqlx::query_as!(Post, "SELECT id, title, body, slug, created_at, author_id FROM posts WHERE id = ? AND deleted_at IS NULL", id)
            .fetch_optional(&self.pool)
            .await
    }

    async fn create(&self, author_id: i64, new: NewPost) -> sqlx::Result<PostWithTags> {
        let slug = slugify(&new.title);
        let mut tx = self.pool.begin().await?;
        let post = sqlx::query_as!(
            Post,
            r#"INSERT INTO posts (title, body, slug, author_id) VALUES (?, ?, ?, ?) RETURNING id, title, body, slug, created_at, author_id"#,
            new.title,
            new.body,
            slug,
            author_id
        )
        .fetch_one(&mut *tx)
        .await?;
        for tag in &new.tags {
            sqlx::query!("INSERT INTO tags (name) VALUES (?) ON CONFLICT (name) DO NOTHING", tag)
                .execute(&mut *tx)
                .await?;
            sqlx::query!(
                "INSERT INTO post_tags (post_id, tag_id) SELECT ?, id FROM tags WHERE name = ?",
                post.id,
                tag
            )
            .execute(&mut *tx)
            .await?;
        }
        tx.commit().await?;
        Ok(PostWithTags { post, tags: new.tags })
    }

    async fn update(&self, id: i64, new: NewPost) -> sqlx::Result<Option<Post>> {
        sqlx::query_as!(
            Post,
            r#"UPDATE posts SET title = ?, body = ? WHERE id = ? AND deleted_at IS NULL RETURNING id, title, body, slug, created_at, author_id"#,
            new.title,
            new.body,
            id
        )
        .fetch_optional(&self.pool)
        .await
    }

    async fn delete(&self, id: i64) -> sqlx::Result<bool> {
        let result = sqlx::query!("UPDATE posts SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL", id)
            .execute(&self.pool)
            .await?;
        Ok(result.rows_affected() > 0)
    }

    async fn restore(&self, id: i64) -> sqlx::Result<bool> {
        let result = sqlx::query!("UPDATE posts SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL", id)
            .execute(&self.pool)
            .await?;
        Ok(result.rows_affected() > 0)
    }

    async fn create_user(&self, email: &str, password_hash: &str) -> sqlx::Result<User> {
        sqlx::query_as!(
            User,
            r#"INSERT INTO users (email, password_hash) VALUES (?, ?) RETURNING id, email, role, created_at"#,
            email,
            password_hash
        )
        .fetch_one(&self.pool)
        .await
    }

    async fn find_user_by_email(&self, email: &str) -> sqlx::Result<Option<UserWithHash>> {
        sqlx::query_as!(
            UserWithHash,
            "SELECT id, email, password_hash, role FROM users WHERE email = ?",
            email
        )
        .fetch_optional(&self.pool)
        .await
    }

    async fn get_user(&self, id: i64) -> sqlx::Result<Option<User>> {
        sqlx::query_as!(User, "SELECT id, email, role, created_at FROM users WHERE id = ?", id)
            .fetch_optional(&self.pool)
            .await
    }

    async fn store_refresh(&self, hash: &str, user_id: i64, expires_at: i64) -> sqlx::Result<()> {
        sqlx::query!(
            "INSERT INTO refresh_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
            hash,
            user_id,
            expires_at
        )
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn take_refresh(&self, hash: &str, now: i64) -> sqlx::Result<Option<i64>> {
        sqlx::query_scalar!(
            r#"DELETE FROM refresh_tokens WHERE token_hash = ? AND expires_at > ? RETURNING user_id as "user_id!""#,
            hash,
            now
        )
        .fetch_optional(&self.pool)
        .await
    }

    async fn delete_refresh(&self, hash: &str) -> sqlx::Result<()> {
        sqlx::query!("DELETE FROM refresh_tokens WHERE token_hash = ?", hash).execute(&self.pool).await?;
        Ok(())
    }

    async fn list_comments(&self, post_id: i64) -> sqlx::Result<Vec<Comment>> {
        sqlx::query_as!(
            Comment,
            "SELECT id, post_id, body, created_at FROM comments WHERE post_id = ? ORDER BY id",
            post_id
        )
        .fetch_all(&self.pool)
        .await
    }

    async fn add_comment(&self, post_id: i64, new: NewComment) -> sqlx::Result<Comment> {
        sqlx::query_as!(
            Comment,
            r#"INSERT INTO comments (post_id, body) VALUES (?, ?) RETURNING id as "id!", post_id, body, created_at"#,
            post_id,
            new.body
        )
        .fetch_one(&self.pool)
        .await
    }

    async fn list_users(&self) -> sqlx::Result<Vec<User>> {
        sqlx::query_as!(User, "SELECT id, email, role, created_at FROM users ORDER BY id")
            .fetch_all(&self.pool)
            .await
    }

    async fn add_image(&self, post_id: i64, url: &str) -> sqlx::Result<Image> {
        sqlx::query_as!(
            Image,
            r#"INSERT INTO images (post_id, url) VALUES (?, ?) RETURNING id as "id!", post_id, url"#,
            post_id,
            url
        )
        .fetch_one(&self.pool)
        .await
    }
}

fn fts_query(input: &str) -> String {
    input
        .split_whitespace()
        .map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
        .collect::<Vec<_>>()
        .join(" ")
}

Recap

  • Multipart needs axum's multipart feature and must be the last extractor
  • Never use the client's file name for a path: make your own with a UUID
  • ServeDir serves the uploads folder; nest_service mounts it under /files
  • axum's default body limit is 2 MB (413); DefaultBodyLimit::max raises it for one route
  • Check magic bytes, not the name or the claimed type: 415 for anything else

Next episode: where the files live. A Storage trait, so our images can be saved without the handler caring how. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam