Rust Web Server with Axum 0.8: OpenAPI Docs and Swagger UI with utoipa
November 3, 2026
Episode 34 of the Rust Backend series, where we build the backend of a blog step by step. After episode 33 the server reports its health and metrics. Now a frontend developer asks: which routes are there, what do I send, what do I get back? A wiki page goes out of date the day after we write it. Today: docs generated from our code, with OpenAPI and Swagger UI. 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 33. New crates today: utoipa and utoipa-swagger-ui.
1. OpenAPI
OpenAPI is a standard JSON file that describes an API: its paths, parameters, request and response types. Tools read it. Swagger UI shows it as a web page where you can even try the requests.
our code โ openapi.json โ Swagger UIcargo add utoipa
cargo add utoipa-swagger-ui --features axumutoipa writes the file from our code, and utoipa-swagger-ui serves the page. (It downloads the Swagger UI files while it builds, so the first build needs the network.)
2. Describe a handler
Each documented handler gets a #[utoipa::path] attribute: the method, the path, the parameters, and the possible answers with their types:
#[utoipa::path(
get,
path = "/api/v2/posts/{id}",
params(("id" = i64, Path, description = "Post id")),
responses(
(status = 200, description = "The post", body = PostV2),
(status = 404, description = "No such post", body = ErrorBody),
),
tag = "posts"
)]
pub async fn get_post_v2<R: Repo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<Json<PostV2>, ApiError> {Our errors are built with json!, so for the docs we add a small type with the same shape, in error.rs:
#[derive(Serialize)]
pub struct ErrorBody {
pub error: String,
}An ApiDoc struct derives OpenApi, and lists the documented paths and types:
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(handlers::get_post_v2),
components(schemas(models::PostV2, error::ErrorBody))
)]
struct ApiDoc;3. ToSchema
cargo buildError: the trait ToSchema is not implemented for PostV2. utoipa must know what a post looks like, field by field. So the types derive ToSchema:
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct PostV2 {
pub id: i64,
#[schema(example = "Hello")]
pub title: String,
/// The post's text in Markdown.
pub content: String,
pub slug: String,
// ...
}and #[derive(Serialize, ToSchema)] on ErrorBody too. Two extras:
- Doc comments (
///) become descriptions in the docs. #[schema(example = "...")]gives the reader an example value.
4. Swagger UI
SwaggerUi is a router: the page at /swagger-ui, the file at /api-docs/openapi.json. We merge it:
use utoipa_swagger_ui::SwaggerUi;
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))cargo run
curl -s localhost:8080/api-docs/openapi.json | jq '.paths | keys'[
"/api/v2/posts/{id}"
]The path we documented. And the type of a post, with our description and our example:
curl -s localhost:8080/api-docs/openapi.json | jq -c '.components.schemas.PostV2.properties | {title, content}'title is a string with the example "Hello", and content carries our doc comment as its description. The page itself is served too:
curl -s localhost:8080/swagger-ui/ | grep -o '<title>.*</title>'<title>Swagger UI</title>In a browser, localhost:8080/swagger-ui/ shows every documented route, with a Try it out button for real requests.
5. Protected routes and bearer auth
Protected routes need a token. We document create_post with its request type, its answers, and a security requirement:
#[utoipa::path(
post,
path = "/posts",
request_body = NewPost,
responses(
(status = 201, description = "Created", body = PostWithTags),
(status = 401, description = "Missing or bad token", body = ErrorBody),
(status = 422, description = "Validation failed", body = ErrorBody),
),
security(("bearer" = [])),
tag = "posts"
)]
#[tracing::instrument(skip_all)]
pub async fn create_post<R: Repo>(Post, NewPost and PostWithTags derive ToSchema too. The bearer scheme itself is added by a modifier, which changes the generated document:
use utoipa::{Modify, OpenApi};
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
#[derive(OpenApi)]
#[openapi(
paths(handlers::get_post_v2, handlers::create_post),
components(schemas(models::PostV2, models::NewPost, models::PostWithTags, models::Post, error::ErrorBody)),
modifiers(&SecurityAddon)
)]
struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer",
SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build()),
);
}
}It also adds an Authorize button to the Swagger UI page, where you paste a token once.
cargo run
curl -s localhost:8080/api-docs/openapi.json | jq -c '.components.securitySchemes'; curl -s localhost:8080/api-docs/openapi.json | jq -c '.paths["/posts"].post.security'The file now has a bearer scheme (HTTP, bearer, format JWT), and POST /posts requires it.
One warning: the docs come from the code, but not all of it. If we change a route and forget its attribute, they lie. A test (next episode) can check that every route is documented.
The final code
src/error.rs
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;
use serde::Serialize;
use utoipa::ToSchema;
#[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()
}
}
#[derive(Serialize, ToSchema)]
pub struct ErrorBody {
pub error: String,
}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, ErrorBody};
use crate::extract::{AdminUser, AuthUser, Role, ValidJson};
use crate::models::{Comment, Hit, Image, ListFilter, ListParams, NewComment, NewPost, NewUser, Page, Post, PostV2, 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;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use tokio::sync::broadcast;
use tokio::sync::broadcast::error::RecvError;
use axum::response::sse::{Event, KeepAlive, Sse};
use std::convert::Infallible;
use tokio_stream::{wrappers::BroadcastStream, Stream, StreamExt};
use metrics_exporter_prometheus::PrometheusHandle;
#[utoipa::path(
post,
path = "/posts",
request_body = NewPost,
responses(
(status = 201, description = "Created", body = PostWithTags),
(status = 401, description = "Missing or bad token", body = ErrorBody),
(status = 422, description = "Validation failed", body = ErrorBody),
),
security(("bearer" = [])),
tag = "posts"
)]
#[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");
metrics::counter!("posts_created_total").increment(1);
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>,
Extension(live): Extension<broadcast::Sender<Comment>>,
_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),
}
})?;
let _ = live.send(comment.clone());
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())
}
pub async fn live(
Extension(live): Extension<broadcast::Sender<Comment>>,
Path(post_id): Path<i64>,
ws: WebSocketUpgrade,
) -> Response {
let rx = live.subscribe();
ws.on_upgrade(move |socket| live_socket(socket, rx, post_id))
}
async fn live_socket(mut socket: WebSocket, mut rx: broadcast::Receiver<Comment>, post_id: i64) {
loop {
tokio::select! {
event = rx.recv() => match event {
Ok(c) => {
if c.post_id != post_id {
continue;
}
let text = serde_json::to_string(&c).unwrap();
if socket.send(Message::Text(text.into())).await.is_err() {
break;
}
}
Err(RecvError::Lagged(n)) => {
let _ = socket.send(Message::Text(format!("{{\"missed\":{n}}}").into())).await;
}
Err(RecvError::Closed) => break,
},
msg = socket.recv() => match msg {
None | Some(Ok(Message::Close(_))) | Some(Err(_)) => break,
Some(Ok(_)) => {}
},
}
}
tracing::info!(post_id, "live socket closed");
}
pub async fn events(
Extension(live): Extension<broadcast::Sender<Comment>>,
Path(post_id): Path<i64>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let stream = BroadcastStream::new(live.subscribe()).filter_map(move |msg| match msg {
Ok(c) if c.post_id == post_id => Some(Ok(Event::default().event("comment").json_data(&c).unwrap())),
_ => None,
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
#[utoipa::path(
get,
path = "/api/v2/posts/{id}",
params(("id" = i64, Path, description = "Post id")),
responses(
(status = 200, description = "The post", body = PostV2),
(status = 404, description = "No such post", body = ErrorBody),
),
tag = "posts"
)]
pub async fn get_post_v2<R: Repo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<Json<PostV2>, ApiError> {
let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
Ok(Json(post.into()))
}
#[derive(Deserialize)]
pub struct SlowParams {
secs: Option<u64>,
}
pub async fn slow(Query(p): Query<SlowParams>) -> &'static str {
tokio::time::sleep(std::time::Duration::from_secs(p.secs.unwrap_or(30))).await;
"finally done"
}
pub async fn health_live() -> &'static str {
"ok"
}
pub async fn health_ready<R: Repo>(
State(repo): State<R>,
Extension(redis): Extension<ConnectionManager>,
) -> (StatusCode, Json<Value>) {
let db = repo.ping().await.is_ok();
let redis_ok = redis::cmd("PING").query_async::<String>(&mut redis.clone()).await.is_ok();
let status = if db && redis_ok { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE };
(status, Json(json!({ "database": if db { "up" } else { "down" }, "redis": if redis_ok { "up" } else { "down" } })))
}
pub async fn metrics_text(Extension(handle): Extension<PrometheusHandle>) -> String {
handle.render()
}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, put}, 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, HeaderName, HeaderValue, Method, StatusCode};
use tower_http::cors::CorsLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::timeout::TimeoutLayer;
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};
use tokio::sync::broadcast;
use models::Comment;
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use axum::extract::{MatchedPath, Request};
use axum::middleware::{self, Next};
use axum::response::Response;
use utoipa::{Modify, OpenApi};
use utoipa_swagger_ui::SwaggerUi;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
pub fn app<R: Repo, S: Storage>(repo: R, store: S, jobs: mpsc::Sender<Job>, cache: ConnectionManager, live: broadcast::Sender<Comment>, handle: PrometheusHandle) -> 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));
let streaming = Router::new()
.route("/posts/{id}/live", get(handlers::live))
.route("/posts/{id}/events", get(handlers::events));
Router::new()
.nest("/api/v1", posts_v1())
.nest("/api/v2", posts_v2())
.merge(posts_v1())
.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>))
.route("/debug/slow", get(handlers::slow))
.route("/health/live", get(handlers::health_live))
.route("/health/ready", get(handlers::health_ready::<R>))
.route("/metrics", get(handlers::metrics_text))
.merge(auth_routes)
.layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(10)))
.merge(streaming)
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
.route_layer(middleware::from_fn(track))
.with_state(repo)
.layer(Extension(Arc::new(store)))
.layer(Extension(jobs))
.layer(Extension(cache))
.layer(Extension(live))
.layer(Extension(handle))
.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();
let (live, _) = broadcast::channel::<Comment>(100);
let handle = PrometheusBuilder::new().set_buckets(&[0.005, 0.01, 0.05, 0.1, 0.5, 1.0]).unwrap().install_recorder().unwrap();
match cfg.storage.as_str() {
"memory" => serve(app(repo, MemStorage::default(), tx, cache, live, handle), &addr).await,
_ => serve(app(repo, LocalStorage::new("uploads"), tx, cache, live, handle), &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>())
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
}
fn posts_common<R: Repo>() -> Router<R> {
Router::new()
.route("/posts", get(handlers::list_posts::<R>).post(handlers::create_post::<R>))
.route(
"/posts/{id}",
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>))
}
fn posts_v1<R: Repo>() -> Router<R> {
posts_common()
.route("/posts/{id}", get(handlers::get_post::<R>))
.layer(SetResponseHeaderLayer::overriding(HeaderName::from_static("deprecation"), HeaderValue::from_static("true")))
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("sunset"),
HeaderValue::from_static("Wed, 30 Jun 2027 00:00:00 GMT"),
))
.layer(SetResponseHeaderLayer::overriding(header::LINK, HeaderValue::from_static(r#"</api/v2>; rel="successor-version""#)))
}
fn posts_v2<R: Repo>() -> Router<R> {
posts_common().route("/posts/{id}", get(handlers::get_post_v2::<R>))
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c().await.expect("Ctrl+C handler");
};
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM handler")
.recv()
.await;
};
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutting down");
}
async fn track(req: Request, next: Next) -> Response {
let start = std::time::Instant::now();
let method = req.method().to_string();
let path = req
.extensions()
.get::<MatchedPath>()
.map(|p| p.as_str().to_string())
.unwrap_or_else(|| "unmatched".to_string());
let response = next.run(req).await;
let status = response.status().as_u16().to_string();
let labels = [("method", method), ("path", path), ("status", status)];
metrics::counter!("http_requests_total", &labels).increment(1);
metrics::histogram!("http_request_duration_seconds", &labels).record(start.elapsed().as_secs_f64());
response
}
#[derive(OpenApi)]
#[openapi(
paths(handlers::get_post_v2, handlers::create_post),
components(schemas(models::PostV2, models::NewPost, models::PostWithTags, models::Post, error::ErrorBody)),
modifiers(&SecurityAddon)
)]
struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer",
SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build()),
);
}
}src/models.rs
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, Clone, sqlx::FromRow, ToSchema)]
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, ToSchema)]
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, ToSchema)]
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,
}
#[derive(Serialize, ToSchema)]
pub struct PostV2 {
pub id: i64,
#[schema(example = "Hello")]
pub title: String,
/// The post's text in Markdown.
pub content: String,
pub slug: String,
pub author_id: Option<i64>,
pub created_at: String,
}
impl From<Post> for PostV2 {
fn from(p: Post) -> PostV2 {
PostV2 { id: p.id, title: p.title, content: p.body, slug: p.slug, author_id: p.author_id, created_at: p.created_at }
}
}Recap
- OpenAPI is a JSON description of an API; Swagger UI turns it into a page you can try
#[utoipa::path(...)]on each handler: method, path, params, responses- Types derive
ToSchema;///comments and#[schema(example = ...)]enrich the docs #[derive(OpenApi)]collects it all;SwaggerUi::new(...).url(...)serves page and file- A
Modifyadds the bearer scheme;security(("bearer" = []))marks protected routes
Next episode: testing. Unit tests, tests against the whole router, and a fresh database per test. Watch the series on YouTube.