3 min read
On this page

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_id in 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 RETURNING in INSERT/UPDATE queries. Without it, you need a second query to fetch the created or updated row. PostgreSQL's RETURNING clause 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 IntoResponse and 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.