axa
axa is a small shared library for web services built directly on Axum, with sqlx the preferred way to reach a database.
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.rsis 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:
[]
= "0.4"
| Feature | Adds |
|---|---|
queue |
The Postgres job queue |
testing |
Request-level test helpers, built on axum-test |
sea-orm |
A SeaORM-backed AppContext, plus axa-seaorm: connections, migration helpers, and model errors, with an easy path from Loco |
diesel |
axa-diesel: Diesel pooling, migrations, and test resets, with no libpq |
tera |
axa-tera: Tera templates with safe escaping and generic errors |
tracing |
axa-tracing: redaction, request tracking across spawned tasks and peers, and diagnostics for blocked or stalled async code |
redis |
axa-redis: a Redis cache with mandatory expiry |
openapi |
axa-openapi: OpenAPI documents, a pinned Swagger UI, route listing, and drift checks |
opentelemetry |
axa-opentelemetry: OTLP export of traces and request metrics, off until an endpoint is set |
session |
axa-session: web sessions over axum_session, signed and encrypted in production, with form tokens. Nothing is stored for a visitor until they sign in. No service uses this yet |
sqlx |
axa-sqlx: pooling, migrations, and test resets. AppContext::db becomes a PgPool |
pagination |
axa-pagination: offset pages, count headers, and LIKE escaping |
keycloak |
axa-keycloak: Keycloak bearer authentication and ABAC enforcement, standing alone so it can be audited alone |
paseto |
axa-paseto: PASETO bearer authentication and ABAC enforcement. No service uses this yet. Enforcement is on unless turned off |
problem |
axa-problem: RFC 9457 problem details. No service uses this yet |
prometheus |
axa-prometheus: Prometheus text exposition |
versioning |
axa-versioning: Accepts-version negotiation |
Environment lives in axa-environment, which has no
dependencies and is always included, so a migration crate can read the environment
the same way without depending on axa.
Quick start
This is a complete service with one route:
use ;
async
async
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 *;
async
async
async
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 *;
# async
let group = new.prefix.add;
assert!;
AppRoutes::mounted lists every route as (method, full path), so a route census
test can compare what is served with what is written down. A HEAD that comes with
GET is left out, and a handler for every method shows as ANY. Axum keeps a
router's methods private, so this reads its debug text, and a test fails if that
text changes.
# use *;
# async
let routes = empty
.add_route;
assert_eq!;
A route or group can state its own time limit where it is declared. It replaces
REQUEST_TIMEOUT_SECONDS there, longer or shorter, and other routes are still cut
off on time:
# use *;
use Duration;
# async
# async
let reports = new
.prefix
.timeout
.add
.add_with_timeout;
# let _ = reports;
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 finished request, and puts the request id, method, and route template on every log line written while serving it. The real path and query string are left out, because either can hold a token or personal data |
| 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. Routes::timeout and Routes::add_with_timeout set a route's own, and Http::timeout_overrides sets one by path suffix |
| Compression | Gzip, Brotli, and Zstd when enabled |
| Body limit | Answers 413 over BODY_LIMIT_BYTES. middleware::body_limit raises it on one route |
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. With neither set, a debug build is development and a release build is production |
PORT, BINDING, HOST |
Listening port, address, and public URL |
DATABASE_URL |
Connection string. Required in production for a service with a database, where it must set sslmode to require, verify-ca, or verify-full unless it stays on this machine |
DB_ALLOW_INSECURE |
Let a production service reach its database without demanding TLS, as on a network that encrypts its own traffic |
DB_MIN_CONNECTIONS, DB_MAX_CONNECTIONS |
Pool size |
DB_CONNECT_TIMEOUT, DB_IDLE_TIMEOUT |
Pool timeouts, in milliseconds. 5 seconds and 10 minutes by default, or a service's Defaults |
DB_LOGGING, DB_AUTO_MIGRATE |
Statement logging, and migrating at boot. A service sharing a database it does not own sets Defaults::auto_migrate to false |
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 |
HEADER_READ_TIMEOUT_SECONDS |
How long a client has to send a request's headers. 30 by default, and 0 allows for ever |
SHUTDOWN_TIMEOUT_SECONDS |
How long, once told to stop, to wait for requests and jobs. 25 by default, and 0 waits for as long as they take |
HSTS_MAX_AGE_SECONDS |
How long browsers keep to HTTPS. One year by default |
HTTP_CSP |
The content-security-policy. Production sends default-src 'none'; frame-ancestors 'none' unless this is set, and sends none if it is empty |
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 |
A setting that is set but invalid, such as HTTP_HSTS=tru, is a mistake, and a
silent default would hide it. In production and any unknown environment it stops
the boot, and the error names the variable and its value. Elsewhere the default
applies and logger::init prints a warning. A blank value counts as unset, which
is what a compose file sends for an unset variable. Flags take 1, true, yes,
on, 0, false, no, or off.
In development and test, the local database is
postgres://postgres:postgres@localhost:5432/<name>_<environment>.
Config::from_lookup takes the lookup as a function, so configuration can be
tested without touching the process environment:
use ;
let config = from_lookup
.unwrap;
assert_eq!;
Health checks
| Route | Meaning |
|---|---|
/_ping, /_health |
The process is up |
/_readiness |
The database, the queue, and any named checks also answer. 503 if not, with {"ok":false,"failed":[...]} |
The route knows the database and queue only when axa holds them, which includes
the pool in AppContext::db with the sqlx feature. Anything else is a named
check, which is an ordinary async closure:
ctx.health_checks = new.add;
The route is public, so a caller must not be able to drive load at a dependency.
One answer, passing or failing, is reused for 5 seconds, and callers during a round
of checks wait for it. A check gets 2 seconds, and past that it has failed. Change
these with Checks::reuse_for and Checks::time_limit. The response carries the
check's name and never its error text, which is only logged. Do not put an address
in a name.
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 ...]
service check
axa::cli::parse reads it, and takes the service's name, version, and description
for --help and --version:
let cli = parse;
parse refuses db down, db reset, and db truncate, because they destroy data.
They run only in development or test, with AXA_DESTROY_DATA=true, and the process
exits non-zero before your code runs. --environment cannot lift this: a production
process stays refused whatever the flag says. Cli::parse() does not refuse them.
check validates the configuration, prints the effective settings with passwords
and other secrets redacted, and exits, non-zero if refused. It connects to nothing,
and it knows only the settings axa reads. Config::apply_cli applies the start
overrides.
Tasks
A task is a named job an operator runs by hand:
use ;
;
let mut tasks = default;
tasks.register;
Background jobs
A worker is a type with serializable arguments:
use *;
use ;
;
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. Queue::connect creates
it, and Queue::connect_existing changes no schema, for a role that may not: a
migration then runs queue::SETUP_STATEMENTS.
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 running job reports in every heartbeat_interval, so it
can take as long as it needs, and one not heard from for stale_minutes is assumed
lost with its worker. A job that panics fails, and its loop carries on.
A job gets one attempt by default, because a retry repeats its work. Raise
max_attempts for the queue, or override BackgroundWorker::max_attempts for jobs
written to be repeated, and each retry then waits twice as long as the last. A job
lost with its worker is queued again if it has attempts left and otherwise fails,
so a crash cannot loop forever. Finished rows are deleted after retention_days.
queue::enqueue_on queues a job on the caller's sqlx transaction, so the job and
the record it is about commit together. Queue::enqueue uses its own connection.
Nothing runs jobs by itself. A service needs start --all or a separate
start --worker, and queue::warn_if_unserved says so at boot. 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 *;
async
Requirements
- Rust 1.97 or newer, on edition 2024
- PostgreSQL for the
queueandsea-ormfeatures
Development
AXA_TEST_DATABASE_URL=postgres://...
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. The full list that CI runs is in section 5 of the spec.
AI agents have two skills: axa-skill for building a
service with axa, and axa-maintainer-skill for
working on axa itself.
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 upgrade from an earlier version, read Upgrading to axa 0.3.
To move a project across, follow Converting a project from Loco to axa, and for the database, Moving SeaORM code 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.