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
//! Aspect-oriented interceptors.
//!
//! An [`Interceptor`] sits *around* a handler: it sees the inbound
//! `RequestContext` before extraction and the `Response` on the way out. Use
//! it for logging, timing, response envelopes, caching — anything that
//! shouldn't live in handler bodies.
//!
//! Apply with the `#[UseInterceptors(...)]` attribute on a handler or
//! controller `impl` block. Multiple interceptors compose in declaration
//! order (first listed = outermost layer).

use std::sync::Arc;

use axum::response::Response;
use futures::future::BoxFuture;

use crate::web::context::RequestContext;

/// Continuation handed to an interceptor. Calling `.run(ctx)` invokes the
/// next layer (or the handler itself if this is the innermost interceptor).
pub struct NextHandler {
    inner: Arc<dyn Fn(RequestContext) -> BoxFuture<'static, Response> + Send + Sync>,
}

impl NextHandler {
    #[doc(hidden)]
    pub fn new<F>(f: F) -> Self
    where
        F: Fn(RequestContext) -> BoxFuture<'static, Response> + Send + Sync + 'static,
    {
        Self { inner: Arc::new(f) }
    }

    #[inline]
    pub fn run(&self, ctx: RequestContext) -> BoxFuture<'static, Response> {
        (self.inner)(ctx)
    }

    /// Internal: produce a cheap clone for passing through composed chains
    /// without forcing the public API into `Clone`.
    #[doc(hidden)]
    #[inline]
    pub fn __clone_for_chain(&self) -> Self {
        Self {
            inner: Arc::clone(&self.inner),
        }
    }
}

/// One layer of the aspect chain.
///
/// `around` *must* call `next.run(ctx)` (typically once) to invoke the inner
/// layer. Returning a response without calling `next` short-circuits the
/// chain — useful for caches and rate limits.
pub trait Interceptor: Send + Sync + 'static {
    fn around(
        &'static self,
        ctx: RequestContext,
        next: NextHandler,
    ) -> BoxFuture<'static, Response>;
}

/// Compose a chain of interceptors around a terminal handler. Declaration
/// order matters: the first interceptor in `globals` is the outermost layer.
/// Used by the launch path to wrap plugin-registered global interceptors
/// around every mounted route (macro and plugin alike).
pub(crate) fn compose_chain(
    globals: &'static [&'static dyn Interceptor],
    terminal: Arc<dyn Fn(RequestContext) -> BoxFuture<'static, Response> + Send + Sync>,
) -> Arc<dyn Fn(RequestContext) -> BoxFuture<'static, Response> + Send + Sync> {
    let mut chain = terminal;
    for ic in globals.iter().rev() {
        let inner = chain;
        let ic: &'static dyn Interceptor = *ic;
        chain = Arc::new(move |ctx| {
            let next = NextHandler {
                inner: Arc::clone(&inner),
            };
            ic.around(ctx, next)
        });
    }
    chain
}

// ─── Stock interceptors ─────────────────────────────────────────────────

/// Log method + path + status + latency on each request.
pub struct LatencyLog;
impl Interceptor for LatencyLog {
    fn around(
        &'static self,
        ctx: RequestContext,
        next: NextHandler,
    ) -> BoxFuture<'static, Response> {
        Box::pin(async move {
            let method = ctx.method().clone();
            let path = ctx.path().to_owned();
            let started = std::time::Instant::now();
            let resp = next.run(ctx).await;
            let micros = started.elapsed().as_micros();
            println!(
                "[arcly] {method} {path} -> {} in {micros}\u{00B5}s",
                resp.status().as_u16()
            );
            resp
        })
    }
}

/// Emit a structured log line for every request via the `tracing` crate.
///
/// Fields: `trace_id`, `span_id`, `method`, `path`, `status`, `duration_ms`.
/// Level is chosen by status: `error` (5xx), `warn` (4xx), `info` (2xx/3xx).
///
/// When `ArclyObservabilityPlugin` is active, these log lines are formatted as
/// JSON and emitted to stdout. Without the plugin, they use `tracing`'s default
/// formatter. For full OTLP traces + Prometheus metrics use `TraceInterceptor`.
pub struct TelemetryLog;

impl Interceptor for TelemetryLog {
    fn around(
        &'static self,
        ctx: RequestContext,
        next: NextHandler,
    ) -> BoxFuture<'static, Response> {
        Box::pin(async move {
            use crate::web::context::{hex_encode_16, hex_encode_8};
            let method = ctx.method().to_string();
            let path = ctx.path().to_owned();
            let trace_id = hex_encode_16(&ctx.trace_id());
            let span_id = hex_encode_8(&ctx.span_id());
            let started = std::time::Instant::now();

            let resp = next.run(ctx).await;

            let status = resp.status().as_u16();
            let duration_ms = started.elapsed().as_millis() as u64;

            if status >= 500 {
                tracing::error!(
                    trace_id,
                    span_id,
                    method,
                    path,
                    status,
                    duration_ms,
                    "request failed"
                );
            } else if status >= 400 {
                tracing::warn!(
                    trace_id,
                    span_id,
                    method,
                    path,
                    status,
                    duration_ms,
                    "request error"
                );
            } else {
                tracing::info!(
                    trace_id,
                    span_id,
                    method,
                    path,
                    status,
                    duration_ms,
                    "request completed"
                );
            }

            resp
        })
    }
}

/// Full-stack observability interceptor: structured log + OTLP span + Prometheus.
///
/// Use this instead of `TelemetryLog` when `ArclyObservabilityPlugin` is active.
/// Apply with `#[UseInterceptors(TraceInterceptor)]` on a handler or controller.
///
/// What it does per request:
/// 1. Creates an OpenTelemetry server span as a child of the incoming W3C parent.
/// 2. Records HTTP semantic-convention attributes on the span.
/// 3. Records `http_requests_total` counter + `http_request_duration_seconds`
///    histogram in the Prometheus registry.
/// 4. Emits a `tracing::info/warn/error!` log with all correlation fields.
pub struct TraceInterceptor;

/// Calls `span.end()` on drop so the span is always finalised — even if the
/// handler panics or the interceptor future is cancelled.
struct SpanGuard<S: opentelemetry::trace::Span>(S);

impl<S: opentelemetry::trace::Span> Drop for SpanGuard<S> {
    fn drop(&mut self) {
        self.0.end();
    }
}

impl Interceptor for TraceInterceptor {
    fn around(
        &'static self,
        ctx: RequestContext,
        next: NextHandler,
    ) -> BoxFuture<'static, Response> {
        Box::pin(async move {
            use crate::observability::metrics::record_request;
            use crate::observability::otel::parent_context_from;
            use crate::web::context::{hex_encode_16, hex_encode_8};
            use opentelemetry::trace::{Span, SpanKind, Status, Tracer};
            use opentelemetry::KeyValue;

            let method = ctx.method().to_string();
            let route = ctx.route().to_owned();
            let trace_id = hex_encode_16(&ctx.trace_id());
            let span_id = hex_encode_8(&ctx.span_id());
            let started = std::time::Instant::now();

            // ── 1. Start OTEL child span ────────────────────────────────────
            let parent_cx = parent_context_from(&ctx);
            let tracer = opentelemetry::global::tracer("arcly-http");
            let span_name = format!("{method} {route}");
            let mut span = SpanGuard(
                tracer
                    .span_builder(span_name)
                    .with_kind(SpanKind::Server)
                    .start_with_context(&tracer, &parent_cx),
            );
            span.0
                .set_attribute(KeyValue::new("http.request.method", method.clone()));
            span.0
                .set_attribute(KeyValue::new("http.route", route.clone()));

            // ── 2. Execute the handler ──────────────────────────────────────
            let resp = next.run(ctx).await;

            // ── 3. Finalise span (SpanGuard::drop calls span.end()) ─────────
            let status = resp.status().as_u16();
            let elapsed = started.elapsed();
            let duration_ms = elapsed.as_millis() as u64;

            span.0
                .set_attribute(KeyValue::new("http.response.status_code", status as i64));
            if status >= 500 {
                span.0.set_status(Status::error("server error"));
            }

            // ── 4. Prometheus metrics ───────────────────────────────────────
            record_request(&route, &method, status, elapsed.as_secs_f64());

            // ── 5. Structured log ───────────────────────────────────────────
            if status >= 500 {
                tracing::error!(
                    trace_id,
                    span_id,
                    method,
                    route,
                    status,
                    duration_ms,
                    "request failed"
                );
            } else if status >= 400 {
                tracing::warn!(
                    trace_id,
                    span_id,
                    method,
                    route,
                    status,
                    duration_ms,
                    "request error"
                );
            } else {
                tracing::info!(
                    trace_id,
                    span_id,
                    method,
                    route,
                    status,
                    duration_ms,
                    "request completed"
                );
            }

            resp
        })
    }
}

/// Wrap successful JSON responses in `{ "data": ... }`. Errors pass through
/// untouched so RFC 7807 ProblemDetails reaches the client verbatim.
pub struct EnvelopeResponse;
impl Interceptor for EnvelopeResponse {
    fn around(
        &'static self,
        ctx: RequestContext,
        next: NextHandler,
    ) -> BoxFuture<'static, Response> {
        Box::pin(async move {
            use axum::body::to_bytes;
            let resp = next.run(ctx).await;
            if !resp.status().is_success() {
                return resp;
            }
            let (parts, body) = resp.into_parts();
            let bytes = to_bytes(body, usize::MAX).await.unwrap_or_default();
            let inner: serde_json::Value =
                serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
            let wrapped = serde_json::json!({ "data": inner });
            let body = axum::body::Body::from(serde_json::to_vec(&wrapped).unwrap());
            Response::from_parts(parts, body)
        })
    }
}