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.7"
| Feature | Adds |
|---|---|
queue |
The Postgres job queue, worker pool, and schedules, from axa-worker. No service uses the pool limits, job status, or schedules yet |
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. No service uses this yet |
tera |
axa-tera: Tera templates with safe escaping and generic errors. No service uses this yet |
tracing |
axa-tracing: redaction, request tracking across spawned tasks and peers, and diagnostics for blocked or stalled async code. No service uses this yet |
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. No service uses this yet |
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 |
outbox |
axa-outbox: a transactional outbox and relay on Postgres, with the broker client left in the service. Turns on queue. No service uses this yet |
cursor-pagination |
axa-cursor-pagination: signed cursor pages with Link: rel="next" and no total, and LIKE escaping. No service uses this yet |
offset-pagination |
axa-offset-pagination: offset pages, count headers, and LIKE escaping. Insecure: a total can count rows a record policy hides. Prefer cursor-pagination |
pagination |
The old name for offset-pagination, as axa::pagination. It will go |
keycloak |
axa-keycloak: Keycloak bearer authentication and ABAC enforcement, standing alone so it can be audited alone. No service uses this yet |
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. Always included, as axa::problem, so this feature adds nothing |
prometheus |
axa-prometheus: Prometheus text exposition. No service uses this yet |
ratelimit |
axa-ratelimit: request limits per client or per key, and failed-login lockout, counted in one process. No service uses this yet |
idempotency |
axa-idempotency: Idempotency-Key handling over Postgres, so a retried write gets its first answer back. Where the layer goes. No service uses this yet |
versioning |
axa-versioning: Accepts-version negotiation. No service uses this yet |
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. A service with layers of its own, such as an auth guard, adds them first and
applies the stack last, because a layer added afterwards sits outside it. Its responses
get no request id, security headers, or CORS headers, it has no panic or timeout
protection, and it reads response bodies compressed:
let router = routes.into_router.layer;
let router = apply?;
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 |
| Not modified | Answers 304 to a GET or HEAD holding the ETag of its answer. See Conditional requests |
| Compression | Gzip, Brotli, and Zstd when enabled |
| Body limit | Answers 413 over BODY_LIMIT_BYTES. middleware::body_limit raises it on one route |
Conditional requests
axa::conditional follows RFC 9110. The standard stack answers 304 to a GET or
HEAD that holds the ETag of its answer, whoever set the tag, so a handler only sets
one. An ETag goes on a response like any header. Leave a timestamp out of it, or the
tag moves every time:
use ;
use json;
async
ETag::weak and ETag::strong take text of your own, such as a version. For a tag from
the body, add conditional::etag to a router or a route, before apply as with any
layer:
use ;
use ;
# async
let catalogue: Router = new
.route;
It hashes a GET's 200 and gives it a weak tag, the same on every replica and for
every encoding. It reads only a body of exact known size up to conditional::MAX_BODY
(8 MB), so a stream or server-sent events pass through unread. It leaves alone an answer
that has an ETag or says no-store, and a HEAD, whose body Axum has already dropped.
It is opt-in because a body with a timestamp in it gains nothing, and because hashing
what is marked no-store would put a hash of personal data on an answer nobody can
revalidate. Put it outside what shapes the body, such as paging, and outside what sets
Cache-Control. No service uses this layer yet.
To refuse a stale write, read the record, then ask, once the caller is authorised:
use ;
use HeaderMap;
async
require_if_match answers 428 with no If-Match, and if_match lets that through.
Both answer 412 to a stale tag, and if_match_holds gives the answer alone for a
service with a refusal of its own. If-Match compares strongly, as RFC 9110 requires, so
a weak tag never passes it and a tag meant for it must be strong. The check and the write
are two steps, so close the gap with a lock on the row or UPDATE ... WHERE version = $1.
A 304 comes after the handler has run, so it saves bytes and not the query. It is made
from the answer the caller would have had, so a refused caller gets the refusal and a tag
says nothing about what they may not see. The hash is std's DefaultHasher, which catches
accidents and not an adversary: a tag defending against someone who writes the data should
be a version counter or a real digest.
Errors
Handlers return Result<T> using axa::Error, which maps to a status and an
RFC 9457 problem, with the media type
application/problem+json. Successes stay bare JSON.
| Error | Status | code |
detail |
|---|---|---|---|
Error::NotFound |
404 |
not_found |
Fixed text |
Error::Unauthorized(_) |
401 |
unauthorized |
Fixed text; yours is only logged |
Error::BadRequest(_) |
400 |
bad_request |
Your text |
Error::CustomError(status, detail) |
Yours | Yours | Yours |
| Anything else | 500 |
internal_server_error |
None; 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.
code is the machine-readable reason. Field errors go in an errors member, which
ErrorDetail::errors sets. The standard stack makes Axum's own refusals problems too:
an unmatched path's 404, a wrong method's 405, and an extractor's rejection. It
treats any error sent as plain text or with no content-type the same way and drops
the text, so a handler that has something to say returns an Error.
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, once it connects or starts another request. 30 by default, and 0 allows for ever |
KEEP_ALIVE_TIMEOUT_SECONDS |
How long an idle connection is kept for a next request. Keep it above any load balancer's idle timeout. 75 by default, and 0 keeps it for ever |
SEND_STALL_TIMEOUT_SECONDS |
How long a client that has stopped reading may hold up a response before it is dropped. Any write that goes through starts this again. 60 by default, and 0 waits for ever |
MAX_CONNECTIONS |
The most connections open at once. 10,000 by default, and more wait to be accepted |
TRUSTED_PROXIES |
Comma-separated addresses or CIDR ranges of the proxies whose X-Forwarded-For entries are believed. Unset ignores the header |
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, as a problem whose failed names them |
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() refuses them too.
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.
A process runs workers jobs at once, 2 by default, and Queue::limit holds one job
type below that. A job may be delayed, given a priority, or given a time limit, and has
none unless it is given one. Queue::job, cancel, retry, and counts say and
change where a job is. A run that lost its claim is stopped, and its job waits in
needs_review for a person. Queue::every queues a job each interval, once per due
time across replicas, with no drift, one job for times missed, and no stacking, and a
schedule removed from code is archived, not deleted. See
Background jobs and schedules.
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.
The outbox
With the outbox feature, an event is a row in axa_outbox, written with
outbox::write on the caller's transaction, so it commits or rolls back with the change
it describes. A relay then publishes it, so a crash between the commit and the publish
loses nothing. Outbox::run takes the publisher as a closure, so the broker client
stays in the service, and a service that wants more runs its own loop on claim and
sent.
Delivery is at least once, so a consumer must dedupe on event_id. Several relays can
run at once, and each row normally goes out once. Events with one topic and key go out in
order. A broker that is down charges no row, and a row it keeps refusing ends as failed
and holds up nothing else. A claim whose relay died comes back after stale_minutes.
Nothing relays by itself. See the
guide for what a service writes. Payloads are
stored in plain JSON, so sent rows are deleted after retention_days, and a failed
row stays until someone deals with it.
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
queue,outbox, andsea-ormfeatures
Development
AXA_TEST_DATABASE_URL=postgres://...
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 |
If-None-Match compared as one exact string |
Weak comparison, lists, and *, as RFC 9110 has it |
The code was written afresh rather than copied, so this crate can keep its own licence terms.
License
See LICENSE.md.