| Crates.io | axtra |
| lib.rs | axtra |
| version | 0.2.4 |
| created_at | 2025-07-02 18:51:08.648523+00 |
| updated_at | 2025-09-18 02:58:15.294053+00 |
| description | Axtra is a Rust library for building web applications with Axum and Astro providing utilities for error handling, notifications, and more. |
| homepage | |
| repository | https://github.com/imothee/axtra |
| max_upload_size | |
| id | 1735466 |
| size | 162,580 |
Opinionated helpers for Axum + Astro projects.
Warning: This library is experimental, opinionated, and subject to breaking changes.
🐉 Here be dragons 🐉
AppError
AppError: One error type for all your Axum APIs.validatorError Macros
app_error! macro: Ergonomic error construction for all variants..map_err().TypeScript Type Generation
ErrorCode, validation errors, etc.) exported to TypeScript via ts-rs.Error Notifications
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.Health Check Endpoint
Static File Serving
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 |
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>
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 payload 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",
}
]
}
}
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.
// 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"))
.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"))?;
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 }, };
Axtra supports sending critical errors to external services for alerting and monitoring.
Enable these features in your Cargo.toml as needed:
sentryDatabase and Exception errors to Sentry for error tracking.toml
features = ["sentry"]
Configure Sentry in your app (see sentry docs).notify-error-slacktoml
features = ["notify-error-slack"]
Set your webhook URL:
SLACK_ERROR_WEBHOOK_URL=your_webhook_url
notify-error-discordtoml
features = ["notify-error-discord"]
Set your webhook URL:
DISCORD_ERROR_WEBHOOK_URL=your_webhook_url
Note:
All notification features are opt-in and only send alerts for server-side errors (Database, Exception, or throw).
You can enable any combination of these features as needed for your project.
Axtra provides a convenient way to wrap API responses with a predictable key, using the WrappedJson<T> type and the ResponseKey derive macro.
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"
}
}
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"
}
}
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" }
]
}
// #[derive(ResponseKey)] will auto-implement this trait:
pub trait ResponseKey {
fn response_key() -> &'static str;
}
See axtra_macros::ResponseKey for details.
Axtra provides a ready-to-use health check route for monitoring your application's status and database connectivity.
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):
Axtra includes helpers for serving static files and SPAs (such as Astro or React) with Axum.
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"));
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());
/ and other paths will serve files from the ./dist directory.404.html from the same directory._static and _astro assets for optimal performance.See routes/health.rs and routes/astro.rs for full implementation details.
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:
"wordpress", "php", "config") or custom paths for filtering.trace, debug, info, etc).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);
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())
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
If no proxy headers are set or trust_proxy is false we will fallback to the connection IP address.
Available presets for common hacker/scanner paths:
"wordpress""php""config"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"]);
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.
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:
on_response.See bouncer/mod.rs and bouncer/layer.rs for full implementation details.
Axtra includes a flexible notification system for sending error alerts to Slack and Discord.
Enable the notifier feature in your Cargo.toml to access the Notifier API:
[features]
notifier = []
You can then use the Notifier struct to send messages to Slack and Discord webhooks.
use axtra::notifier::Notifier;
use serde_json::json;
// Create a notifier for Slack
let slack = Notifier::with_slack("https://hooks.slack.com/services/XXX");
// Send a simple Slack message
slack.notify_slack("Hello from Axtra!").await?;
// Send a rich Slack message (blocks)
let blocks = json!([{ "type": "section", "text": { "type": "plain_text", "text": "Error occurred!" } }]);
slack.notify_slack_rich(blocks).await?;
// Create a notifier for Discord
let discord = Notifier::with_discord("https://discord.com/api/webhooks/XXX");
// Send a simple Discord message
discord.notify_discord("Hello from Axtra!").await?;
// Send a rich Discord message (embeds)
let embeds = json!([{ "title": "Error", "description": "Something went wrong!" }]);
discord.notify_discord_rich(embeds).await?;
You can also use static methods for one-off notifications:
use axtra::notifier::Notifier;
use serde_json::json;
// Send a one-off Slack message
Notifier::slack("https://hooks.slack.com/services/XXX", "Hello!").await?;
// Send a one-off rich Slack message (blocks)
let blocks = json!([
{ "type": "section", "text": { "type": "plain_text", "text": "Critical error occurred!" } }
]);
Notifier::slack_rich("https://hooks.slack.com/services/XXX", blocks).await?;
// Send a one-off Discord message
Notifier::discord("https://discord.com/api/webhooks/XXX", "Hello!").await?;
// Send a one-off rich Discord message (embeds)
let embeds = json!([
{ "title": "Error", "description": "Something went wrong!", "color": 16711680 }
]);
Notifier::discord_rich("https://discord.com/api/webhooks/XXX", embeds).await?;
See notifier/mod.rs for full API details.
MIT
PRs and issues welcome! See CONTRIBUTING.md.