⚡ 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.
Quick Start • Features • Architecture • Auth Pipeline • Examples • Configuration
👀 At a Glance
use *;
;
;
async
One annotation gives you: routing, OpenAPI docs, JWT decoding, distributed tracing, and dependency injection — with no locks on the request hot path.
📦 Feature Matrix
| Capability | Details | |
|---|---|---|
| 🧩 | Declarative routing | #[Controller], #[Get]/#[Post]/#[Put]/#[Patch]/#[Delete], typed #[Param]/#[Query]/#[Body] extraction |
| 🎛️ | Production governance | One LaunchConfig: process-wide request deadline (504), atomic in-flight admission cap (503 + Retry-After), configurable body cap, bounded response cache with background sweeper, WebSocket drain deadline, config-driven CORS, x-request-id on every response — all lock-free |
| 🔌 | 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 |
| 🔏 | Field-level encryption | #[EncryptFields(key = "tenant:acme", fields("ssn"))] — AES-256-GCM envelope encryption under per-tenant / per-subject DEKs (CryptoVault, ArcSwap key ring, KMS behind the KekSource trait); rotation without re-encryption, and GDPR erasure via crypto-shredding (vault.shred) even inside append-only sinks |
| 🏬 | 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 → draining → graceful drain, per-plugin budget via LaunchConfig); plugin routes share the exact same request pipeline as macro routes; global interceptors, pre-body BoundaryFilters, explicit DI overrides, runtime-mounted routes under /_plugins/* (ArcSwap, lock-free) |
| 🚀 | Response caching | #[CacheTTL(30)] + CacheInterceptor — keyed on path + query, hit/miss stats, one-call flush |
🏛 Architecture
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/ data governance: #[MaskFields] masking · #[EncryptFields] envelope encryption + crypto-shredding
├── 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.
| Invariant | Why it matters |
|---|---|
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
# Run the full-featured example — works without Redis (in-memory fallback)
| Endpoint | URL |
|---|---|
| 📜 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:
| Password | Role | Permissions | |
|---|---|---|---|
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
# ── 1. Login: returns a JWT pair AND sets signed cookies ──────────────────────
|
# ── 2a. Bearer auth (API clients) ──────────────────────────────────────────────
TOKEN=
|
# ── 2b. Cookie auth (browsers) — no header, the signed cookie carries the JWT ──
|
# ── 3. Tampered cookies are rejected by HMAC verification ─────────────────────
Token lifecycle guarantees
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→refreshJTI 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 JWTpermsclaim first (zero I/O), with a role-map fallback. Wildcards (users:*) are honored. - Login is brute-force-protected cluster-wide —
/auth/loginsits behind aDistributedRateLimit(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
SecretSourcewatcher hot-swaps JWT/cookie keys throughArcSwap; 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:
// safe client retries
// PII never persists raw
// ABAC gate (hot-reloadable)
// compliance record
// 504 + cancel on expiry
async … // atomic with the write
# Versioned + deprecated endpoints announce their lifecycle on the wire:
|
# deprecation: true
# sunset: 2027-01-01
And the stack is self-documenting — every attribute above surfaces in the generated OpenAPI spec, no extra annotations:
// curl -s localhost:3000/openapi.json | jq '.paths["/orders/"].post'
{
"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
# Tenant resolved once per request (X-Tenant-Id header → frozen TenantRegistry);
# TENANT guard rejects a forged header that contradicts the JWT tenant claim.
|
OAuth 2.0 (Google / GitHub)
Providers self-register only when credentials are present — no config, no route:
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:
= { = "0.1", = ["db-sqlx-postgres"] }
# or: db-seaorm-postgres · db-diesel-postgres · db-sqlx-sqlite · …
# Features are composable — enable two drivers during a migration.
// boot (plugin on_init): build pools, register per tenant, freeze
let primary = sqlx_driver.await?;
ctx.provide;
// handler: one attribute = begin → commit on Ok / rollback on Err or timeout
async …
Guarantees, verified by the example's /notes demo:
| Path | Behaviour |
|---|---|
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
// produce (inside #[Transactional] — same DB transaction as the data):
tx.execute_bind.await?;
// consume (NestJS-style — registered at link time, dispatched lock-free):
| Guarantee | Mechanism |
|---|---|
| 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:
ctx.provide;
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.
Plugin system — extend the framework without forking it
A plugin owns a corner of the framework's behaviour through one trait. Everything it registers rides the same frozen, lock-free runtime as the core:
use *;
use BoxFuture;
;
The lifecycle contract, enforced by App::launch_configured:
| Phase | When | Guarantees |
|---|---|---|
on_init |
before the DI container freezes | queue providers/overrides, routes, filters, interceptors; route & provider collisions fail launch loudly (no silent winners, no axum panics) |
on_start |
listener is live | resolve anything via &'static FrozenDiContainer; a failure rolls back already-started plugins in reverse order |
on_draining |
shutdown signal received | fires concurrently with the HTTP in-flight drain — quiesce MQ consumers and schedulers |
on_shutdown |
HTTP fully drained | reverse order, per-plugin drain_budget timeout — a wedged plugin can't wedge the process |
Two escape hatches keep "dynamic" off the hot path:
- Runtime routes —
Inject<DynamicRouteTable>, thenmount()/unmount()under/_plugins/*at any time. Dispatch is oneArcSwapload + one hash probe; routes outside the namespace pay nothing. The core router stays frozen. - Request-scoped state —
ctx.extensions_mut()carries typed per-request values from interceptors to handlers. The process-wide container is untouched.
See examples/enterprise_app/src/infra/shield.rs for a compact plugin that
exercises this entire surface.
Production governance — LaunchConfig
Every operational guardrail lives in one struct, applied as outermost layers at launch (zero per-request cost for anything left disabled):
.await
Operational guarantees behind those knobs:
- Shutdown always completes: signal →
on_draining(concurrent with HTTP drain) → WS Close frames at the deadline → drain finishes → per-pluginon_shutdown. No state where a live socket or wedged plugin forces a SIGKILL. - Every response carries
x-request-id(inbound honoured, else minted) — including sheds and timeouts — alongside W3Ctraceparentfor tracing. - JWT signing never panics:
issue_*returnResult<_, JwtSignError>; a bad rotation payload is a 500 on that request, not a dead process. - No unbounded memory on the request path: response cache is capacity-
gated and swept;
/openapi.jsonis serialized once at boot and served as static bytes.
🧰 Examples
| Feature | Where |
|---|---|
| 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 | bootstrap.rs |
Full plugin surface: boundary filter, global interceptor, runtime /_plugins route, drain hooks |
shield.rs |
Field encryption: dev KekSource, per-tenant DEKs, /_plugins/vault/demo |
kek_source.rs, bootstrap.rs |
Production governance: deadline, admission cap, CORS (LaunchConfig) |
main.rs |
| Redis with in-memory fallback | redis.rs |
WebSocket gateways, rooms, broadcast patterns, and SSE under one roof.
⚙️ Configuration
All settings are environment variables read at startup by the example's AppInitPlugin:
| Variable | Default | Purpose |
|---|---|---|
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: trueon cookies (TLS), and pointREDIS_URLat a persistent instance — the in-memory fallback loses sessions and token state on restart.
🛠 Development
📄 License
MIT