Rust Web Server with Axum 0.8: Caching with RwLock, then Redis
November 3, 2026
Episode 28 of the Rust Backend series, where we build the backend of a blog step by step. After episode 27 emails go out in the background. But every time someone reads a post, we ask the database, even if nothing changed. For a popular post, that is the same question a thousand times a minute. Today: caching. All the code below is the final, tested code from the video, ready to copy.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
We continue in the same blog_api project from episode 27. New today: the redis crate, and the Redis server (sudo apt install redis-server on Ubuntu).
1. Hit and miss
A cache remembers answers. On a read, look in the cache first:
- hit: answer right away
- miss: ask the database, then remember the answer for next time
2. In memory, with RwLock
First, in memory: a HashMap from id to post, behind an RwLock (many readers at the same time, or one writer). New file src/cache.rs:
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use crate::models::Post;
pub type PostCache = Arc<RwLock<HashMap<i64, Post>>>;The app takes it as one more parameter and hands it to the handlers as an Extension (.layer(Extension(cache)), with let cache = PostCache::default(); in main).
get_post, first version: take a read lock, look up the id; if it is there, return it; if not, ask the database and remember the answer:
pub async fn get_post<R: Repo>(
State(repo): State<R>,
Extension(cache): Extension<PostCache>,
Path(id): Path<i64>,
) -> Result<Json<Post>, ApiError> {
let cached = cache.read().unwrap();
if let Some(post) = cached.get(&id) {
tracing::debug!(id, "cache hit");
return Ok(Json(post.clone()));
}
tracing::debug!(id, "cache miss");
let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
cache.write().unwrap().insert(id, post.clone());
Ok(Json(post))
}3. The hidden "not Send" error
cargo buildA long error, and it only says: the trait Handler<_, _> is not implemented. The real reason is hidden: axum needs a handler's future to be Send, and ours holds a standard lock guard (cached) across an .await. A std::sync guard cannot move to another thread, and after an .await the task may continue on any thread of Tokio's pool.
(This version would also deadlock: the read guard is still alive when we ask for the write lock.)
The fix: finish with the lock before the await. A small block, and clone the post out. The guard is dropped at the closing brace:
let cached = {
let guard = cache.read().unwrap();
guard.get(&id).cloned()
};
if let Some(post) = cached {
tracing::debug!(id, "cache hit");
return Ok(Json(post));
}Run, make a post, and read post 1 twice: the log shows a cache miss, then a cache hit.
4. Stale data
Now we update the post, and read it again:
curl -s -X PUT localhost:8080/posts/1 -H 'content-type: application/json' -H "authorization: Bearer $TOKEN" -d '{"title":"New title","body":"Changed."}' | jq -c .title; curl -s localhost:8080/posts/1 | jq -c .titleThe PUT answers with "New title", but the GET right after it shows the old title! The cache doesn't know the post changed. This is the hard part of caching. The fix: every write removes the post from the cache. In update_post and delete_post:
cache.write().unwrap().remove(&id);cargo run
curl -s localhost:8080/posts/1 | jq -c .title; curl -s -X PUT localhost:8080/posts/1 -H 'content-type: application/json' -H "authorization: Bearer $TOKEN" -d '{"title":"Newer title","body":"Changed."}' | jq -c .title; curl -s localhost:8080/posts/1 | jq -c .titleNow the read after the update shows the new title.
5. Two servers need a shared cache
With two servers behind a load balancer, each has its own memory: server A removes the old post, server B still serves it. A shared cache solves that: Redis.
Redis is a separate program. On Ubuntu it is one apt install, and it is already running:
redis-cli pingPONGWe add the redis crate, with Tokio and a connection manager that reconnects by itself:
cargo add redis --features tokio-comp,connection-manager6. get, put and del
Redis stores text, so we store each post as JSON under the key post:<id>. Three functions in cache.rs; every Redis error is only logged:
use redis::{aio::ConnectionManager, AsyncCommands};
use crate::models::Post;
pub async fn get(redis: &ConnectionManager, id: i64) -> Option<Post> {
let mut conn = redis.clone();
match conn.get::<_, Option<String>>(format!("post:{id}")).await {
Ok(Some(json)) => serde_json::from_str(&json).ok(),
Ok(None) => None,
Err(err) => {
tracing::warn!(?err, "redis get failed");
None
}
}
}
pub async fn put(redis: &ConnectionManager, post: &Post) {
let mut conn = redis.clone();
let json = serde_json::to_string(post).unwrap();
if let Err(err) = conn.set_ex::<_, _, ()>(format!("post:{}", post.id), json, 60).await {
tracing::warn!(?err, "redis set failed");
}
}
pub async fn del(redis: &ConnectionManager, id: i64) {
let mut conn = redis.clone();
if let Err(err) = conn.del::<_, ()>(format!("post:{id}")).await {
tracing::warn!(?err, "redis del failed");
}
}set_ex stores with an expiry: 60 seconds. Even if we forget an invalidation somewhere, a stale answer lives at most one minute. Cloning a ConnectionManager is cheap: all clones share one connection.
The handlers use these instead of the map:
pub async fn get_post<R: Repo>(
State(repo): State<R>,
Extension(redis): Extension<ConnectionManager>,
Path(id): Path<i64>,
) -> Result<Json<Post>, ApiError> {
if let Some(post) = cache::get(&redis, id).await {
tracing::debug!(id, "cache hit");
return Ok(Json(post));
}
tracing::debug!(id, "cache miss");
let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
cache::put(&redis, &post).await;
Ok(Json(post))
}and update_post / delete_post call cache::del(&redis, id).await;. main connects once (REDIS_URL, default redis://127.0.0.1:6379), with short timeouts so a dead Redis can't slow every request down:
let client = redis::Client::open(cfg.redis_url.as_str()).unwrap();
let redis_config = ConnectionManagerConfig::new()
.set_number_of_retries(1)
.set_connection_timeout(Some(Duration::from_millis(300)))
.set_response_timeout(Some(Duration::from_millis(300)));
let cache = ConnectionManager::new_with_config(client, redis_config).await.unwrap();7. Post needs Deserialize
cargo buildError E0277: Post does not implement Deserialize, which serde_json::from_str needs. We only ever sent posts out, never read them in. Now we read them back from Redis:
#[derive(Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct Post {8. Redis in action
cargo run
curl -s localhost:8080/posts/1 | jq -c .title; curl -s localhost:8080/posts/1 | jq -c .titleA miss, then a hit in the log. In Redis, the key and its time to live:
redis-cli ttl post:1; redis-cli get post:1The TTL counts down from 60, and the value is the post as JSON.
What if Redis is down?
sudo systemctl stop redis-server
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/posts/1Still 200, from the database, with a WARN redis get failed in the log. The cache is a helper, never a must.
The final code
src/cache.rs
use redis::{aio::ConnectionManager, AsyncCommands};
use crate::models::Post;
pub async fn get(redis: &ConnectionManager, id: i64) -> Option<Post> {
let mut conn = redis.clone();
match conn.get::<_, Option<String>>(format!("post:{id}")).await {
Ok(Some(json)) => serde_json::from_str(&json).ok(),
Ok(None) => None,
Err(err) => {
tracing::warn!(?err, "redis get failed");
None
}
}
}
pub async fn put(redis: &ConnectionManager, post: &Post) {
let mut conn = redis.clone();
let json = serde_json::to_string(post).unwrap();
if let Err(err) = conn.set_ex::<_, _, ()>(format!("post:{}", post.id), json, 60).await {
tracing::warn!(?err, "redis set failed");
}
}
pub async fn del(redis: &ConnectionManager, id: i64) {
let mut conn = redis.clone();
if let Err(err) = conn.del::<_, ()>(format!("post:{id}")).await {
tracing::warn!(?err, "redis del failed");
}
}src/config.rs
use std::sync::OnceLock;
pub struct Config {
pub host: String,
pub port: u16,
pub database_url: String,
pub jwt_secret: String,
pub storage: String,
pub smtp_host: String,
pub smtp_port: u16,
pub mail_from: String,
pub cleanup_secs: u64,
pub redis_url: 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());
let smtp_host = std::env::var("SMTP_HOST").unwrap_or("localhost".to_string());
let smtp_port = std::env::var("SMTP_PORT").unwrap_or("1025".to_string()).parse().expect("SMTP_PORT is a port number");
let mail_from = std::env::var("MAIL_FROM").unwrap_or("LCE Blog <blog@example.com>".to_string());
let cleanup_secs = std::env::var("CLEANUP_SECS").unwrap_or("3600".to_string()).parse().expect("CLEANUP_SECS is a number");
let redis_url = std::env::var("REDIS_URL").unwrap_or("redis://127.0.0.1:6379".to_string());
Config { host, port, database_url, jwt_secret, storage, smtp_host, smtp_port, mail_from, cleanup_secs, redis_url }
}
}
static CONFIG: OnceLock<Config> = OnceLock::new();
pub fn config() -> &'static Config {
CONFIG.get_or_init(Config::from_env)
}src/handlers.rs
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;
use crate::jobs::Job;
use tokio::sync::mpsc;
use crate::cache;
use redis::aio::ConnectionManager;
#[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>,
Extension(redis): Extension<ConnectionManager>,
Path(id): Path<i64>,
) -> Result<Json<Post>, ApiError> {
if let Some(post) = cache::get(&redis, id).await {
tracing::debug!(id, "cache hit");
return Ok(Json(post));
}
tracing::debug!(id, "cache miss");
let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
cache::put(&redis, &post).await;
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>,
Extension(redis): Extension<ConnectionManager>,
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))?;
cache::del(&redis, id).await;
Ok(Json(post))
}
pub async fn delete_post<R: Repo>(State(repo): State<R>, Extension(redis): Extension<ConnectionManager>, 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?;
cache::del(&redis, 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>,
Extension(jobs): Extension<mpsc::Sender<Job>>,
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),
})?;
if jobs.send(Job::Welcome { email: user.email.clone() }).await.is_err() {
tracing::error!("job queue closed");
}
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
mod auth;
mod config;
mod error;
mod extract;
mod handlers;
mod models;
mod repo;
mod storage;
mod mail;
mod jobs;
mod cache;
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};
use mail::Mailer;
use jobs::Job;
use tokio::sync::mpsc;
use tokio::time::Duration;
use redis::aio::{ConnectionManager, ConnectionManagerConfig};
pub fn app<R: Repo, S: Storage>(repo: R, store: S, jobs: mpsc::Sender<Job>, cache: ConnectionManager) -> 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(Extension(jobs))
.layer(Extension(cache))
.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 };
let (tx, rx) = mpsc::channel::<Job>(100);
let mailer = Mailer::new(cfg);
tokio::spawn(jobs::worker(rx, mailer));
tokio::spawn(jobs::cleanup(repo.clone(), Duration::from_secs(cfg.cleanup_secs)));
let client = redis::Client::open(cfg.redis_url.as_str()).unwrap();
let redis_config = ConnectionManagerConfig::new()
.set_number_of_retries(1)
.set_connection_timeout(Some(Duration::from_millis(300)))
.set_response_timeout(Some(Duration::from_millis(300)));
let cache = ConnectionManager::new_with_config(client, redis_config).await.unwrap();
match cfg.storage.as_str() {
"memory" => serve(app(repo, MemStorage::default(), tx, cache), &addr).await,
_ => serve(app(repo, LocalStorage::new("uploads"), tx, cache), &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/models.rs
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};
#[derive(Serialize, Deserialize, 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,
}Recap
- Cache hit: answer at once; miss: ask the database and remember the answer
- A
std::synclock guard held across.awaitmakes the handler's future notSend: drop the guard in a block first - Every write must invalidate the cached copy, or readers see stale data
- Redis shares the cache between servers; store JSON with
set_exand an expiry - If the cache fails, log it and fall back to the database
Next episode: live comments over WebSocket. Watch the series on YouTube.