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}