Skip to main content

Crate axum_error_sets

Crate axum_error_sets 

Source
Expand description

Typed, composable HTTP error sets for Axum and Aide.

axum-error-sets provides compile-time guarantees for HTTP error handling in Axum applications. Instead of using monolithic error enums or loosely-typed responses, functions declare the exact set of HTTP status codes they can return using type-level tuple sets (e.g., (NotFound, Unauthorized)).

§Key Concepts & Features

  • Powered by type-sets: Uses type-level set operations under the hood to manage, contain, and convert tuple sets of status codes at compile time.
  • No Per-Function Custom Error Enums: Eliminates the need to construct large, domain-wide error enums or define bespoke Error types for every function layer.
  • Exact Error Contracts: Functions declare precisely which HTTP status codes they can produce in their return signature.
  • Subset-to-Superset Promotion: Error sets grow deterministically as they move up application layers via .into_superset(). Lower-level code remains precise without restricting higher-level callers.
  • Custom Response Formatting: Implement IntoResponseWith on your central error payload type (e.g., AppError or StringError) to completely control how Axum converts error values into IntoResponse for any given status code.
  • Compile-Time Guarantees: Returning an undeclared status code produces a compiler error. Callers cannot silently “forget” or shrink handled error sets without explicit conversion.
  • Aide & OpenAPI Integration: Implement AideResponseFor to automatically generate precise OpenAPI metadata for every status code in an error set.

For complete runnable code, visit the examples/ directory on GitHub.

§Example 1: Basic Status Mapping

Demonstrates converting Result types directly into typed HTTP error statuses using StatusResultExt.

mod common;

use axum_error_sets::{
    StatusResultExt,
    code::{InternalServerError, NotFound},
};
use common::{AppResultSet, StringError};

fn app_error() -> Result<String, StringError> {
    Err(StringError::new("record missing"))
}

fn generic_error() -> Result<(), std::io::Error> {
    Err(std::io::Error::new(
        std::io::ErrorKind::Other,
        "generic error",
    ))
}

// Maps standard Result<T, CommonError> to a 404 ApiError
fn get_user() -> AppResultSet<String, (NotFound,)> {
    let user = app_error().into_not_found()?;
    Ok(user)
}

// Maps multiple status codes within the same function
fn process_user() -> AppResultSet<String, (NotFound, InternalServerError)> {
    let user = app_error().into_not_found()?;

    if user.is_empty() {
        return Err(StringError::new("payload corrupt")).into_internal()?;
    }

    // Since AppError implements From<T> where T: Error, the ? operator automatically
    // converts the std::io::Error into an AppError.
    generic_error().into_internal()?;

    Ok(user)
}

fn main() {
    println!("get_user: {:?}", get_user());
    println!("process_user: {:?}", process_user());
}

§Example 2: Error Set Composition

Demonstrates how lower-level functions with small error sets transparently expand into larger caller-level contracts using into_superset().

mod common;
use axum_error_sets::{
    ResultSetExt, StatusResultExt,
    code::{Conflict, InternalServerError, NotFound, Unauthorized},
};
use common::{AppResultSet, StringError};
use rootcause::report;

fn app_error(msg: &'static str) -> Result<String, StringError> {
    Err(StringError::new(msg))
}

fn generic_io_error() -> Result<(), std::io::Error> {
    Err(std::io::Error::new(
        std::io::ErrorKind::ConnectionReset,
        "database socket reset",
    ))
}

// -----------------------------------------------------------------------------
// Lower-Level Functions (Small Error Sets)
// -----------------------------------------------------------------------------

/// Database lookup function: only ever yields a 404 (NotFound).
fn fetch_user(id: &str) -> AppResultSet<String, (NotFound,)> {
    if id != "valid_id" {
        return app_error("user record not found")
            .into_not_found()
            .map_err(Into::into);
    }
    Ok(String::from("Alice"))
}

/// Authentication check: only ever yields a 401 (Unauthorized).
fn check_auth(token: &str) -> AppResultSet<(), (Unauthorized,)> {
    if token.is_empty() {
        app_error("missing auth token").into_unauthorized()?;
    }
    Ok(())
}

// -----------------------------------------------------------------------------
// Mid/High-Level Service (Growing Error Sets)
// -----------------------------------------------------------------------------

/// Service layer combining lower-level functions.
///
/// `into_superset()` expands both `(Unauthorized,)` and `(NotFound,)`
/// into the larger `(Unauthorized, NotFound, Conflict, InternalServerError)` set.
fn update_user_profile(
    id: &str,
    token: &str,
    new_name: &str,
) -> AppResultSet<String, (Unauthorized, NotFound, Conflict, InternalServerError)> {
    // 1. Promote (Unauthorized,) to full set
    check_auth(token).into_superset()?;

    // 2. Promote (NotFound,) to full set
    let mut username = fetch_user(id).into_superset()?;

    // 3. Directly introduce 409 (Conflict) at this layer
    if new_name == "taken_username" {
        return Err(Conflict(report!("username already taken")).into());
    }

    // 4. Automatically wrap external errors into 500 (InternalServerError)
    generic_io_error().into_internal()?;

    username.push_str(" -> ");
    username.push_str(new_name);

    Ok(username)
}

fn main() {
    println!(
        "Failed Auth: {:?}",
        update_user_profile("valid_id", "", "NewName")
    );
    println!(
        "Failed Fetch: {:?}",
        update_user_profile("invalid_id", "token123", "NewName")
    );
    println!(
        "Conflict Error: {:?}",
        update_user_profile("valid_id", "token123", "taken_username")
    );
}

§Example 3: Axum Route Handlers & Aide OpenAPI Integration

Demonstrates integrating error sets directly into Axum handlers to automatically generate OpenAPI metadata.

mod common;
use aide::{
    axum::{
        ApiRouter,
        routing::{get_with, post_with},
    },
    openapi::OpenApi,
};
use axum::{Json, extract::Path};
use axum_error_sets::{
    ResultSetExt,
    code::{BadRequest, Conflict, InternalServerError, NotFound},
};
use common::{AppResultSet, StringError};
use rootcause::report;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

// -----------------------------------------------------------------------------
// DTOs
// -----------------------------------------------------------------------------

#[derive(Serialize, Deserialize, JsonSchema)]
struct User {
    id: String,
    name: String,
}

#[derive(Deserialize, JsonSchema)]
struct CreateUserPayload {
    name: String,
}

// -----------------------------------------------------------------------------
// Core Business Logic (Returns narrow error sets)
// -----------------------------------------------------------------------------

fn find_user_by_id(id: &str) -> AppResultSet<User, (NotFound,)> {
    if id != "42" {
        return Err(NotFound(StringError::new("user ID not found")).into());
    }
    Ok(User {
        id: id.to_string(),
        name: String::from("Alice"),
    })
}

fn create_user_in_db(name: &str) -> AppResultSet<User, (Conflict, InternalServerError)> {
    if name == "admin" {
        return Err(Conflict(report!("username 'admin' is reserved")).into());
    }
    Ok(User {
        id: String::from("43"),
        name: name.to_string(),
    })
}

// -----------------------------------------------------------------------------
// Axum Handlers with Aide Support
// -----------------------------------------------------------------------------

/// GET /users/{id}
///
/// OpenAPI documents both 404 (NotFound) and 500 (InternalServerError).
async fn get_user_handler(
    Path(id): Path<String>,
) -> AppResultSet<Json<User>, (NotFound, InternalServerError)> {
    let user = find_user_by_id(&id).into_superset()?;
    Ok(Json(user))
}

/// POST /users
///
/// Combines payload validation (400), registration conflicts (409), and database failures (500).
async fn create_user_handler(
    Json(payload): Json<CreateUserPayload>,
) -> AppResultSet<Json<User>, (BadRequest, Conflict, InternalServerError)> {
    if payload.name.trim().is_empty() {
        return Err(BadRequest(StringError::new("name cannot be empty")).into());
    }

    let new_user = create_user_in_db(&payload.name).into_superset()?;
    Ok(Json(new_user))
}

// -----------------------------------------------------------------------------
// Router Setup & Main
// -----------------------------------------------------------------------------

#[tokio::main]
async fn main() {
    let mut api = OpenApi::default();

    // ApiRouter automatically extracts openapi metadata from declared handler error sets via AideResponseFor
    let app = ApiRouter::<()>::new()
        .api_route(
            "/users/{id}",
            get_with(get_user_handler, |op| {
                op.description("Fetch a user by their unique identifier")
            }),
        )
        .api_route(
            "/users",
            post_with(create_user_handler, |op| {
                op.description("Register a new user")
            }),
        )
        .finish_api(&mut api);

    println!("OpenAPI Schema generated successfully!");
    println!(
        "Routes documented: {}",
        serde_json::to_string_pretty(&api).unwrap()
    );

    // Run axum server with `app` here
    let _ = app;
}

§Example 4: Axum Route Handlers & Utoipa OpenAPI Integration

Demonstrates integrating error sets directly into Axum handlers to automatically generate OpenAPI metadata using Utoipa.

mod common;

use axum::{
    Json, Router,
    extract::Path,
    routing::{get, post},
};
use axum_error_sets::{
    ErrorSet, ResultSetExt,
    code::{BadRequest, Conflict, InternalServerError, NotFound},
};
use common::{AppResultSet, StringError};
use rootcause::report;
use serde::{Deserialize, Serialize};
use utoipa::{OpenApi, ToSchema};

use crate::common::AppError;

// -----------------------------------------------------------------------------
// DTOs (using utoipa::ToSchema instead of schemars::JsonSchema)
// -----------------------------------------------------------------------------

#[derive(Serialize, Deserialize, ToSchema, OpenApi)]
struct User {
    id: String,
    name: String,
}

#[derive(Deserialize, ToSchema, OpenApi)]
struct CreateUserPayload {
    name: String,
}

// -----------------------------------------------------------------------------
// Core Business Logic
// -----------------------------------------------------------------------------

fn find_user_by_id(id: &str) -> AppResultSet<User, (NotFound,)> {
    if id != "42" {
        return Err(NotFound(StringError::new("user ID not found")).into());
    }
    Ok(User {
        id: id.to_string(),
        name: String::from("Alice"),
    })
}

fn create_user_in_db(name: &str) -> AppResultSet<User, (Conflict, InternalServerError)> {
    if name == "admin" {
        return Err(Conflict(report!("username 'admin' is reserved")).into());
    }
    Ok(User {
        id: String::from("43"),
        name: name.to_string(),
    })
}

// -----------------------------------------------------------------------------
// Axum Handlers with Utoipa Support
// -----------------------------------------------------------------------------

/// GET /users/{id}
///
/// utoipa automatically expands `AppResultSet<Json<User>, ...>` via the `IntoResponses` trait.
#[utoipa::path(
    get,
    path = "/users/{id}",
    params(
        ("id" = String, Path, description = "User ID")
    ),
    responses(
        (status = 200, description = "User retrieved successfully", body = User),
        ErrorSet<AppError, (NotFound, InternalServerError)>
    )
)]
async fn get_user_handler(
    Path(id): Path<String>,
) -> AppResultSet<Json<User>, (NotFound, InternalServerError)> {
    let user = find_user_by_id(&id).into_superset()?;
    Ok(Json(user))
}

/// POST /users
#[utoipa::path(
    post,
    path = "/users",
    request_body = CreateUserPayload,
    responses(
        (status = 200, description = "User created successfully", body = User),

        ErrorSet<AppError, (NotFound, InternalServerError)>
    )
)]
async fn create_user_handler(
    Json(payload): Json<CreateUserPayload>,
) -> AppResultSet<Json<User>, (BadRequest, Conflict, InternalServerError)> {
    if payload.name.trim().is_empty() {
        return Err(BadRequest(StringError::new("name cannot be empty")).into());
    }

    let new_user = create_user_in_db(&payload.name).into_superset()?;
    Ok(Json(new_user))
}

// -----------------------------------------------------------------------------
// OpenAPI Documentation Container
// -----------------------------------------------------------------------------

#[derive(OpenApi)]
#[openapi(
    paths(get_user_handler, create_user_handler,),
    components(schemas(User, CreateUserPayload))
)]
struct ApiDoc;

// -----------------------------------------------------------------------------
// Router Setup & Main
// -----------------------------------------------------------------------------

#[tokio::main]
async fn main() {
    let api = ApiDoc::openapi();

    let app = Router::<()>::new()
        .route("/users/{id}", get(get_user_handler))
        .route("/users", post(create_user_handler));

    println!("OpenAPI Schema generated successfully!");
    println!(
        "Routes documented:\n{}",
        serde_json::to_string_pretty(&api).unwrap()
    );

    let _ = app;
}

Modules§

code

Structs§

ErrorSet
An error defined by a set of possible status codes.

Traits§

AideResponseFor
Should be implemented for a type to be used as T inside [ApiError<T, _>], for usage with aide.
IntoResponseWith
Should be implemented for a type to be used as T inside [ApiError<T, _>].
ResultSetExt
Extension trait for results, to convert errorsets into supersets.
StatusResultExt
Extension trait for Result to convert it into a Result with an [ApiError].
StatusWrapper
Implemented for all types that can be used as E inside [ApiError<_, E>].
UtoipaResponseFor