axtra 0.3.0

Axtra is a Rust library for building web applications with Axum and Astro providing utilities for error handling, notifications, and more.
Documentation

Axtra

🎨 Overview

Opinionated helpers for Axum + Astro projects.

Warning: This library is experimental, opinionated, and subject to breaking changes. 🐉 Here be dragons 🐉

Sponsored by cmdline.io - Error tracking and incident management for Rust applications.


Features

Error Handling

  • AppError

    • AppError: One error type for all your Axum APIs.
    • Error variants for authentication, authorization, validation, database, not found, and more.
    • Automatic handling of thrown WithRejection<Json> and Validation using validator
  • Error Macros

    • app_error! macro: Ergonomic error construction for all variants.
    • Captures error location automatically.
    • Supports underlying error mapping for easy use with .map_err().
  • TypeScript Type Generation

    • Rust error types (ErrorCode, validation errors, etc.) exported to TypeScript via ts-rs.
    • Ensures your frontend and backend share error contracts.
  • Error Notifications

    • Pluggable notification system with trait-based architecture
    • Built-in providers: Slack, Discord, ntfy, cmdline.io
    • Create custom providers by implementing ErrorNotifier trait
    • Configure via builder pattern or environment variables

Api Responses

  • Wrapped JSON Responses
    • WrappedJson<T>: Automatically wraps responses with a key derived from the type name.
    • ResponseKey derive macro: Customize or auto-generate response keys for your types.
    • List responses are pluralized automatically.

Axum Helpers

  • Health Check Endpoint

    • Built-in Axum route for checking Postgres connectivity.
    • Returns status, DB health, and timestamp.
  • Static File Serving

    • Helpers for serving SPA and static files with Axum.

Bouncer

  • IP Banning and Malicious Path Filtering
    • Simple IP banning and malicious path filtering middleware for Axum.

Notifications

  • Pluggable Notification System
    • Trait-based architecture for custom providers
    • Built-in: Slack, Discord, ntfy, cmdline.io
    • Easy to add Sentry, PagerDuty, or any custom provider

ErrorHandling

AppError

AppError is an enum type containing types for handling BadRequest, NotFound, Authorization, Authentication, Database and Exception errors.

AppErrors will be logged automatically with the following severity

Error Type Severity (Log Level)
Authentication INFO
Authorization INFO
BadRequest WARN
NotFound WARN
Validation WARN
Database ERROR
Exception ERROR
  • INFO: Expected authentication/authorization failures.
  • WARN: Client errors, invalid input, not found, validation issues.
  • ERROR: Server-side failures, database errors, exceptions, and triggers notifications (Slack/Discord/Sentry if enabled).

WithRejection

AppError will automatically handle malformed JSON and render BadRequest errors when using WithRejection.

pub async fn create(
    auth_session: AuthSession,
    State(pool): State<PgPool>,
    WithRejection(Json(payload), _): WithRejection<Json<NewSubscription>, AppError>,
) -> Result<WrappedJson<CheckoutSession>, AppError>

Validator

When using .validate()? in a handler, AppError will automatically catch and render the ValidationErrors.

#[derive(Debug, Validate, Deserialize)]
struct SignupData {
    #[validate(email)]
    mail: String,
}

handler() -> Result<impl IntoResponse, AppError> {
  let signup_data = SignupData { mail: "notemail" };
  payload.validate()?;
}
{
    "status": "Bad Request",
    "message": "There was a validation error with your request.",
    "error": "Validation error",
    "code": "validation",
    "validationErrors": {
        "errors": [
            {
                "field": "mail",
                "code": "email",
                "message": "mail must be a valid email",
            }
        ]
    }
}

Error Macro Usage

The app_error! macro makes error construction ergonomic and consistent.
It automatically tracks error location and supports both direct and closure-based usage.

If no response type is passed, defaults to HTML.

Standalone (Direct) Usage

// Basic error (HTML response)
Err(app_error!(bad_request, "Missing field"));

// JSON error
Err(app_error!(bad_request, json, "Invalid JSON payload"));

// HTML error
Err(app_error!(bad_request, html, "Form error"));

// Not found resource: &str
Err(app_error!(not_found, "User not found"));
Err(app_error!(not_found, json, "User not found"));

// Unauthorized resource: &str, action: &str
Err(app_error!(unauthorized, "users", "delete"));
Err(app_error!(unauthorized, json, "users", "delete"));

// Unauthenticated
Err(app_error!(unauthenticated));
Err(app_error!(unauthenticated, json));

// Validation error
Err(app_error!(validation, errors));
Err(app_error!(validation, json, errors));

// Thrown exceptons
Err(app_error!(throw, "You broke something"))
Err(app_error!(throw, json, "You broke something and we're responding with json"))
Err(app_error!(throw, html, "You broke something and we're responding with html"))

Closure (For .map_err() and error mapping)

// Bad request with underlying error
let value: i32 = input.parse().map_err(app_error!(bad_request, with_error, "Invalid number"))?;

// With format args
let value: i32 = input.parse().map_err(app_error!(bad_request, with_error, "Invalid number: {}", input))?;

// JSON error with underlying error
let user: User = serde_json::from_str(&body).map_err(app_error!(bad_request, json, with_error, "Bad JSON: {}", body))?;

// Database error mapping
let user = sqlx::query!("SELECT * FROM users WHERE id = $1", id)
    .fetch_one(&pool)
    .await
    .map_err(app_error!(db, "Failed to fetch user"))?;

// Exception mapping
let result = do_something().map_err(app_error!(exception, "Unexpected error"))?;

Typescript types

Axtra provides Ts-Rs bindings to output typed ErrorResponses.

To enable, add the export to your build.rs

use axtra::errors::ErrorResponse;
use std::fs;
use std::path::Path;
use ts_rs::TS;

fn main() {
    // Specify the path to the directory containing the TypeScript files
    let ts_dir = Path::new("types");
    fs::create_dir_all(ts_dir).unwrap();

    ErrorResponse::export_all_to(ts_dir).unwrap();
}
/**
 * Enum of all possible error codes.
 */
export type ErrorCode = "authentication" | "authorization" | "badRequest" | "database" | "exception" | "notFound" | "validation";

export type ErrorResponse = { status: string, message: string, code: ErrorCode, validationErrors?: SerializableValidationErrors, };

/**
 * Represents all validation errors in a serializable form.
 */
export type SerializableValidationErrors = { errors: Array<ValidationFieldError>, };

/**
 * Represents a single field validation error.
 */
export type ValidationFieldError = { field: string, code: string, message: string, params: { [key in string]?: string }, };

Error Notification System

Axtra uses a pluggable, trait-based notification system for sending critical errors to external services. This design keeps axtra lean and unopinionated—you choose which providers to use and at what versions.

Quick Start

use axtra::notifier::NotificationManager;
use axtra::errors::notifiers::init_notification_manager;

// Auto-configure from environment variables
init_notification_manager(NotificationManager::from_env());

Builder Pattern (Recommended)

use axtra::notifier::{NotificationManager, SlackConfig, DiscordConfig, NtfyConfig, CmdlineConfig};
use axtra::errors::notifiers::init_notification_manager;

let manager = NotificationManager::builder()
    .with_slack(SlackConfig::new("https://hooks.slack.com/services/...")
        .with_mention("@oncall"))
    .with_discord(DiscordConfig::new("https://discord.com/api/webhooks/..."))
    .with_ntfy(NtfyConfig::new("my-app-errors"))
    .with_cmdline(CmdlineConfig::new(std::env::var("CMDLINE_API_KEY").unwrap())
        .with_environment("production")
        .with_service("my-app"))
    .build();

init_notification_manager(manager);

Feature Flags

Enable the providers you need in Cargo.toml:

[dependencies]
axtra = { version = "0.3", features = ["notify-error-slack", "notify-error-ntfy"] }
Feature Provider Environment Variables
notify-error-slack Slack webhook SLACK_ERROR_WEBHOOK_URL, SLACK_ERROR_MENTION (optional)
notify-error-discord Discord webhook DISCORD_ERROR_WEBHOOK_URL, DISCORD_ERROR_MENTION (optional)
notify-error-ntfy ntfy push notifications NTFY_TOPIC, NTFY_SERVER_URL (optional), NTFY_ACCESS_TOKEN (optional)
notify-error-cmdline cmdline.io error tracking CMDLINE_API_KEY, ENVIRONMENT or ENV, SERVICE or APP_NAME, RELEASE or VERSION

Custom Providers

Implement the ErrorNotifier trait to add any provider. This keeps axtra free of version lock-in—you control your dependencies.

Sentry Example
// In YOUR Cargo.toml (not axtra's):
// sentry = "0.35"  # whatever version you want

use axtra::notifier::{ErrorNotifier, ErrorEvent, NotifyError, NotifyFuture, NotificationManager, SlackConfig};
use reqwest::Client;
use std::sync::Arc;

struct SentryProvider;

impl ErrorNotifier for SentryProvider {
    fn notify<'a>(&'a self, _client: &'a Client, event: &'a ErrorEvent) -> NotifyFuture<'a> {
        Box::pin(async move {
            let level = match event.severity() {
                "critical" => sentry::Level::Fatal,
                "major" => sentry::Level::Error,
                _ => sentry::Level::Warning,
            };

            sentry::with_scope(
                |scope| {
                    scope.set_tag("app_name", &event.app_name);
                    scope.set_tag("location", &event.location);
                },
                || {
                    sentry::capture_message(&event.message, level);
                },
            );
            Ok(())
        })
    }

    fn name(&self) -> &'static str {
        "sentry"
    }
}

// Usage
let manager = NotificationManager::builder()
    .with_provider(Arc::new(SentryProvider))
    .with_slack(SlackConfig::new("https://hooks.slack.com/..."))
    .build();
cmdline.io Example

The built-in CmdlineProvider handles error tracking automatically, but here's how you could customize it:

use axtra::notifier::{ErrorNotifier, ErrorEvent, NotifyError, NotifyFuture};
use reqwest::Client;

struct MyCmdlineProvider {
    api_key: String,
    environment: Option<String>,
    service: Option<String>,
}

impl ErrorNotifier for MyCmdlineProvider {
    fn notify<'a>(&'a self, client: &'a Client, event: &'a ErrorEvent) -> NotifyFuture<'a> {
        Box::pin(async move {
            client.post("https://cmdline.io/api/ingest/errors")
                .header("Authorization", format!("Bearer {}", self.api_key))
                .json(&serde_json::json!({
                    "title": format!("{:?}: {}", event.error_code, event.message),
                    "severity": event.severity(),
                    "error_type": "exception",
                    "environment": self.environment,
                    "service": self.service.as_deref().unwrap_or(&event.app_name),
                    "message": event.source_error,
                    "extra": {
                        "location": event.location,
                    },
                }))
                .send()
                .await
                .map_err(NotifyError::transient)?
                .error_for_status()
                .map_err(NotifyError::permanent)?;

            Ok(())
        })
    }

    fn name(&self) -> &'static str {
        "cmdline"
    }
}
PagerDuty Example
use axtra::notifier::{ErrorNotifier, ErrorEvent, NotifyError, NotifyFuture};
use reqwest::Client;

struct PagerDutyProvider {
    routing_key: String,
}

impl ErrorNotifier for PagerDutyProvider {
    fn notify<'a>(&'a self, client: &'a Client, event: &'a ErrorEvent) -> NotifyFuture<'a> {
        Box::pin(async move {
            client.post("https://events.pagerduty.com/v2/enqueue")
                .json(&serde_json::json!({
                    "routing_key": self.routing_key,
                    "event_action": "trigger",
                    "payload": {
                        "summary": event.message,
                        "severity": event.severity(),
                        "source": event.app_name,
                        "custom_details": {
                            "location": event.location,
                            "error_code": format!("{:?}", event.error_code),
                        }
                    }
                }))
                .send()
                .await?;
            Ok(())
        })
    }

    fn name(&self) -> &'static str {
        "pagerduty"
    }
}

Note: All notification features are opt-in and only trigger for server-side errors (Database, Exception, or throw). Notifications are sent concurrently to all configured providers.


Api Responses

WrappedJson & ResponseKey

Axtra provides a convenient way to wrap API responses with a predictable key, using the WrappedJson<T> type and the ResponseKey derive macro.

Usage

use axtra::response::{WrappedJson, ResponseKey};
use serde::Serialize;

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

// In your handler:
async fn get_user() -> Result<WrappedJson<User>, AppError> {
    let user = User { id: 1, name: "Alice".to_string() };
    Ok(WrappedJson(user))
}

Produces JSON:

{
  "user": {
    "id": 1,
    "name": "Alice"
  }
}

Customizing the Response Key

You can override the default key by using the #[response_key = "custom_name"] attribute:

#[derive(Serialize, ResponseKey)]
#[response_key = "account"]
struct UserAccount {
    id: i32,
    email: String,
}

Produces JSON:

{
  "account": {
    "id": 1,
    "email": "alice@example.com"
  }
}

List Responses

When returning a list, the key is automatically pluralized:

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

async fn list_users() -> Result<WrappedJson<Vec<User>>, AppError> {
    let users = vec![
        User { id: 1, name: "Alice".to_string() },
        User { id: 2, name: "Bob".to_string() },
    ];
    Ok(WrappedJson(users))
}

Produces JSON:

{
  "users": [
    { "id": 1, "name": "Alice" },
    { "id": 2, "name": "Bob" }
  ]
}

Macro Implementation

// #[derive(ResponseKey)] will auto-implement this trait:
pub trait ResponseKey {
    fn response_key() -> &'static str;
}

See axtra_macros::ResponseKey for details.


Axum Helpers

Health Check Endpoint

Axtra provides a ready-to-use health check route for monitoring your application's status and database connectivity.

Usage

use axtra::routes::health::check_health;
use axum::{routing::get, Router};
use sqlx::PgPool;

fn app(pool: PgPool) -> Router {
    Router::new()
        .route("/health", get(check_health))
        .with_state(pool)
}

Response (healthy):

{
  "status": "healthy",
  "postgres": true,
  "timestamp": "2025-07-15T12:34:56Z"
}

Response (degraded):

  • Returns HTTP 503 Service Unavailable if the database is not reachable.

Static File & Single Page App (SPA) Routes

Axtra includes helpers for serving static files and SPAs (such as Astro or React) with Axum.

Serve a Single Page App (SPA)

use axtra::routes::astro::serve_spa;
use axum::Router;

// Serves files from ./dist/myapp/index.html for /myapp and /myapp/*
let router = Router::new().merge(serve_spa("myapp"));

Serve Static Files

use axtra::routes::astro::serve_static_files;
use axum::Router;

// Serves files from ./dist, with compression and custom cache headers
let router = Router::new().merge(serve_static_files());
  • Requests to / and other paths will serve files from the ./dist directory.
  • Requests for missing files will return 404.html from the same directory.
  • Cache headers are set for _static and _astro assets for optimal performance.

See routes/health.rs and routes/astro.rs for full implementation details.


Bouncer: IP Banning & Malicious Path Filtering

Axtra's bouncer middleware automatically bans IP addresses that hit known malicious or unwanted paths, helping protect your Axum app from common scanner and exploit traffic. Enable the bouncer feature in your Cargo.toml to access the Notifier API:

Features

  • Ban IPs for a configurable duration when they access blocked paths.
  • Use presets ("wordpress", "php", "config") or custom paths for filtering.
  • Customize HTTP status for banned and blocked responses.
  • Set log level for event tracing (trace, debug, info, etc).
  • Expose the banlist for observability and monitoring.

Usage Example

use axtra::bouncer::{BouncerConfig, BouncerLayer};
use axum::{Router, routing::get};
use axum::http::StatusCode;
use tracing::Level;
use std::time::Duration;

// Create a config with presets and custom paths, and customize responses/logging
let config = BouncerConfig::from_rules(
    &["wordpress", "config"],
    &["/custom"]
)
    .duration(Duration::from_secs(1800))
    .banned_response(StatusCode::UNAUTHORIZED)
    .blocked_response(StatusCode::NOT_FOUND)
    .log_level(Level::INFO);

let layer = BouncerLayer::new(config);

let app = Router::new()
    .route("/", get(|| async { "Hello" }))
    .layer(layer);

Extracting the IP Address

To ensure that we have access to the clients IP Address you must start axum with, if you do not IP address will always be None and bouncer will not work.

axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())

instead of the usual

axum::serve(listener, app.into_make_service())

Trusted Proxy

Since proxy headers can be spoofed you must opt-in to allow proxy headers for IP addresses by specifically setting trust_proxy to true.

If trust_proxy is true we will look for in descending order

  • CF-conecting-ip (Cloudflare)
  • X-Forwarded-For
  • X-Real-IP

If no proxy headers are set or trust_proxy is false we will fallback to the connection IP address.

Presets

Available presets for common hacker/scanner paths:

  • "wordpress"
  • "php"
  • "config"

Advanced Usage

You can also pass only presets or only custom paths:

let config = BouncerConfig::from_preset_rules(&["wordpress"]);
let config = BouncerConfig::from_custom_rules(&["/admin", "/hidden"]);

Tracing & TraceLayer Integration

The bouncer middleware uses tracing to log blocked and banned events.
You can configure the log level via .log_level(Level::DEBUG) or similar.

Best Practice:
Place BouncerLayer before Axum's TraceLayer so that blocked/banned requests are logged by bouncer and not missed by TraceLayer's on_response hooks.

Example with TraceLayer

use axtra::bouncer::{BouncerConfig, BouncerLayer};
use axum::{Router, routing::get};
use tower_http::trace::TraceLayer;

let config = BouncerConfig::from_rules(&["wordpress"], &[])
    .log_level(tracing::Level::INFO);

let app = Router::new()
    .route("/", get(|| async { "Hello" }))
    .layer(TraceLayer::new_for_http())
    .layer(BouncerLayer::new(config));

Logging:

  • Bouncer logs blocked/banned events at your chosen level.
  • TraceLayer logs all requests that reach your handlers.
  • Blocked/banned requests are handled and logged by bouncer, and do not reach TraceLayer's on_response.

See bouncer/mod.rs and bouncer/layer.rs for full implementation details.

Notifier

Axtra includes a pluggable notification system for error alerts and incident tracking. Enable provider features in your Cargo.toml:

[dependencies]
axtra = { version = "0.3", features = ["notify-error-slack", "notify-error-ntfy"] }

NotificationManager API

The NotificationManager coordinates multiple providers, sending error events to all of them concurrently.

use axtra::notifier::{NotificationManager, SlackConfig, DiscordConfig, NtfyConfig};
use axtra::errors::notifiers::init_notification_manager;

// Builder pattern - recommended for explicit configuration
let manager = NotificationManager::builder()
    .with_slack(SlackConfig::new("https://hooks.slack.com/services/XXX")
        .with_mention("@oncall"))
    .with_discord(DiscordConfig::new("https://discord.com/api/webhooks/XXX"))
    .with_ntfy(NtfyConfig::new("my-app-errors"))
    .build();

init_notification_manager(manager);

// Or auto-configure from environment variables
init_notification_manager(NotificationManager::from_env());

ErrorEvent

All providers receive an ErrorEvent with full context:

pub struct ErrorEvent {
    pub app_name: String,      // from APP_NAME env var
    pub error_code: ErrorCode, // Database, Exception, etc.
    pub message: String,       // human-readable error
    pub location: String,      // e.g., "mymodule::handler:42"
    pub timestamp: OffsetDateTime,
    pub source_error: Option<String>, // error chain for debugging
}

impl ErrorEvent {
    // Maps error_code to severity for incident systems
    pub fn severity(&self) -> &'static str; // "critical", "major", "minor", "warning"
}

Custom Providers

Implement ErrorNotifier to create your own provider:

use axtra::notifier::{ErrorNotifier, ErrorEvent, NotifyError, NotifyFuture};
use reqwest::Client;

struct MyProvider { /* config */ }

impl ErrorNotifier for MyProvider {
    fn notify<'a>(&'a self, client: &'a Client, event: &'a ErrorEvent) -> NotifyFuture<'a> {
        Box::pin(async move {
            // Send notification using the shared HTTP client
            client.post("https://my-service.com/alert")
                .json(&serde_json::json!({
                    "message": event.message,
                    "severity": event.severity(),
                }))
                .send()
                .await?;
            Ok(())
        })
    }

    fn name(&self) -> &'static str {
        "my-provider"
    }
}

NotifyError

Use NotifyError::transient() for retryable errors (network issues) and NotifyError::permanent() for configuration errors:

client.post(url)
    .send()
    .await
    .map_err(NotifyError::transient)?  // Network error - might work on retry
    .error_for_status()
    .map_err(NotifyError::permanent)?; // 4xx error - won't work on retry

See notifier/mod.rs for full API details.


Documentation


License

MIT


Contributing

PRs and issues welcome! See CONTRIBUTING.md.