Building a REST API
This topic walks through building a complete CRUD API with Axum. Not toy code — the patterns here are what you will use in production. We will build a task management API with proper error handling, validation, database integration, and authentication middleware.
Project Setup
Start with the dependencies:
// Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] }
tower-http = { version = "0.5", features = ["trace", "cors"] }
tracing = "0.1"
tracing-subscriber = "0.3"
chrono = { version = "0.4", features = ["serde"] }
jsonwebtoken = "9"
Application Structure
Organize the project into modules:
// src/main.rs
mod db;
mod errors;
mod handlers;
mod models;
mod auth;
use axum::Router;
use std::sync::Arc;
use tower_http::trace::TraceLayer;
struct AppState {
db: sqlx::PgPool,
jwt_secret: String,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let pool = sqlx::PgPool::connect("postgres://localhost/tasks")
.await
.expect("Failed to connect to database");
sqlx::migrate!().run(&pool).await.expect("Failed to run migrations");
let state = Arc::new(AppState {
db: pool,
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"),
});
let app = create_router(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
tracing::info!("Listening on 0.0.0.0:3000");
axum::serve(listener, app).await.unwrap();
}
fn create_router(state: Arc<AppState>) -> Router {
let public = Router::new()
.route("/health", axum::routing::get(|| async { "OK" }))
.route("/auth/login", axum::routing::post(handlers::login));
let protected = Router::new()
.route(
"/tasks",
axum::routing::get(handlers::list_tasks)
.post(handlers::create_task),
)
.route(
"/tasks/:id",
axum::routing::get(handlers::get_task)
.put(handlers::update_task)
.delete(handlers::delete_task),
)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
auth::require_auth,
));
Router::new()
.merge(public)
.merge(protected)
.layer(TraceLayer::new_for_http())
.with_state(state)
}
Public routes have no auth. Protected routes go through the auth middleware. The trace layer covers everything.
Domain Models
Define the types that flow through the system:
// src/models.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Task {
pub id: i64,
pub user_id: i64,
pub title: String,
pub description: Option<String>,
pub completed: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateTask {
pub title: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTask {
pub title: Option<String>,
pub description: Option<String>,
pub completed: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub token: String,
}
Task derives both Serialize for JSON responses and sqlx::FromRow for database mapping. The input types derive Deserialize only.
Error Handling
A unified error type that maps to HTTP responses:
// src/errors.rs
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
#[derive(Debug)]
pub enum AppError {
NotFound(String),
BadRequest(String),
Unauthorized,
Internal(String),
Database(sqlx::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::Unauthorized => {
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
}
AppError::Internal(msg) => {
tracing::error!("Internal error: {}", msg);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
)
}
AppError::Database(err) => {
tracing::error!("Database error: {:?}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
)
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
AppError::Database(err)
}
}
Every handler returns Result<T, AppError>. Database errors convert automatically through the From impl. Internal errors never leak details to the client.
Database Layer
Keep database queries in a separate module:
// src/db.rs
use crate::models::{CreateTask, Task, UpdateTask};
use sqlx::PgPool;
pub async fn get_tasks(pool: &PgPool, user_id: i64) -> Result<Vec<Task>, sqlx::Error> {
sqlx::query_as::<_, Task>(
"SELECT id, user_id, title, description, completed, created_at, updated_at
FROM tasks WHERE user_id = $1 ORDER BY created_at DESC"
)
.bind(user_id)
.fetch_all(pool)
.await
}
pub async fn get_task(pool: &PgPool, id: i64, user_id: i64) -> Result<Option<Task>, sqlx::Error> {
sqlx::query_as::<_, Task>(
"SELECT id, user_id, title, description, completed, created_at, updated_at
FROM tasks WHERE id = $1 AND user_id = $2"
)
.bind(id)
.bind(user_id)
.fetch_optional(pool)
.await
}
pub async fn insert_task(
pool: &PgPool,
user_id: i64,
input: &CreateTask,
) -> Result<Task, sqlx::Error> {
sqlx::query_as::<_, Task>(
"INSERT INTO tasks (user_id, title, description)
VALUES ($1, $2, $3)
RETURNING id, user_id, title, description, completed, created_at, updated_at"
)
.bind(user_id)
.bind(&input.title)
.bind(&input.description)
.fetch_one(pool)
.await
}
pub async fn update_task(
pool: &PgPool,
id: i64,
user_id: i64,
input: &UpdateTask,
) -> Result<Option<Task>, sqlx::Error> {
sqlx::query_as::<_, Task>(
"UPDATE tasks SET
title = COALESCE($3, title),
description = COALESCE($4, description),
completed = COALESCE($5, completed),
updated_at = NOW()
WHERE id = $1 AND user_id = $2
RETURNING id, user_id, title, description, completed, created_at, updated_at"
)
.bind(id)
.bind(user_id)
.bind(&input.title)
.bind(&input.description)
.bind(input.completed)
.fetch_optional(pool)
.await
}
pub async fn delete_task(pool: &PgPool, id: i64, user_id: i64) -> Result<bool, sqlx::Error> {
let result = sqlx::query("DELETE FROM tasks WHERE id = $1 AND user_id = $2")
.bind(id)
.bind(user_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
Every query is parameterized — no SQL injection possible. Each function takes the pool by reference and returns a Result. The handlers will convert these errors through the From<sqlx::Error> impl on AppError.
Authentication Middleware
JWT-based auth that sets the current user on the request:
// src/auth.rs
use crate::errors::AppError;
use crate::AppState;
use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::Response;
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct CurrentUser {
pub id: i64,
pub username: String,
}
#[derive(Serialize, Deserialize)]
struct Claims {
sub: i64,
username: String,
exp: usize,
}
pub async fn require_auth(
State(state): State<Arc<AppState>>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let token = request
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| AppError::Unauthorized)?;
let user = CurrentUser {
id: token_data.claims.sub,
username: token_data.claims.username,
};
request.extensions_mut().insert(user);
Ok(next.run(request).await)
}
The middleware extracts the Bearer token, decodes it, and attaches the CurrentUser to the request. Handlers extract it with Extension<CurrentUser>.
Request Handlers
The handlers tie everything together:
// src/handlers.rs
use crate::auth::CurrentUser;
use crate::errors::AppError;
use crate::{db, models, AppState};
use axum::extract::{Extension, Json, Path, State};
use axum::http::StatusCode;
use std::sync::Arc;
pub async fn list_tasks(
State(state): State<Arc<AppState>>,
Extension(user): Extension<CurrentUser>,
) -> Result<Json<Vec<models::Task>>, AppError> {
let tasks = db::get_tasks(&state.db, user.id).await?;
Ok(Json(tasks))
}
pub async fn get_task(
State(state): State<Arc<AppState>>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<i64>,
) -> Result<Json<models::Task>, AppError> {
let task = db::get_task(&state.db, id, user.id)
.await?
.ok_or_else(|| AppError::NotFound(format!("Task {} not found", id)))?;
Ok(Json(task))
}
pub async fn create_task(
State(state): State<Arc<AppState>>,
Extension(user): Extension<CurrentUser>,
Json(input): Json<models::CreateTask>,
) -> Result<(StatusCode, Json<models::Task>), AppError> {
if input.title.trim().is_empty() {
return Err(AppError::BadRequest("Title cannot be empty".to_string()));
}
if input.title.len() > 200 {
return Err(AppError::BadRequest(
"Title must be 200 characters or fewer".to_string(),
));
}
let task = db::insert_task(&state.db, user.id, &input).await?;
Ok((StatusCode::CREATED, Json(task)))
}
pub async fn update_task(
State(state): State<Arc<AppState>>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<i64>,
Json(input): Json<models::UpdateTask>,
) -> Result<Json<models::Task>, AppError> {
if let Some(ref title) = input.title {
if title.trim().is_empty() {
return Err(AppError::BadRequest("Title cannot be empty".to_string()));
}
}
let task = db::update_task(&state.db, id, user.id, &input)
.await?
.ok_or_else(|| AppError::NotFound(format!("Task {} not found", id)))?;
Ok(Json(task))
}
pub async fn delete_task(
State(state): State<Arc<AppState>>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<i64>,
) -> Result<StatusCode, AppError> {
let deleted = db::delete_task(&state.db, id, user.id).await?;
if deleted {
Ok(StatusCode::NO_CONTENT)
} else {
Err(AppError::NotFound(format!("Task {} not found", id)))
}
}
pub async fn login(
State(state): State<Arc<AppState>>,
Json(input): Json<models::LoginRequest>,
) -> Result<Json<models::LoginResponse>, AppError> {
// In production, verify against hashed passwords in the database
let user_id = verify_credentials(&state.db, &input.username, &input.password)
.await
.map_err(|_| AppError::Unauthorized)?;
let token = create_jwt(user_id, &input.username, &state.jwt_secret);
Ok(Json(models::LoginResponse { token }))
}
Notice the pattern: every handler extracts state, the current user, and any request data. Validation happens before the database call. Errors propagate with ?. The database layer returns Option for single-item lookups, and the handler converts None to AppError::NotFound.
Validation
For the example above, validation is inline. For larger applications, consider a validation library:
use axum::Json;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct CreateTask {
pub title: String,
pub description: Option<String>,
}
impl CreateTask {
pub fn validate(&self) -> Result<(), AppError> {
if self.title.trim().is_empty() {
return Err(AppError::BadRequest("Title is required".to_string()));
}
if self.title.len() > 200 {
return Err(AppError::BadRequest(
"Title must be 200 characters or fewer".to_string(),
));
}
if let Some(ref desc) = self.description {
if desc.len() > 5000 {
return Err(AppError::BadRequest(
"Description must be 5000 characters or fewer".to_string(),
));
}
}
Ok(())
}
}
Call input.validate()? at the top of the handler. The method returns Result<(), AppError>, so ? propagates validation failures as 400 responses.
Testing the API
# Health check
curl http://localhost:3000/health
# Login
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "secret"}'
# Create a task
curl -X POST http://localhost:3000/tasks \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title": "Ship the feature", "description": "Deploy to production"}'
# List tasks
curl http://localhost:3000/tasks \
-H "Authorization: Bearer <token>"
# Update a task
curl -X PUT http://localhost:3000/tasks/1 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"completed": true}'
# Delete a task
curl -X DELETE http://localhost:3000/tasks/1 \
-H "Authorization: Bearer <token>"
Common Pitfalls
- Leaking internal errors to clients. Never return raw database errors or stack traces. Map everything through your error type and log the details server-side.
- Skipping validation. Database constraints catch some invalid data, but they produce unhelpful error messages. Validate in the handler and return clear messages.
- Not scoping queries to the current user. Every query should include
user_idin its WHERE clause. Without this, users can read and modify each other's data. - Putting business logic in handlers. Handlers should validate input, call a service or database function, and format the response. Complex logic belongs in a separate service layer.
- Forgetting
RETURNINGin INSERT/UPDATE queries. Without it, you need a second query to fetch the created or updated row. PostgreSQL'sRETURNINGclause gives you the result in one round trip.
Key Takeaways
- Structure your Axum project into modules: models, database, errors, handlers, auth.
- Define a single error type that implements
IntoResponseand use?everywhere. - Keep database queries in a dedicated module, parameterized and returning domain types.
- Authentication middleware decodes tokens and attaches the user to the request via extensions.
- Validate input in handlers before calling the database layer.
- Every database query should scope to the authenticated user.
- The pattern is always the same: extract, validate, execute, respond.