Rust Web Server with Axum 0.8: Login with JSON Web Tokens and 401
November 3, 2026
Episode 18 of the Rust Backend series, where we build the backend of a blog step by step. After episode 17 Ana can sign up, and her password is stored as an argon2 hash. Now she wants to log in, and then prove on every request that it is really her. Today: login, and JSON Web Tokens. 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 17. One new crate today: jsonwebtoken.
1. How a JWT works
After login, the server gives Ana a token. A JWT has three parts, split by dots:
header.claims.signatureThe header names the algorithm, the claims say who she is (her user id) and when the token expires, and the signature is made with a secret that only our server knows. When she sends the token back, we check the signature: if anyone changed a single character, it no longer matches.
2. The secret
The secret goes into .env. Make a long random one:
openssl rand -hex 32JWT_SECRET=5c0e8f1b3a9d47e2b6f0a4c8d1e7239f6b5a0c3d8e1f47a29b6c5d0e3f8a1b47(That is the demo value from the video. Use your own, and never commit it.) Config reads it, and the server refuses to start without it:
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
Config { host, port, database_url, jwt_secret }3. Claims and make_token
cargo add jsonwebtokenIn auth.rs, the claims: sub (the subject, our user id), role, and exp, the expiry time in seconds since 1970:
use jsonwebtoken::{encode, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Serialize, Deserialize)]
pub struct Claims {
pub sub: i64,
pub role: String,
pub exp: u64,
}
pub fn make_token(user_id: i64, role: &str, secret: &str) -> Result<String, ApiError> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let claims = Claims { sub: user_id, role: role.to_string(), exp: now + 3600 };
Ok(encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?)
}The token lives one hour. Header::default() means HS256: an HMAC with SHA-256 and our secret. A Jwt variant in ApiError (#[from] jsonwebtoken::errors::Error, a 500 with the details only in the log) lets ? work.
4. Pick a crypto backend
To try it, main prints one token at start (println!("{:?}", auth::make_token(1, "user", &cfg.jwt_secret));):
cargo runAn error: jsonwebtoken needs a crypto backend, and we did not choose one. Since version 11 the crate makes us pick between aws_lc_rs and rust_crypto. We pick aws_lc_rs:
cargo add jsonwebtoken --features aws_lc_rsNow it runs and prints a token. Remove the println! again.
5. Find the user and verify the password
Login needs the user with the hash, so a separate type (we never serialize it):
pub struct UserWithHash {
pub id: i64,
pub email: String,
pub password_hash: String,
pub role: String,
}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
}verify_password parses the stored hash string (it contains the salt and settings) and checks the password, again in spawn_blocking, because argon2 is slow on purpose:
use argon2::password_hash::{phc::PasswordHash, PasswordVerifier};
pub async fn verify_password(password: String, hash: String) -> Result<bool, ApiError> {
let ok = tokio::task::spawn_blocking(move || {
let parsed = PasswordHash::new(&hash)?;
Ok::<_, argon2::password_hash::Error>(Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
})
.await
.expect("verify task panicked")?;
Ok(ok)
}6. The login handler and one 401
#[derive(Deserialize)]
pub struct Login {
pub email: String,
pub password: String,
}
pub async fn login<R: Repo>(State(repo): State<R>, Json(body): Json<Login>) -> Result<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 token = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
Ok(Json(json!({ "token": token })))
}A new variant, 401 Unauthorized:
#[error("invalid email or password")]
Unauthorized,An unknown email and a wrong password give the same answer. We never tell an attacker which part was wrong, or they could find out which emails have accounts.
.route("/login", post(handlers::login::<R>))7. Log in
cargo run
TOKEN=$(curl -s localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"hunter2hunter2"}' | jq -r .token)
echo $TOKEN200 OK and a token: three parts, split by dots. The middle part is just base64. Anyone can read it:
echo $TOKEN | cut -d. -f2 | base64 -dIt shows sub, role and exp in plain JSON. A JWT is signed, not encrypted: never put secrets in it.
A wrong password:
curl -i localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"wrong-password"}'HTTP/1.1 401 Unauthorized
{"error":"invalid email or password"}8. /me: use the token
A route that uses the token. read_token decodes and checks it; any problem (bad signature, expired, garbage) becomes a 401, and only the log gets the reason:
pub fn read_token(token: &str, secret: &str) -> Result<Claims, ApiError> {
let data = decode::<Claims>(token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::new(Algorithm::HS256))
.map_err(|err| {
tracing::info!(?err, "bad token");
ApiError::Unauthorized
})?;
Ok(data.claims)
}The me handler reads the Authorization header, removes Bearer , decodes the token, and loads the user:
pub async fn me<R: Repo>(State(repo): State<R>, headers: HeaderMap) -> Result<Json<User>, ApiError> {
let token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(ApiError::Unauthorized)?;
let claims = auth::read_token(token, &config().jwt_secret)?;
let user = repo.get_user(claims.sub).await?.ok_or(ApiError::Unauthorized)?;
Ok(Json(user))
}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
}.route("/me", get(handlers::me::<R>))cargo run
curl -s localhost:8080/me -H "authorization: Bearer $TOKEN"; echo200 OK, and Ana's user: id, email, role and created_at, no hash.
9. Change one letter
Now we change one character near the end of the token (the signature):
curl -s -i localhost:8080/me -H "authorization: Bearer $(echo $TOKEN | sed 's/.\(.\)$/Z\1/')"HTTP/1.1 401 UnauthorizedThe log says InvalidSignature. Nobody can change a token, for example to "role":"admin", without the secret.
/me works, but those header lines would be copied into every protected handler. Next time we turn them into an extractor.
The final code
.env
HOST=0.0.0.0
PORT=8080
RUST_LOG=blog_api=debug,tower_http=debug
DATABASE_URL=sqlite://blog.db
JWT_SECRET=5c0e8f1b3a9d47e2b6f0a4c8d1e7239f6b5a0c3d8e1f47a29b6c5d0e3f8a1b47src/auth.rs
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use argon2::{password_hash::PasswordHasher, Argon2};
use argon2::password_hash::{phc::PasswordHash, PasswordVerifier};
use crate::error::ApiError;
pub async fn hash_password(password: String) -> Result<String, ApiError> {
let hash = tokio::task::spawn_blocking(move || {
Argon2::default().hash_password(password.as_bytes()).map(|h| h.to_string())
})
.await
.expect("hash task panicked")?;
Ok(hash)
}
#[derive(Serialize, Deserialize)]
pub struct Claims {
pub sub: i64,
pub role: String,
pub exp: u64,
}
pub fn make_token(user_id: i64, role: &str, secret: &str) -> Result<String, ApiError> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let claims = Claims { sub: user_id, role: role.to_string(), exp: now + 3600 };
Ok(encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?)
}
pub async fn verify_password(password: String, hash: String) -> Result<bool, ApiError> {
let ok = tokio::task::spawn_blocking(move || {
let parsed = PasswordHash::new(&hash)?;
Ok::<_, argon2::password_hash::Error>(Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
})
.await
.expect("verify task panicked")?;
Ok(ok)
}
pub fn read_token(token: &str, secret: &str) -> Result<Claims, ApiError> {
let data = decode::<Claims>(token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::new(Algorithm::HS256))
.map_err(|err| {
tracing::info!(?err, "bad token");
ApiError::Unauthorized
})?;
Ok(data.claims)
}src/config.rs
use std::sync::OnceLock;
pub struct Config {
pub host: String,
pub port: u16,
pub database_url: String,
pub jwt_secret: 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");
Config { host, port, database_url, jwt_secret }
}
}
static CONFIG: OnceLock<Config> = OnceLock::new();
pub fn config() -> &'static Config {
CONFIG.get_or_init(Config::from_env)
}src/error.rs
use axum::extract::rejection::{JsonRejection, QueryRejection};
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,
}
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::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
}
};
let message = match &self {
ApiError::BadJson(rejection) => rejection.body_text(),
ApiError::BadQuery(rejection) => rejection.body_text(),
other => other.to_string(),
};
(status, Json(json!({ "error": message }))).into_response()
}
}src/handlers.rs
use axum::extract::{rejection::QueryRejection, Path, Query, State};
use axum::{http::{header, HeaderMap, StatusCode}, Json};
use crate::error::ApiError;
use crate::extract::ValidJson;
use crate::models::{Comment, Hit, 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};
#[tracing::instrument(skip_all)]
pub async fn create_post<R: Repo>(
State(repo): State<R>,
ValidJson(new): ValidJson<NewPost>,
) -> Result<(StatusCode, Json<PostWithTags>), ApiError> {
let post = repo.create(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>,
ValidJson(new): ValidJson<NewPost>,
) -> Result<Json<Post>, ApiError> {
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>) -> Result<StatusCode, ApiError> {
if !repo.delete(id).await? {
return Err(ApiError::NotFound(id));
}
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>,
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,
}
pub async fn login<R: Repo>(State(repo): State<R>, Json(body): Json<Login>) -> Result<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 token = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
Ok(Json(json!({ "token": token })))
}
pub async fn me<R: Repo>(State(repo): State<R>, headers: HeaderMap) -> Result<Json<User>, ApiError> {
let token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(ApiError::Unauthorized)?;
let claims = auth::read_token(token, &config().jwt_secret)?;
let user = repo.get_user(claims.sub).await?.ok_or(ApiError::Unauthorized)?;
Ok(Json(user))
}src/main.rs
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;
pub fn app<R: Repo>(repo: R) -> Router {
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("/signup", post(handlers::signup::<R>))
.route("/login", post(handlers::login::<R>))
.route("/me", get(handlers::me::<R>))
.with_state(repo)
.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 app = app(SqliteRepo { pool });
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
tracing::info!(%addr, "listening");
axum::serve(listener, app).await.unwrap();
}src/models.rs
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 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,
}src/repo.rs
use std::future::Future;
use sqlx::{QueryBuilder, Sqlite, SqlitePool};
use crate::models::{slugify, Comment, Hit, 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, 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;
}
#[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 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 FROM posts WHERE id = ? AND deleted_at IS NULL", id)
.fetch_optional(&self.pool)
.await
}
async fn create(&self, 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) VALUES (?, ?, ?) RETURNING id, title, body, slug, created_at"#,
new.title,
new.body,
slug
)
.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"#,
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 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
}
}
fn fts_query(input: &str) -> String {
input
.split_whitespace()
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" ")
}Recap
- A JWT is
header.claims.signature:sub,roleandexp, signed with a secret from.env - jsonwebtoken 11 needs a crypto backend feature:
aws_lc_rsorrust_crypto verify_passwordchecks against the argon2 hash, inspawn_blocking- Unknown email and wrong password give the same 401
- Signed is not encrypted: anyone can read the claims, nobody can change them
Next episode: an auth extractor. Protected routes become one parameter. Watch the series on YouTube.