<div align="center">
# ⚡ arcly-http
### NestJS ergonomics meets Rust safety & speed
*A batteries-included, enterprise-grade HTTP framework built on axum — declarative controllers, zero-lock DI, a complete auth pipeline, realtime gateways, and first-class observability.*
[](https://www.rust-lang.org)
[](https://github.com/tokio-rs/axum)
[](#-license)
[](#)
[Quick Start](#-quick-start) •
[Features](#-feature-matrix) •
[Architecture](#-architecture) •
[Auth Pipeline](#-authentication--authorization) •
[Examples](#-examples) •
[Configuration](#-configuration)
</div>
---
## 👀 At a Glance
```rust
use arcly_http::prelude::*;
pub struct UserController;
#[Controller("/users", tags("users"))]
impl UserController {
/// Fetch a single user by ID — JWT-protected, traced, documented.
#[Get("/:id", summary("Get user"), security("bearer"))]
#[UseInterceptors(TraceInterceptor)]
async fn get_user(
svc: Inject<UserService>, // ← zero-lock DI, O(1) resolution
ctx: RequestContext, // ← claims & session pre-decoded
#[Param("id")] id: u64, // ← typed path extraction
) -> Result<Json<User>, HttpException> {
JWT_AUTH.check(&ctx)?; // ← zero-overhead guard
svc.find(id).map(Json).ok_or_else(|| NotFound::new("user not found").into())
}
}
#[Module(controllers(UserController), providers(UserService))]
pub struct AppModule;
#[tokio::main]
async fn main() -> std::io::Result<()> {
App::launch::<AppModule>("0.0.0.0:3000").await // /docs, /healthz, /metrics — free
}
```
One annotation gives you: routing, OpenAPI docs, JWT decoding, distributed tracing, and dependency injection — with **no locks on the request hot path**.
---
## 📦 Feature Matrix
| 🧩 | **Declarative routing** | `#[Controller]`, `#[Get]`/`#[Post]`/`#[Put]`/`#[Patch]`/`#[Delete]`, typed `#[Param]`/`#[Query]`/`#[Body]` extraction |
| 🔌 | **Zero-lock DI** | `#[Injectable]` + `#[Module]` DAG → frozen `&'static` container; `Inject<T>` resolves in O(1) with **no locks, no allocation** |
| 🔐 | **JWT auth** | `JwtService` (HS/RS/ES families), access + refresh pairs, automatic Bearer decoding at the boundary, `JWT_AUTH` / `RoleGuard` |
| 🍪 | **Signed cookies** | `CookieService` — HMAC-SHA256, tamper-proof; boundary falls back to the JWT cookie when no Bearer header (browser-friendly, no localStorage) |
| 🗂️ | **Server-side sessions** | `SessionManager` + pluggable `SessionStore` trait; `ctx.session()` on every request; `SESSION_AUTH` guard |
| 🌐 | **OAuth 2.0 + PKCE** | `OAuth2Provider` trait + registry — Authorization Code flow with atomic single-use CSRF state; Google & GitHub providers in the example |
| 🛡️ | **Fine-grained permissions** | `resource:action` perms embedded in JWT (`issue_access_with_perms`) → zero-I/O permission checks with wildcard support (`users:*`) |
| 🏢 | **Multi-tenancy** | `TenantRegistry` (header / subdomain strategies, frozen map) resolved once per request; `ctx.tenant()` + `TENANT` guard cross-checks the JWT `tenant` claim against the resolved tenant |
| 🌍 | **Distributed rate limiting** | `DistributedRateLimit` over a pluggable `RateLimitBackend` — cluster-wide sliding window (atomic Redis Lua), `sub`-then-IP principal keying, explicit `FailOpen`/`FailClosed` policy |
| 🔄 | **Secrets rotation** | `SecretSource` (Vault / AWS SM / env) + `Rotating<T>` over `ArcSwap` — hot-swap `JWT_SECRET`/cookie secrets with zero restarts; previous key verifies through the grace window |
| 🗄️ | **Datasource routing** | `DataSource` trait + frozen `DataSourceRegistry`: tenant-scoped pools, read/write splitting, `ReadAfterWritePin` defeats replica lag |
| 📋 | **Audit trail** | `#[AuditLog(action, resource)]` — one record per mutation (who/what/when/where/outcome), lock-free `try_send` hot path, hash-chained (SHA-256) batches into an app-provided append-only `AuditSink` |
| 📤 | **Transactional outbox** | `OutboxTx::enqueue` inside the business transaction + background `OutboxRelay` (poll → publish → ack, at-least-once with idempotency keys) — kills the dual-write problem |
| 🏷️ | **API versioning** | `#[Version("v1")]` mounts the controller under `/v1/...`; `#[Deprecated(sunset = "…")]` adds RFC 8594 `Deprecation`/`Sunset` headers so clients learn deadlines on the wire |
| 🧬 | **Unified DB layer** | `ArclyDbPool` — one injectable handle over **SQLx / SeaORM / Diesel** (Cargo features `db-sqlx-*` / `db-seaorm-*` / `db-diesel-*`); tenant routing + RW splitting apply via the existing registry; sync Diesel fully isolated behind `spawn_blocking` |
| 💾 | **`#[Transactional]`** | Begin on the tenant pool → commit on `Ok`, rollback on `Err` / `#[Timeout]` cancellation; task-local tx (no locks); `with_current_tx` reaches it from services — outbox rows ride the **same transaction** |
| 🔏 | **Distributed lock** | `DistributedLock` + `DLockBackend` — fencing tokens (cluster-monotonic), compare-and-delete release, auto-renew heartbeat; `OutboxRelay` leader election ships on it |
| 🗃️ | **DB migrations** | `MigrationRunner` — versioned `arcly_migrations` ledger per datasource, checksum **drift = boot failure**, fleet DDL serialized on the cluster lock, multi-tenant `run_all` |
| 🔁 | **Idempotency keys** | `#[Idempotent(ttl = "24h")]` — Stripe-style `Idempotency-Key`: atomic claim, stored-response replay (`Idempotency-Replayed: true`), 409 for concurrent duplicates |
| ⚖️ | **ABAC policy engine** | `#[RequirePolicies("orders.refund")]` — default-deny attribute rules (subject × resource × env) over `ArcSwap`; hot-reload a new `PolicySet` and the next request obeys it — no OPA call on the hot path, no restart |
| 📨 | **Event consumer mesh** | `#[EventConsumer]` + `#[EventPattern("topic")]` — link-time handler registry (frozen dispatch), consume-side dedupe, bounded retries → dead-letter with reason; transport (Kafka/AMQP/NATS) is one app-side trait |
| 🧵 | **Cross-channel tracing** | `traceparent` rides outbox rows and message envelopes — consumers continue the producing request's trace (`TraceContext::from_traceparent`) instead of starting orphan roots |
| 🕶️ | **PII masking** | `#[MaskFields("card:last4")]` + hot-reloadable `Masker` (`ArcSwap`) — Redact/Hash/Last4/Drop applied **before durability** at every sink: responses, the idempotency replay cache, outbox rows, and dead-lettered payloads; fields surface as `x-arcly-masked-fields` |
| 🏬 | **Dynamic tenants** | `TenantRegistry` over `ArcSwap` snapshots — `upsert`/`suspend`/`resume` live on the next request (no redeploy); suspended tenants get 401 with no silent fallback |
| ⏱️ | **Deadlines & bulkheads** | `#[Timeout("2s")]` → `504` + future cancellation (worker freed); `Bulkhead` (const semaphore) sheds with `503` instead of queueing when a slow dependency saturates |
| ✅ | **Validation** | `validator` integration on `#[Body]`/`#[Query]` — violations return RFC-7807 `422` with per-field errors, automatically |
| ⚡ | **Resilience** | `#[circuit_breaker(threshold = 3, cooldown = "10s")]` (lock-free state machine) + `RateLimit` fixed-window limiter (single CAS) |
| 📡 | **Realtime** | WebSocket gateways with rooms & broadcast, JWT auth at handshake; SSE streams |
| 🔭 | **Observability** | W3C `traceparent` in/out, Prometheus `/metrics`, `/healthz` + `/readyz` with pluggable checks, OTLP traces, structured JSON logs |
| 📜 | **OpenAPI** | Swagger UI at `/docs` — and the spec mirrors the whole hardening stack: `Idempotency-Key` param + 409 + replay header, ABAC-aware 403s, 504 with the route deadline, RFC 8594 `Sunset` headers, `x-arcly-*` vendor extensions for gateways/SDK generators |
| 🧱 | **Plugin system** | `ArclyPlugin` lifecycle (init → start → graceful drain with timeout); plugin routes share the *exact same* request pipeline as macro routes |
| 🚀 | **Response caching** | `#[CacheTTL(30)]` + `CacheInterceptor` — keyed on path + query, hit/miss stats, one-call flush |
---
## 🏛 Architecture
```mermaid
flowchart LR
subgraph entry ["Request Entry Points"]
A["HTTP<br/>macro routes"]
B["Plugin<br/>routes"]
C["WebSocket<br/>handshake"]
end
subgraph pipeline ["One Shared Pipeline"]
D["auth::extract<br/>Bearer → cookie → session"]
E["observability::propagation<br/>W3C traceparent"]
F["RequestContext"]
end
subgraph runtime ["Runtime"]
G["Guards<br/>JWT · Role · Session · Perms"]
H["Handler"]
I["core: frozen DI<br/>(HTTP-agnostic)"]
end
A --> D
B --> D
C --> D
D --> F
E --> F
F --> G --> H
H -. "Inject<T> · O(1), no locks" .-> I
```
### Workspace layout
```
arcly-http/ ── the framework crate
└── src/
├── auth/ identity & access — one place, one pipeline
│ ├── jwt.rs JwtService: sign / decode / validate (rotating keys)
│ ├── cookie.rs HMAC-SHA256 signed cookies (rotating secrets)
│ ├── session.rs SessionManager + SessionStore trait
│ ├── oauth.rs OAuth2Provider trait + registry
│ ├── secrets.rs SecretSource · Rotating<T> (ArcSwap) · watcher
│ ├── policy.rs ABAC engine: default-deny PolicySet · hot reload
│ ├── guards.rs JWT_AUTH · RoleGuard · SESSION_AUTH
│ └── extract.rs ★ the single credential-extraction pipeline
├── core/ DI engine + plugin lifecycle (HTTP-agnostic)
├── data/ DataSource · tenant registry · RW splitting · outbox
│ ├── db.rs / tx.rs unified ArclyDbPool + #[Transactional] runtime
│ ├── drivers/ sqlx · seaorm · diesel (feature-gated)
│ └── migrate.rs versioned migrations · checksum drift guard
├── web/ boundary, RequestContext, tenants (dynamic), versioning, idempotency
├── compliance/ PII masking: #[MaskFields] · sink-level redaction
├── messaging/ event consumer mesh: #[EventPattern] · retries · DLQ
├── realtime/ WebSocket gateways + SSE
├── observability/ telemetry, metrics, health, propagation, audit trail
└── resilience/ circuit breaker · rate limiting · timeout · bulkhead · dlock
arcly-http-macros/ ── proc-macros (#[Controller], #[Module], …)
examples/
├── enterprise_app/ ── full e-commerce backend, every feature exercised
│ └── src/
│ ├── api/ one file per resource + shared guards
│ ├── auth/ AuthService, OAuth providers, permissions
│ ├── infra/ bootstrap, redis, sessions, datasources, secrets
│ └── domain.rs · services.rs
└── realtime_enterprise_app/ ── realtime-focused walkthrough
```
### Design invariants
> These are enforced by structure, not convention — each has exactly one home in the tree.
| **One request pipeline** — every entry point authenticates through `auth/extract.rs` | A security fix lands everywhere at once; entry points can never drift |
| **`core` is HTTP-agnostic** — the DI engine never imports axum | Engine internals evolve without touching routing code |
| **Handlers never see axum** — `RequestContext` is the only request surface | Swap or upgrade the HTTP layer without breaking user code |
| **Macros target one stable module** — codegen references `__macro_support` only | Internal files move freely; the macro crate never needs to follow |
| **No locks on the request path** — frozen maps, atomics, and `ArcSwap` only | Tenancy, rate limiting, and secret rotation scale without contention |
---
## 🚀 Quick Start
```bash
git clone git@gitlab.com:arcly/arcly-http/arcly-http.git
cd arcly-http
# Run the full-featured example — works without Redis (in-memory fallback)
cargo run -p enterprise_app
```
| 📜 Swagger UI | `http://localhost:3000/docs` |
| ❤️ Health | `http://localhost:3000/healthz` |
| 📊 Metrics | `http://localhost:3000/metrics` |
| 📡 Live SSE feed | `curl -N http://localhost:3000/streams/inventory` |
---
## 🔐 Authentication & Authorization
The enterprise example seeds two demo users at startup:
| `admin@example.com` | `admin123` | admin | `users:*` `products:*` `orders:*` `admin:*` |
| `user@example.com` | `user123` | customer | `products:read` `orders:read` `orders:create` |
### Three ways in, one pipeline
```bash
# ── 1. Login: returns a JWT pair AND sets signed cookies ──────────────────────
curl -s -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
# ── 2a. Bearer auth (API clients) ──────────────────────────────────────────────
TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
# ── 2b. Cookie auth (browsers) — no header, the signed cookie carries the JWT ──
# ── 3. Tampered cookies are rejected by HMAC verification ─────────────────────
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/auth/me \
-H "Cookie: arcly_auth=forged.signature" # → 401
```
### Token lifecycle guarantees
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
participant R as Redis
C->>S: POST /auth/login
S->>R: SET refresh::{jti} (TTL 7d)
S->>R: SET access2refresh::{access_jti} → refresh_jti
S-->>C: token pair + Set-Cookie ×2
C->>S: POST /auth/refresh
S->>R: GETDEL refresh::{jti} (atomic — single use)
Note over S,R: concurrent replay of the same<br/>refresh token loses the race → 401
S-->>C: new token pair
C->>S: POST /auth/logout
S->>R: GETDEL access2refresh::{access_jti}
S->>R: DEL refresh::{refresh_jti}
S-->>C: 204 + Max-Age=0 cookies (auth + session cleared)
```
- **Refresh tokens are single-use** — rotation uses atomic `GETDEL`, so a stolen-then-replayed token always loses the race.
- **Logout actually revokes** — the `access→refresh` JTI mapping lets a Bearer-only logout kill the paired refresh token, delete the server-side session, and expire both cookies.
- **Permissions ride the token** — `PermissionGuard::require("users:read")` checks the JWT `perms` claim first (zero I/O), with a role-map fallback. Wildcards (`users:*`) are honored.
- **Login is brute-force-protected cluster-wide** — `/auth/login` sits behind a `DistributedRateLimit` (10/min per principal, **fail-closed**): the sliding window counts in Redis via one atomic Lua call, so the limit holds across every replica.
- **Secrets rotate without restarts** — a `SecretSource` watcher hot-swaps JWT/cookie keys through `ArcSwap`; the previous key keeps verifying until live credentials expire naturally.
### Declarative route hardening
Every cross-cutting policy is one attribute on the handler — the macro weaves
it into the same compiled thunk, so there is no runtime middleware lookup:
```rust
#[Post("/", status(201), security("bearer"))]
#[Idempotent(ttl = "24h")] // safe client retries
#[MaskFields("payment.card:last4")] // PII never persists raw
#[RequirePolicies("orders.create")] // ABAC gate (hot-reloadable)
#[AuditLog(action = "order.create", resource = "order")] // compliance record
#[Timeout("5s")] // 504 + cancel on expiry
#[UseInterceptors(TraceInterceptor)]
async fn create_order(ctx: RequestContext, #[Body] dto: CreateOrderDto) -> … {
JWT_AUTH.check(&ctx)?;
let _permit = ORDER_BULKHEAD.try_enter()?; // 503 when saturated
… // outbox enqueue is
} // atomic with the write
```
```bash
# Versioned + deprecated endpoints announce their lifecycle on the wire:
# sunset: 2027-01-01
```
And the stack is **self-documenting** — every attribute above surfaces in the
generated OpenAPI spec, no extra annotations:
```jsonc
"parameters": [ { "name": "Idempotency-Key", "in": "header", … } ],
"responses": {
"409": { "description": "Conflict — a request with this Idempotency-Key is already in flight" },
"504": { "description": "Gateway Timeout — handler exceeded its 5000ms deadline (work cancelled)" },
"201": { "headers": { "Idempotency-Replayed": { … } } }
},
"x-arcly-idempotent-ttl-secs": 86400,
"x-arcly-audit": { "action": "order.create", "resource": "order" },
"x-arcly-timeout-ms": 5000
}
// ABAC routes get policy-aware 403s + x-arcly-policies; sunset routes get
// deprecated:true, x-sunset, and documented RFC 8594 response headers.
```
### Multi-tenancy & datasource routing
```bash
# Tenant resolved once per request (X-Tenant-Id header → frozen TenantRegistry);
# TENANT guard rejects a forged header that contradicts the JWT tenant claim.
curl -s http://localhost:3000/admin/tenant-info \
```json
{
"tenant": "acme",
"datasource": "acme",
"read_before_write": "acme-replica-0", // reads → replica (round-robin)
"write": "acme-primary", // writes → primary (trips the pin)
"read_after_write": "acme-primary" // pinned — replica-lag protection
}
```
### OAuth 2.0 (Google / GitHub)
Providers self-register only when credentials are present — no config, no route:
```bash
export GOOGLE_CLIENT_ID=… GOOGLE_CLIENT_SECRET=…
export GITHUB_CLIENT_ID=… GITHUB_CLIENT_SECRET=…
export OAUTH_REDIRECT_BASE=https://your-domain.example
```
```
GET /oauth/{provider}/authorize → { "url": "https://accounts.google.com/…" }
GET /oauth/{provider}/callback → validates CSRF state (atomic, single-use),
exchanges code with PKCE, upserts the user,
mints tokens through the same path as
password login, sets both cookies
```
---
Tenants are **dynamically provisionable**: `POST /admin/tenants` upserts a
tenant live (effective on the next request — no redeploy), and
`/admin/tenants/:id/suspend` hard-cuts its traffic to `401` with no silent
fallback to the shared pool. The hot path stays one `ArcSwap` pointer load.
### Unified database layer & `#[Transactional]`
Pick your ecosystem with Cargo features — the injected handle and the
transaction semantics stay identical:
```toml
arcly-http = { version = "0.1", features = ["db-sqlx-postgres"] }
# or: db-seaorm-postgres · db-diesel-postgres · db-sqlx-sqlite · …
# Features are composable — enable two drivers during a migration.
```
```rust
// boot (plugin on_init): build pools, register per tenant, freeze
let primary = sqlx_driver(&db_url, 16).await?;
ctx.provide(DataSourceRegistry::new(ArclyDbPool::new("default", primary)));
// handler: one attribute = begin → commit on Ok / rollback on Err or timeout
#[Post("/", status(201), security("bearer"))]
#[Transactional]
#[AuditLog(action = "note.create", resource = "note")]
async fn create_note(ctx: RequestContext, #[Body] dto: CreateNoteDto) -> … {
with_current_tx(|mut tx| async move {
let r = async {
tx.execute_bind("INSERT INTO notes (body) VALUES (?)", &[&dto.body]).await?;
tx.execute_bind("INSERT INTO arcly_outbox (…) VALUES (…)", &[…]).await?;
Ok(()) // ← outbox rides the SAME transaction
}.await;
(tx, r)
}).await?;
…
}
```
Guarantees, verified by the example's `/notes` demo:
| Handler returns `Ok` | transaction commits — note **and** outbox row land together |
| Handler returns `Err` *after* both inserts | rollback — neither row survives |
| `#[Timeout]` expiry / client disconnect | future dropped → uncommitted tx → driver rollback |
| Sync Diesel | `#[Transactional]` rejects with guidance — use `DieselBlockingPool::transaction(…)` (whole tx in one `spawn_blocking` closure; workers never block) |
| Schema changes | `MigrationRunner` — ledgered, checksummed (drift fails the boot), serialized across the fleet on the cluster lock |
---
### Event mesh — produce transactionally, consume declaratively
```rust
// produce (inside #[Transactional] — same DB transaction as the data):
tx.execute_bind("INSERT INTO arcly_outbox (topic, payload, idempotency_key, traceparent) …").await?;
// consume (NestJS-style — registered at link time, dispatched lock-free):
#[EventConsumer]
impl NoteEventsConsumer {
#[EventPattern("note.created")]
async fn on_note_created(ctx: EventContext) -> Result<(), String> {
let evt: NoteCreated = ctx.payload()?; // typed payload
// ctx.trace continues the producing request's trace
ctx.inject::<SearchIndexer>().index(evt.note_id).await
}
}
```
| No dual writes | outbox row commits with the business data |
| Fleet-safe relay | leader election on the distributed lock |
| Effectively-once consume | claim → complete-on-success / release-on-failure via the idempotency store |
| Poison isolation | bounded nack-retries, then dead-letter with the failure reason |
| Trace continuity | `traceparent` column → `EventContext.trace` (same trace ID end-to-end) |
---
### PII masking — redact before durability
The compliance machinery is durable *on purpose* (hash-chained audit,
committed outbox rows, replayable idempotency cache, DLQ) — so raw PII
reaching any of them is permanent. The `Masker` closes that at the sink:
```rust
ctx.provide(Masker::new(MaskingPolicy::new(1)
.field("email") // Redact: j***@e***.com
.field("payment.card.number:last4") // ************4242
.field("ssn:drop")
.field("items.*.patient_name:hash"), // joinable, unreadable
));
```
One `ArcSwap` load + a pure tree walk per sink write — rules hot-reload at
runtime (`POST /admin/masking` in the example), and `#[MaskFields]` sits
*inside* `#[Idempotent]` so replay caches only ever hold masked bodies.
---
## 🧰 Examples
<details>
<summary><b>📦 enterprise_app</b> — full e-commerce backend (click to expand)</summary>
| JWT + refresh rotation (Redis) | `auth.rs` |
| Cookie + session auth | `auth.rs`, `session_store.rs` |
| OAuth 2.0 Google/GitHub + PKCE | `oauth.rs`, `oauth_providers.rs` |
| `resource:action` permissions | `permissions.rs` |
| Module DAG (5 modules) | `main.rs` |
| Circuit-breaker on payments | `services.rs` |
| Response caching with TTL | `controllers.rs` (products) |
| WebSocket order gateway | `gateways.rs` |
| SSE inventory stream | `controllers.rs` |
| Health checks + plugin lifecycle | `plugin.rs` |
| Redis with in-memory fallback | `redis.rs` |
</details>
<details>
<summary><b>📡 realtime_enterprise_app</b> — realtime-focused walkthrough</summary>
WebSocket gateways, rooms, broadcast patterns, and SSE under one roof.
</details>
---
## ⚙️ Configuration
All settings are environment variables read at startup by the example's `AppInitPlugin`:
| `REDIS_URL` | `redis://127.0.0.1:6379` | Token store + sessions — **falls back to in-memory** when unreachable |
| `JWT_SECRET` | dev default | JWT HMAC signing secret |
| `COOKIE_SECRET` | dev default | Auth-cookie HMAC secret |
| `SESSION_SECRET` | dev default | Session-cookie HMAC secret |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | unset | Enables the Google OAuth provider |
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | unset | Enables the GitHub OAuth provider |
| `OAUTH_REDIRECT_BASE` | `http://localhost:3000` | Base URL for OAuth callbacks |
| `ARCLY_DB_URL` | `sqlite:///tmp/arcly_demo.db?mode=rwc` | Unified DB pool for the `/notes` `#[Transactional]` demo |
Secrets (`JWT_SECRET`, `COOKIE_SECRET`, `SESSION_SECRET`) are watched at runtime: change the value at the source and the corresponding service hot-rotates its keys within 60 s — no restart, no mass logout. Tenants are selected per request via the `X-Tenant-Id` header (`acme` / `globex`, fallback `public`).
> ⚠️ **Production:** set real secrets, enable `secure: true` on cookies (TLS), and point `REDIS_URL` at a persistent instance — the in-memory fallback loses sessions and token state on restart.
---
## 🛠 Development
```bash
cargo build --workspace # build everything
cargo test -p arcly-http # unit + doc tests
cargo test -p arcly-http --test smoke # integration smoke suite
cargo run -p enterprise_app # full example server
```
---
## 📄 License
MIT