Skip to main content

arcly_http/web/
boundary.rs

1//! Boundary adapter: converts an axum `Request` into the opaque
2//! `RequestContext` and dispatches to the macro-generated thunk.
3//!
4//! This is the *only* place in the framework where axum extraction primitives
5//! are touched outside of the launch path. `assemble_context` is the single
6//! request→context pipeline — plugin routes (`web::plugin_routes`) reuse it,
7//! so body limits, trace propagation, and credential extraction can never
8//! drift between entry points.
9
10use axum::body::Body;
11use axum::extract::{RawPathParams, Request, State};
12use axum::http::request::Parts;
13use axum::response::Response;
14use axum::routing::{on, MethodFilter, MethodRouter};
15use smallvec::SmallVec;
16use smol_str::SmolStr;
17
18use crate::auth::extract::extract_auth;
19use crate::core::engine::{FrozenDiContainer, RouteDescriptor, RouteSpec};
20use crate::observability::lean_telemetry::on_request_start;
21use crate::observability::propagation::extract_trace_context;
22use crate::web::context::RequestContext;
23
24/// Maximum request body size — protects against memory exhaustion.
25const MAX_BODY: usize = 8 * 1024 * 1024;
26
27/// RAII guard that tracks the in-flight request count in the Prometheus gauge.
28/// Pairs with `lean_telemetry::RequestGuard` which tracks the same count in the
29/// raw atomic used by health endpoints.
30pub(crate) struct InFlightGuard;
31impl InFlightGuard {
32    #[inline]
33    pub(crate) fn new() -> Self {
34        metrics::gauge!("http_requests_in_flight").increment(1.0);
35        Self
36    }
37}
38impl Drop for InFlightGuard {
39    #[inline]
40    fn drop(&mut self) {
41        metrics::gauge!("http_requests_in_flight").decrement(1.0);
42    }
43}
44
45/// The single request→`RequestContext` pipeline:
46/// body read (capped) → W3C trace propagation → credential extraction
47/// (Bearer / cookie / session) → context construction.
48///
49/// Every HTTP entry point — macro routes here, plugin routes in
50/// `web::plugin_routes` — goes through this function.
51pub(crate) async fn assemble_context(
52    parts: Parts,
53    body: Body,
54    params: SmallVec<[(SmolStr, SmolStr); 4]>,
55    container: &'static FrozenDiContainer,
56    route_pattern: &'static str,
57    route_spec: Option<&'static RouteSpec>,
58) -> RequestContext {
59    let bytes = axum::body::to_bytes(body, MAX_BODY)
60        .await
61        .unwrap_or_default();
62
63    let trace = extract_trace_context(&parts.headers);
64    let auth = extract_auth(&parts.headers, container).await;
65    // Frozen-map lookup — no I/O, no locks. None when no registry is provided.
66    let tenant = container
67        .try_get::<crate::web::tenant::TenantRegistry>()
68        .and_then(|tr| tr.resolve(&parts.headers));
69
70    RequestContext::__new(
71        parts.method,
72        SmolStr::new(parts.uri.path()),
73        SmolStr::new(parts.uri.query().unwrap_or("")),
74        params,
75        parts.headers,
76        bytes,
77        trace.trace_id,
78        trace.span_id,
79        trace.parent_span_id,
80        container,
81        route_pattern,
82        route_spec,
83    )
84    .__with_claims(auth.claims)
85    .__with_session(auth.session)
86    .__with_tenant(tenant)
87}
88
89/// Wrap a macro-generated route descriptor in an axum `MethodRouter`.
90/// The HTTP method filter is baked in so the caller can drop the result
91/// straight into `Router::route`.
92pub fn adapt(rt: &'static RouteDescriptor) -> MethodRouter<&'static FrozenDiContainer> {
93    let filter = MethodFilter::try_from(axum::http::Method::from(rt.method))
94        .expect("Arcly: unsupported HTTP method");
95
96    let handler = move |State(container): State<&'static FrozenDiContainer>,
97                        raw_params: RawPathParams,
98                        req: Request| async move {
99        let params: SmallVec<[(SmolStr, SmolStr); 4]> = raw_params
100            .iter()
101            .map(|(k, v)| (SmolStr::new(k), SmolStr::new(v)))
102            .collect();
103
104        let (parts, body) = req.into_parts();
105        let ctx = assemble_context(parts, body, params, container, rt.path, Some(rt.spec)).await;
106
107        let _guard = on_request_start();
108        let _in_flight = InFlightGuard::new();
109        let resp: Response = (rt.handler)(ctx).await;
110        drop(_guard);
111
112        let (mut p, b) = resp.into_parts();
113        // RFC 8594: announce deprecation + sunset date on versioned routes
114        // marked #[Deprecated(sunset = "…")].
115        if !rt.spec.sunset.is_empty() {
116            p.headers
117                .insert("deprecation", axum::http::HeaderValue::from_static("true"));
118            if let Ok(v) = axum::http::HeaderValue::from_str(rt.spec.sunset) {
119                p.headers.insert("sunset", v);
120            }
121        }
122        Response::from_parts(p, Body::new(b))
123    };
124
125    on(filter, handler)
126}