Skip to main content

data_connector/
context.rs

1//! Per-request context and hook data bridge for storage hooks.
2//!
3//! Uses tokio task-local storage so that per-request data (e.g. tenant ID,
4//! conversation store ID from HTTP headers) can reach hooks without threading
5//! it through every storage method signature.
6//!
7//! Also provides a task-local [`ExtraColumns`] bridge so that hooked storage
8//! wrappers can pass hook-provided extra column values to backends without
9//! changing any storage trait signatures.
10
11use std::collections::HashMap;
12
13use crate::hooks::ExtraColumns;
14
15// ────────────────────────────────────────────────────────────────────────────
16// Types
17// ────────────────────────────────────────────────────────────────────────────
18
19/// Per-request context passed to storage hooks.
20///
21/// Populated by the gateway layer (from HTTP headers, middleware output, etc.)
22/// before each storage operation. Hooks access it via [`current_request_context`].
23#[derive(Debug, Clone, Default)]
24pub struct RequestContext {
25    data: HashMap<String, String>,
26}
27
28impl RequestContext {
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    /// Create a context pre-populated with the given key-value pairs.
34    pub fn with_data(data: HashMap<String, String>) -> Self {
35        Self { data }
36    }
37
38    /// Get a value by key.
39    pub fn get(&self, key: &str) -> Option<&str> {
40        self.data.get(key).map(String::as_str)
41    }
42
43    /// Set a key-value pair.
44    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
45        self.data.insert(key.into(), value.into());
46    }
47
48    /// Borrow the underlying data map.
49    pub fn data(&self) -> &HashMap<String, String> {
50        &self.data
51    }
52}
53
54// ────────────────────────────────────────────────────────────────────────────
55// Task-local storage
56// ────────────────────────────────────────────────────────────────────────────
57
58tokio::task_local! {
59    static REQUEST_CONTEXT: RequestContext;
60    static HOOK_EXTRA_COLUMNS: ExtraColumns;
61}
62
63/// Run an async block with the given [`RequestContext`] available via task-local.
64///
65/// Called by the gateway HTTP handler before invoking storage operations.
66/// The context is available inside `f` via [`current_request_context`].
67pub async fn with_request_context<F, T>(ctx: RequestContext, f: F) -> T
68where
69    F: std::future::Future<Output = T>,
70{
71    REQUEST_CONTEXT.scope(ctx, f).await
72}
73
74/// Read the current request context, if one is set for this task.
75///
76/// Returns `None` when called outside a [`with_request_context`] scope.
77pub fn current_request_context() -> Option<RequestContext> {
78    REQUEST_CONTEXT.try_with(|ctx| ctx.clone()).ok()
79}
80
81/// Run an async block with the given [`ExtraColumns`] available via task-local.
82///
83/// Called by [`HookedStorage`](crate::hooked) wrappers to make hook-provided
84/// extra column values visible to the inner backend during write operations.
85pub async fn with_extra_columns<F, T>(extra: ExtraColumns, f: F) -> T
86where
87    F: std::future::Future<Output = T>,
88{
89    HOOK_EXTRA_COLUMNS.scope(extra, f).await
90}
91
92/// Read the current hook extra columns, if set for this task.
93///
94/// Backends call this during INSERT to pick up values provided by hooks.
95/// Returns `None` when called outside a [`with_extra_columns`] scope.
96pub fn current_extra_columns() -> Option<ExtraColumns> {
97    HOOK_EXTRA_COLUMNS.try_with(|ec| ec.clone()).ok()
98}
99
100// ────────────────────────────────────────────────────────────────────────────
101// Tests
102// ────────────────────────────────────────────────────────────────────────────
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn request_context_new_is_empty() {
110        let ctx = RequestContext::new();
111        assert!(ctx.data().is_empty());
112        assert!(ctx.get("anything").is_none());
113    }
114
115    #[test]
116    fn request_context_set_and_get() {
117        let mut ctx = RequestContext::new();
118        ctx.set("tenant_id", "abc");
119        assert_eq!(ctx.get("tenant_id"), Some("abc"));
120        assert!(ctx.get("missing").is_none());
121    }
122
123    #[test]
124    fn request_context_with_data() {
125        let mut data = HashMap::new();
126        data.insert("key".to_string(), "value".to_string());
127        let ctx = RequestContext::with_data(data);
128        assert_eq!(ctx.get("key"), Some("value"));
129    }
130
131    #[tokio::test]
132    async fn current_request_context_returns_none_outside_scope() {
133        assert!(current_request_context().is_none());
134    }
135
136    #[tokio::test]
137    async fn with_request_context_makes_context_available() {
138        let mut ctx = RequestContext::new();
139        ctx.set("store_id", "123");
140
141        let result = with_request_context(ctx, async {
142            let inner = current_request_context().expect("should be set");
143            inner.get("store_id").unwrap().to_string()
144        })
145        .await;
146
147        assert_eq!(result, "123");
148    }
149
150    #[tokio::test]
151    async fn context_not_available_after_scope_exits() {
152        let ctx = RequestContext::new();
153        with_request_context(ctx, async {}).await;
154        assert!(current_request_context().is_none());
155    }
156
157    // ── ExtraColumns task-local ──────────────────────────────────────────
158
159    #[tokio::test]
160    async fn extra_columns_returns_none_outside_scope() {
161        assert!(current_extra_columns().is_none());
162    }
163
164    #[tokio::test]
165    async fn extra_columns_available_inside_scope() {
166        let mut extra = ExtraColumns::new();
167        extra.insert(
168            "tenant_id".to_string(),
169            serde_json::Value::String("t-123".to_string()),
170        );
171
172        let result = with_extra_columns(extra, async {
173            let ec = current_extra_columns().expect("should be set");
174            ec.get("tenant_id").unwrap().as_str().unwrap().to_string()
175        })
176        .await;
177
178        assert_eq!(result, "t-123");
179    }
180
181    #[tokio::test]
182    async fn extra_columns_not_leaked_after_scope() {
183        let extra = ExtraColumns::new();
184        with_extra_columns(extra, async {}).await;
185        assert!(current_extra_columns().is_none());
186    }
187}