arcly-http 0.1.1

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation

⚡ 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.

Rust axum License: MIT OpenAPI

Quick StartFeaturesArchitectureAuth PipelineExamplesConfiguration


👀 At a Glance

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

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&lt;T&gt; · 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 axumRequestContext 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

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
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:

Email 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 ──────────────────────
curl -s -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"admin123"}' -c cookies.txt | jq

# ── 2a. Bearer auth (API clients) ──────────────────────────────────────────────
TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"admin123"}' | jq -r .access_token)
curl -s http://localhost:3000/auth/me -H "Authorization: Bearer $TOKEN" | jq

# ── 2b. Cookie auth (browsers) — no header, the signed cookie carries the JWT ──
curl -s http://localhost:3000/auth/me -b cookies.txt | jq

# ── 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

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 tokenPermissionGuard::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:

#[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
# Versioned + deprecated endpoints announce their lifecycle on the wire:
curl -si http://localhost:3000/v1/meta/info | grep -iE "deprecation|sunset"
# 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.
curl -s http://localhost:3000/admin/tenant-info \
  -H "Authorization: Bearer $TOKEN" -H "X-Tenant-Id: acme" | jq
{
  "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:

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:

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.
// 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:

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("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
    }
}
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(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.


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 arcly_http::prelude::*;
use futures::future::BoxFuture;

struct MyPlugin;

impl ArclyPlugin for MyPlugin {
    fn name(&self) -> &'static str { "my-plugin" }

    fn on_init<'a>(&'a mut self, ctx: &'a mut ArclyPluginContext)
        -> BoxFuture<'a, Result<(), PluginError>> {
        Box::pin(async move {
            ctx.provide(MyService::new());                    // new DI singleton
            ctx.override_provider(MySessionStore::new());     // replace a core provider — explicit, never silent
            ctx.add_get("/my/route", my_handler);             // mounted on the shared pipeline
            ctx.register_boundary_filter(&MY_FILTER);         // pre-body early reject (signatures, allowlists)
            ctx.register_global_interceptor(&MY_INTERCEPTOR); // wraps EVERY route, macro + plugin
            ctx.modify_openapi(|spec| { /* patch the spec */ });
            Ok(())
        })
    }
    // on_start:    listener live — spawn background tasks, mount runtime routes
    // on_draining: shutdown signal — stop consuming new work (runs WITH the HTTP drain)
    // on_shutdown: HTTP fully drained — cleanup, bounded by LaunchConfig::drain_budget per plugin
}

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 routesInject<DynamicRouteTable>, then mount()/unmount() under /_plugins/* at any time. Dispatch is one ArcSwap load + one hash probe; routes outside the namespace pay nothing. The core router stays frozen.
  • Request-scoped statectx.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):

App::launch_configured::<AppModule>(addr, info, plugins, LaunchConfig {
    request_timeout: Duration::from_secs(10),  // routes w/o #[Timeout] → 504, worker freed
    max_in_flight:   8_192,                    // beyond this → 503 + Retry-After (atomic CAS, pre-body)
    max_body_bytes:  2 * 1024 * 1024,          // request body cap, every entry point
    cache_max_entries: 50_000,                 // #[CacheTTL] store ceiling + background sweeper
    ws_drain_deadline: Duration::from_secs(10),// then Close frames — shutdown can't hang on live sockets
    drain_budget:    Duration::from_secs(5),   // per-plugin on_shutdown budget
    cors: Some(CorsConfig::for_origins(["https://app.example.com"])),
    ..Default::default()
}).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-plugin on_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 W3C traceparent for tracing.
  • JWT signing never panics: issue_* return Result<_, 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.json is 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: true on cookies (TLS), and point REDIS_URL at a persistent instance — the in-memory fallback loses sessions and token state on restart.


🛠 Development

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