Skip to main content

nestrs_core/
als.rs

1//! Async-local-storage runtime support for the `#[als]` proc-macro.
2//!
3//! Async-local storage (ALS) is the NestJS `cls-hooked` / `nestjs-cls`
4//! pattern: a value that propagates through every `.await` on the
5//! current task without being threaded through every function
6//! signature. In Rust the underlying primitive is `tokio::task_local!`,
7//! and `nestrs-macros::als` generates the per-type static cell, the
8//! `with_*` / `current_*` helpers, and the `FromRequestParts` impl
9//! around it. This module is the small runtime surface the macro
10//! relies on:
11//!
12//! - [`AlsError`] — the rejection type the generated extractor
13//!   returns when middleware forgot to install the value.
14//! - [`task_local!`] — re-export of [`tokio::task_local!`] so the
15//!   `#[als]` macro's emitted code has a stable path
16//!   (`::nestrs_core::als::task_local!`) without requiring `tokio`
17//!   as a direct user dependency.
18//! - `async_trait` — re-export of `async_trait::async_trait` so the
19//!   generated `FromRequestParts` impl can use axum 0.7's
20//!   `async_trait`-based extractor trait without requiring
21//!   `async-trait` as a direct user dependency.
22//! - [`AlsCell`] — type alias for the `tokio::task_local!` cell the
23//!   runtime helper wraps, so user code can name it without a
24//!   tokio-internal type path.
25//! - [`AlsContext`] — a small typed wrapper around a `tokio::task_local!`
26//!   cell for users who don't want to use the proc-macro.
27//!
28//! That's intentionally a thin layer. The macro is the primary
29//! surface; this module just gives its generated code something
30//! stable to point at so callers can `match` on the failure mode.
31
32/// Rejection type returned by an `#[als]`-generated extractor when
33/// the value was never installed on the current task.
34///
35/// This almost always indicates a wiring bug — middleware that should
36/// have set the ALS via `with_<name>(value, future)` didn't, so the
37/// handler runs without the per-request context it expects. Surface
38/// it as a 500 (or your framework's "internal error") so it doesn't
39/// masquerade as a 400 / 404 from a real request shape.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum AlsError {
42    /// No value for the requested ALS key is installed on the current
43    /// task. Either the request never entered a `with_*` scope, or a
44    /// `tokio::spawn` boundary stripped the task-local — see
45    /// [`crate::spawn_with_request_scope`] for the request-scoped
46    /// analogue.
47    NotSet,
48}
49
50impl std::fmt::Display for AlsError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            AlsError::NotSet => f.write_str(
54                "async-local-storage value is not installed on this task \
55                 — middleware should set it via `with_<name>(value, future).await`",
56            ),
57        }
58    }
59}
60
61impl std::error::Error for AlsError {}
62
63impl axum::response::IntoResponse for AlsError {
64    fn into_response(self) -> axum::response::Response {
65        (
66            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
67            self.to_string(),
68        )
69            .into_response()
70    }
71}
72
73/// Re-export of [`tokio::task_local!`] so the `#[als]` macro's emitted
74/// code can reference the primitive via a stable path
75/// (`::nestrs_core::als::task_local!`) without requiring `tokio` to be
76/// a direct dependency of the user's crate.
77pub use tokio::task_local;
78
79/// Re-export of [`async_trait::async_trait`] so the `#[als]` macro's
80/// generated `FromRequestParts` impl can use axum 0.7's
81/// `async_trait`-based extractor trait without requiring `async-trait`
82/// as a direct user dependency.
83pub use async_trait::async_trait;
84
85/// Type alias for the `tokio::task_local!` cell [`AlsContext`] wraps.
86/// Exists so helper users can name the cell without a tokio-internal
87/// type path.
88pub type AlsCell<T> = tokio::task::LocalKey<std::cell::RefCell<Option<T>>>;
89
90/// Manual async-local-storage helper for users who don't want to use
91/// the `#[als]` proc-macro.
92///
93/// Wraps a `tokio::task_local!` cell with a small ergonomic surface:
94/// install a value for the duration of a future, read the current
95/// value from anywhere on the task, scope-guard that automatically
96/// drops. The macro is sugar over this trait — pick whichever fits
97/// your codebase.
98///
99/// # Example
100///
101/// ```ignore
102/// use nestrs_core::als::{AlsContext, AlsError};
103///
104/// tokio::task_local! {
105///     static MY_CTX: std::cell::RefCell<Option<String>>;
106/// }
107///
108/// async fn handler() -> Result<String, AlsError> {
109///     AlsContext::new(&MY_CTX).current().ok_or(AlsError::NotSet)
110/// }
111/// ```
112pub struct AlsContext<T>
113where
114    T: Clone + Send + Sync + 'static,
115{
116    cell: &'static AlsCell<T>,
117}
118
119impl<T> AlsContext<T>
120where
121    T: Clone + Send + Sync + 'static,
122{
123    /// Bind a typed view to a `tokio::task_local!` cell.
124    pub fn new(cell: &'static AlsCell<T>) -> Self {
125        Self { cell }
126    }
127
128    /// Run `future` with `value` installed in the cell. After `future`
129    /// completes (or panics), the cell is restored to its prior state.
130    pub async fn with<F, R>(&self, value: T, future: F) -> R
131    where
132        F: std::future::Future<Output = R>,
133    {
134        // `scope` runs `future` with the cell set to a fresh `Some(value)`.
135        // The drop semantics of `task_local` restore the prior state.
136        self.cell
137            .scope(std::cell::RefCell::new(Some(value)), future)
138            .await
139    }
140
141    /// Read the current value. Returns `None` outside any `with` scope.
142    pub fn current(&self) -> Option<T> {
143        self.cell
144            .try_with(|cell| cell.borrow().clone())
145            .ok()
146            .flatten()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    tokio::task_local! {
155        static CTX: std::cell::RefCell<Option<String>>;
156    }
157
158    #[tokio::test]
159    async fn als_context_with_installs_value_for_future_duration() {
160        let als = AlsContext::new(&CTX);
161        assert!(als.current().is_none(), "empty before scope");
162
163        let inside = als
164            .with(String::from("hello"), async { als.current() })
165            .await;
166        assert_eq!(inside.as_deref(), Some("hello"));
167
168        assert!(als.current().is_none(), "empty after scope ends");
169    }
170
171    #[tokio::test]
172    async fn als_context_with_restores_after_panic() {
173        let als = AlsContext::new(&CTX);
174        // Install once normally, then install a panicking future inside.
175        // The outer cell must be restored.
176        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
177            let rt = tokio::runtime::Builder::new_current_thread()
178                .build()
179                .unwrap();
180            rt.block_on(async {
181                let _ = als
182                    .with(String::from("outer"), async {
183                        // Inner scope swallows the panic via `catch_unwind`.
184                        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
185                            let rt = tokio::runtime::Builder::new_current_thread()
186                                .build()
187                                .unwrap();
188                            rt.block_on(async {
189                                let _ = als
190                                    .with(String::from("inner"), async {
191                                        panic!("simulated panic");
192                                    })
193                                    .await;
194                            });
195                        }));
196                        assert!(result.is_err(), "inner panic propagated");
197                        als.current()
198                    })
199                    .await;
200            });
201        }));
202        assert!(als.current().is_none(), "outer scope restored after panic");
203    }
204
205    #[test]
206    fn als_error_display_is_actionable() {
207        let err = AlsError::NotSet;
208        let msg = format!("{err}");
209        assert!(
210            msg.contains("not installed"),
211            "the error message should hint at the fix: {msg}"
212        );
213    }
214}