axa 0.1.0

Small shared library for services built directly on Axum and sqlx.
Documentation

axa

axa is a small shared library for web services built directly on Axum and sqlx.

It holds the plumbing every service needs and none of them should write twice: route tables, a standard middleware stack, health checks, error responses, configuration, logging, graceful shutdown, a command line, background jobs, and a Postgres job queue.

The specification is spec/index.md. It is the single source of truth, and every requirement in it names the test that proves it.

Why it exists

A framework gives you a lot for free, and asks you to trust what it does on your behalf. For a service handling identity, clinical, or government records, that trade runs the wrong way: an auditor has to read the framework as well as your code, and each of its dependencies joins your supply chain.

axa takes the opposite position:

  • You can read the whole path. main.rs is ordinary Rust that calls ordinary functions. Nothing calls into your code from behind a trait.
  • One copy. A defect in shared plumbing is fixed once, and an auditor reads it once.
  • Fewer dependencies. Replacing Loco and SeaORM with sqlx took one measured service from 510 normal dependencies to about 348, before counting anything added to replace them.
  • Safe defaults that fail closed. A production service with no database URL, or with a wildcard CORS origin, refuses to start.

A library, not a framework

There are no lifecycle hooks, macros, or generated code. AppContext is a plain struct, passed as ordinary Axum state, and AppRoutes builds an ordinary axum::Router. Any piece can be read, replaced, or left out on its own.

Some things will deliberately never be added: route macros, a generic CRUD controller, a dependency-injection container, a plugin system, a configuration language, and a database abstraction. Each one puts indirection back between a reader and the behaviour.

Installing

Add it from crates.io:

[dependencies]
axa = "0.1"
Feature Adds
queue The Postgres job queue
testing Request-level test helpers, built on axum-test
sea-orm A SeaORM-backed AppContext, migration helpers, and model errors. Transitional: it goes once the last service has moved to sqlx
sqlx axa-sqlx: pooling, migrations, and test resets
pagination axa-pagination: offset pages, count headers, and LIKE escaping
problem axa-problem: RFC 9457 problem details. No service uses this yet
prometheus axa-prometheus: Prometheus text exposition
versioning axa-versioning: Accepts-version negotiation

Quick start

This is a complete service with one route:

use axa::{
    Environment, Result,
    config::{Config, Defaults},
    logger,
    prelude::*,
    server,
};

async fn hello() -> Result<Response> {
    format::json(serde_json::json!({ "hello": "world" }))
}

async fn run() -> Result<()> {
    let environment = Environment::from_env();
    let defaults = Defaults {
        // An empty name means this service has no database.
        database: "",
        ..Defaults::default()
    };
    let config = Config::from_env(&environment, &defaults)?;
    logger::init("hello_service", &config.logger);

    let server_config = config.server.clone();
    let ctx = AppContext::new(environment, (), config);

    let router = AppRoutes::with_default_routes()
        .add_route(Routes::new().prefix("/api").add("/hello", get(hello)))
        .into_app(ctx)?;

    server::serve(router, &server_config).await
}

It listens on port 1241 and serves /api/hello, /_ping, /_health, and /_readiness under the standard middleware. It stops cleanly on SIGTERM or Ctrl-C, letting requests in flight finish.

The default port spells the name: a=1, x=24, a=1.

What it provides

Routes

Routes is a group sharing a prefix, and AppRoutes collects the groups:

use axa::prelude::*;

async fn list() -> &'static str { "[]" }
async fn create() -> &'static str { "{}" }
async fn show(Path(id): Path<u32>) -> String { id.to_string() }

fn folders() -> Routes {
    Routes::new()
        .prefix("/api/folders")
        .add("/", get(list))
        .add("/", post(create))
        .add("/{id}", get(show))
}

A "/" route is served at the prefix with no trailing slash, and two methods on one path merge. The fields are public, so a test can check what is mounted without booting anything:

# use axa::prelude::*;
# async fn list() -> &'static str { "[]" }
let group = Routes::new().prefix("/api").add("/items", get(list));
assert!(group.handlers.iter().any(|handler| handler.uri == "/items"));

Middleware

AppRoutes::into_app applies this stack, or call middleware::apply on any router. From the outside in:

Layer Behaviour
Request id Keeps a caller's x-request-id after removing unsafe characters, or mints a UUID. Always returned
Tracing Logs each request
Panic catching Turns a handler panic into a 500
Security headers x-content-type-options, x-frame-options, referrer-policy, and optionally HSTS. A handler's own value wins
CORS Off unless origins are configured
Timeout Answers 408 after REQUEST_TIMEOUT_SECONDS
Compression Gzip, Brotli, and Zstd when enabled
Body limit Answers 413 over BODY_LIMIT_BYTES

Errors

Handlers return Result<T> using axa::Error, which maps to a status and a JSON body:

Error Status Body
Error::NotFound 404 Fixed text
Error::Unauthorized(_) 401 Fixed text; yours is only logged
Error::BadRequest(_) 400 Your text
Error::CustomError(status, detail) Yours Yours
Anything else 500 Fixed text; the real error is only logged

So a failed database call cannot put a hostname or a query in a client's response. The not_found, unauthorized, and bad_request functions build these for you.

The body is {"error": ..., "description": ...} today, and is moving to RFC 9457 application/problem+json.

Configuration

There are no config files. A service states its defaults in code, and the environment overrides them, so what a deployment runs with is visible in one place.

Variable Effect
APP_ENV development, test, or production. LOCO_ENV is also read. Any other name gets production's stricter behaviour
PORT, BINDING, HOST Listening port, address, and public URL
DATABASE_URL Connection string. Required in production for a service with a database
DB_MIN_CONNECTIONS, DB_MAX_CONNECTIONS Pool size
DB_CONNECT_TIMEOUT, DB_IDLE_TIMEOUT Pool timeouts, in milliseconds
DB_LOGGING, DB_AUTO_MIGRATE Statement logging, and migrating at boot
LOG_ENABLE, LOG_LEVEL, LOG_FORMAT, LOG_FILTER Logging. RUST_LOG wins over all of them
BODY_LIMIT_BYTES Largest accepted request body
HTTP_COMPRESSION, HTTP_HSTS Response compression and HSTS
REQUEST_TIMEOUT_SECONDS Per-request timeout. 0 turns it off
CORS_ALLOWED_ORIGINS Comma-separated origins
WORKER_MODE BackgroundQueue, BackgroundAsync, or ForegroundBlocking

Defaults change with the environment:

Setting Development Test Production
Database Local, derived from the service name Local, derived from the service name Required
Migrate at boot Yes Yes No
Compression Off Off On
Request timeout Off Off 60 seconds
HSTS Off Off On
CORS Any origin Any origin None until listed; * is refused
Log format Compact Compact, off by default JSON
Jobs Queue In the foreground Queue

Config::from_lookup takes the lookup as a function, so configuration can be tested without touching the process environment:

use axa::{Environment, config::{Config, Defaults}};

let config = Config::from_lookup(
    &Environment::Development,
    &Defaults::default(),
    |name| (name == "PORT").then(|| "8000".to_string()),
)
.unwrap();
assert_eq!(config.server.port, 8000);

Health checks

Route Meaning
/_ping, /_health The process is up
/_readiness The database and queue also answer. 503 if not

Command line

axa::cli::Cli is a clap parser that a service matches on itself:

service start [--worker | --all] [--binding ADDR] [--port PORT]
service db migrate | down [STEPS] | reset | status | truncate
service task [NAME] [KEY:VALUE ...]

Tasks

A task is a named job an operator runs by hand:

use axa::{prelude::*, task::{Tasks, Vars}};

struct Greet;

#[async_trait]
impl Task for Greet {
    fn task(&self) -> TaskInfo {
        TaskInfo {
            name: "greet".to_string(),
            detail: "Say hello".to_string(),
        }
    }

    async fn run(&self, _ctx: &AppContext, vars: &Vars) -> Result<()> {
        println!("hello {}", vars.cli_arg("name")?);
        Ok(())
    }
}

let mut tasks = Tasks::default();
tasks.register(Greet);

Background jobs

A worker is a type with serializable arguments:

use axa::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Args {
    report_id: u64,
}

struct ReportWorker;

#[async_trait]
impl BackgroundWorker<Args> for ReportWorker {
    fn build(_ctx: &AppContext) -> Self {
        Self
    }

    async fn perform(&self, args: Args) -> Result<()> {
        println!("building report {}", args.report_id);
        Ok(())
    }
}

ReportWorker::perform_later(&ctx, args) then runs according to WORKER_MODE:

Mode Behaviour
BackgroundQueue Saved to Postgres for a worker process
BackgroundAsync Spawned on the current runtime
ForegroundBlocking Run before returning. Tests use this

The job queue

With the queue feature, jobs live in one axa_queue table, created on connect. It needs no broker: Postgres FOR UPDATE SKIP LOCKED lets each worker loop claim the oldest ready job in one statement, so no two loops ever claim the same row.

A job moves from queued to running to completed or failed, keeping its error text on the row. A job still running after stale_minutes is assumed lost with its worker and is queued again.

Delivery is at least once, so a job's work must be safe to repeat. Queue arguments are stored in plain text, so never put a secret in them.

Testing

With the testing feature, a service implements TestApp to say how it boots for tests. That is the one trait axa asks a service to implement, and it exists only for tests.

use axa::testing::prelude::*;

#[tokio::test]
async fn health_answers() {
    request::<App, _, _>(|server, _ctx| async move {
        assert_eq!(server.get("/_health").await.status_code(), 200);
    })
    .await;
}

Requirements

  • Rust 1.97 or newer, on edition 2024
  • PostgreSQL for the queue and sea-orm features

Development

cargo test --all-features
cargo test --features queue,testing
cargo test
cargo clippy --all-targets --all-features -- -D warnings
AXA_TEST_DATABASE_URL=postgres://... cargo test --features queue,testing -- --ignored

The last command runs the queue tests against a real Postgres. Those tests have caught a real race that nothing else did, so run them when you touch the queue.

Read AGENTS.md before changing the crate. A change in behaviour carries a spec edit, a code edit, and a test edit together.

Relationship to Loco

To move a project across, follow Converting a project from Loco to axa.

axa was extracted from what the Main X Index services relied on in Loco (Apache-2.0), and some differences are deliberate:

Loco axa
A Hooks trait drives your app Your main.rs calls plain functions
YAML config rendered through a template engine The environment, with defaults in code
Permissive CORS when enabled Listed origins only in production
x-powered-by header None
No request timeout or security headers by default Both on in production; headers everywhere

The code was written afresh rather than copied, so this crate can keep its own licence terms.

License

See LICENSE.md.