Skip to main content

modkit/
context.rs

1use serde::de::DeserializeOwned;
2use std::sync::Arc;
3use tokio_util::sync::CancellationToken;
4use uuid::Uuid;
5
6// Import configuration types from the config module
7use crate::config::{ConfigError, ConfigProvider, module_config_or_default};
8
9// Note: runtime-dependent features are conditionally compiled
10
11// DB types are available only when feature "db" is enabled.
12// We keep local aliases so the rest of this file can compile without importing `modkit_db`.
13#[cfg(feature = "db")]
14pub(crate) type DbManager = modkit_db::DbManager;
15#[cfg(feature = "db")]
16pub(crate) type DbProvider = modkit_db::DBProvider<modkit_db::DbError>;
17
18// Stub types for no-db builds (never exposed; methods that would use them are cfg'd out).
19#[cfg(not(feature = "db"))]
20#[derive(Clone, Debug)]
21pub struct DbManager;
22#[cfg(not(feature = "db"))]
23#[derive(Clone, Debug)]
24pub struct DbProvider;
25
26#[derive(Clone)]
27#[must_use]
28pub struct ModuleCtx {
29    module_name: Arc<str>,
30    instance_id: Uuid,
31    config_provider: Arc<dyn ConfigProvider>,
32    client_hub: Arc<crate::client_hub::ClientHub>,
33    cancellation_token: CancellationToken,
34    db: Option<DbProvider>,
35}
36
37/// Builder for creating module-scoped contexts with resolved database handles.
38///
39/// This builder internally uses `DbManager` to resolve per-module `Db` instances
40/// at build time, ensuring `ModuleCtx` contains only the final, ready-to-use entrypoint.
41pub struct ModuleContextBuilder {
42    instance_id: Uuid,
43    config_provider: Arc<dyn ConfigProvider>,
44    client_hub: Arc<crate::client_hub::ClientHub>,
45    root_token: CancellationToken,
46    db_manager: Option<Arc<DbManager>>, // internal only, never exposed to modules
47}
48
49impl ModuleContextBuilder {
50    pub fn new(
51        instance_id: Uuid,
52        config_provider: Arc<dyn ConfigProvider>,
53        client_hub: Arc<crate::client_hub::ClientHub>,
54        root_token: CancellationToken,
55        db_manager: Option<Arc<DbManager>>,
56    ) -> Self {
57        Self {
58            instance_id,
59            config_provider,
60            client_hub,
61            root_token,
62            db_manager,
63        }
64    }
65
66    /// Returns the process-level instance ID.
67    #[must_use]
68    pub fn instance_id(&self) -> Uuid {
69        self.instance_id
70    }
71
72    /// Build a module-scoped context, resolving the `DbHandle` for the given module.
73    ///
74    /// # Errors
75    /// Returns an error if database resolution fails.
76    pub async fn for_module(&self, module_name: &str) -> anyhow::Result<ModuleCtx> {
77        let db: Option<DbProvider> = {
78            #[cfg(feature = "db")]
79            {
80                if let Some(mgr) = &self.db_manager {
81                    mgr.get(module_name).await?.map(modkit_db::DBProvider::new)
82                } else {
83                    None
84                }
85            }
86            #[cfg(not(feature = "db"))]
87            {
88                let _ = module_name; // avoid unused in no-db builds
89                None
90            }
91        };
92
93        Ok(ModuleCtx::new(
94            Arc::<str>::from(module_name),
95            self.instance_id,
96            self.config_provider.clone(),
97            self.client_hub.clone(),
98            self.root_token.child_token(),
99            db,
100        ))
101    }
102}
103
104impl ModuleCtx {
105    /// Create a new module-scoped context with all required fields.
106    pub fn new(
107        module_name: impl Into<Arc<str>>,
108        instance_id: Uuid,
109        config_provider: Arc<dyn ConfigProvider>,
110        client_hub: Arc<crate::client_hub::ClientHub>,
111        cancellation_token: CancellationToken,
112        db: Option<DbProvider>,
113    ) -> Self {
114        Self {
115            module_name: module_name.into(),
116            instance_id,
117            config_provider,
118            client_hub,
119            cancellation_token,
120            db,
121        }
122    }
123
124    // ---- public read-only API for modules ----
125
126    #[inline]
127    #[must_use]
128    pub fn module_name(&self) -> &str {
129        &self.module_name
130    }
131
132    /// Returns the process-level instance ID.
133    ///
134    /// This is a unique identifier for this process instance, shared by all modules
135    /// in the same process. It is generated once at bootstrap.
136    #[inline]
137    #[must_use]
138    pub fn instance_id(&self) -> Uuid {
139        self.instance_id
140    }
141
142    #[inline]
143    #[must_use]
144    pub fn config_provider(&self) -> &dyn ConfigProvider {
145        &*self.config_provider
146    }
147
148    /// Get the `ClientHub` for dependency resolution.
149    #[inline]
150    #[must_use]
151    pub fn client_hub(&self) -> Arc<crate::client_hub::ClientHub> {
152        self.client_hub.clone()
153    }
154
155    #[inline]
156    #[must_use]
157    pub fn cancellation_token(&self) -> &CancellationToken {
158        &self.cancellation_token
159    }
160
161    /// Get a module-scoped DB entrypoint for secure database operations.
162    ///
163    /// Returns `None` if no database is configured for this module.
164    ///
165    /// # Security
166    ///
167    /// The returned `DBProvider<modkit_db::DbError>`:
168    /// - Is cheap to clone (shares an internal `Db`)
169    /// - Provides `conn()` for non-transactional access (fails inside tx via guard)
170    /// - Provides `transaction(..)` for transactional operations
171    ///
172    /// # Example
173    ///
174    /// ```ignore
175    /// let db = ctx.db().ok_or_else(|| anyhow!("no db"))?;
176    /// let conn = db.conn()?;
177    /// let user = svc.get_user(&conn, &scope, id).await?;
178    /// ```
179    #[must_use]
180    #[cfg(feature = "db")]
181    pub fn db(&self) -> Option<modkit_db::DBProvider<modkit_db::DbError>> {
182        self.db.clone()
183    }
184
185    /// Get a database handle, returning an error if not configured.
186    ///
187    /// This is a convenience method that combines `db()` with an error for
188    /// modules that require database access.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error if the database is not configured for this module.
193    ///
194    /// # Example
195    ///
196    /// ```ignore
197    /// let db = ctx.db_required()?;
198    /// let conn = db.conn()?;
199    /// let user = svc.get_user(&conn, &scope, id).await?;
200    /// ```
201    #[cfg(feature = "db")]
202    pub fn db_required(&self) -> anyhow::Result<modkit_db::DBProvider<modkit_db::DbError>> {
203        self.db().ok_or_else(|| {
204            anyhow::anyhow!(
205                "Database is not configured for module '{}'",
206                self.module_name
207            )
208        })
209    }
210
211    #[must_use]
212    pub fn current_module(&self) -> Option<&str> {
213        Some(&self.module_name)
214    }
215
216    /// Deserialize the module's config section into T, or use defaults if missing.
217    ///
218    /// This method uses lenient configuration loading: if the module is not present in config,
219    /// has no config section, or the module entry is not an object, it returns `T::default()`.
220    /// This allows modules to exist without configuration sections in the main config file.
221    ///
222    /// It extracts the 'config' field from: `modules.<name> = { database: ..., config: ... }`
223    ///
224    /// # Example
225    ///
226    /// ```rust,ignore
227    /// #[derive(serde::Deserialize, Default)]
228    /// struct MyConfig {
229    ///     api_key: String,
230    ///     timeout_ms: u64,
231    /// }
232    ///
233    /// let config: MyConfig = ctx.config()?;
234    /// ```
235    ///
236    /// # Errors
237    /// Returns `ConfigError` if deserialization fails.
238    pub fn config<T: DeserializeOwned + Default>(&self) -> Result<T, ConfigError> {
239        module_config_or_default(self.config_provider.as_ref(), &self.module_name)
240    }
241
242    /// Get the raw JSON value of the module's config section.
243    /// Returns the 'config' field from: modules.<name> = { database: ..., config: ... }
244    #[must_use]
245    pub fn raw_config(&self) -> &serde_json::Value {
246        use std::sync::LazyLock;
247
248        static EMPTY: LazyLock<serde_json::Value> =
249            LazyLock::new(|| serde_json::Value::Object(serde_json::Map::new()));
250
251        if let Some(module_raw) = self.config_provider.get_module_config(&self.module_name) {
252            // Try new structure first: modules.<name> = { database: ..., config: ... }
253            if let Some(obj) = module_raw.as_object()
254                && let Some(config_section) = obj.get("config")
255            {
256                return config_section;
257            }
258        }
259        &EMPTY
260    }
261
262    /// Create a derivative context with the same references but no DB handle.
263    /// Useful for modules that don't require database access.
264    pub fn without_db(&self) -> ModuleCtx {
265        ModuleCtx {
266            module_name: self.module_name.clone(),
267            instance_id: self.instance_id,
268            config_provider: self.config_provider.clone(),
269            client_hub: self.client_hub.clone(),
270            cancellation_token: self.cancellation_token.clone(),
271            db: None,
272        }
273    }
274}
275
276#[cfg(test)]
277#[cfg_attr(coverage_nightly, coverage(off))]
278mod tests {
279    use super::*;
280    use serde::Deserialize;
281    use serde_json::json;
282    use std::collections::HashMap;
283
284    #[derive(Debug, PartialEq, Deserialize, Default)]
285    struct TestConfig {
286        #[serde(default)]
287        api_key: String,
288        #[serde(default)]
289        timeout_ms: u64,
290        #[serde(default)]
291        enabled: bool,
292    }
293
294    struct MockConfigProvider {
295        modules: HashMap<String, serde_json::Value>,
296    }
297
298    impl MockConfigProvider {
299        fn new() -> Self {
300            let mut modules = HashMap::new();
301
302            // Valid module config
303            modules.insert(
304                "test_module".to_owned(),
305                json!({
306                    "database": {
307                        "url": "postgres://localhost/test"
308                    },
309                    "config": {
310                        "api_key": "secret123",
311                        "timeout_ms": 5000,
312                        "enabled": true
313                    }
314                }),
315            );
316
317            Self { modules }
318        }
319    }
320
321    impl ConfigProvider for MockConfigProvider {
322        fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value> {
323            self.modules.get(module_name)
324        }
325    }
326
327    #[test]
328    fn test_module_ctx_config_with_valid_config() {
329        let provider = Arc::new(MockConfigProvider::new());
330        let ctx = ModuleCtx::new(
331            "test_module",
332            Uuid::new_v4(),
333            provider,
334            Arc::new(crate::client_hub::ClientHub::default()),
335            CancellationToken::new(),
336            None,
337        );
338
339        let result: Result<TestConfig, ConfigError> = ctx.config();
340        assert!(result.is_ok());
341
342        let config = result.unwrap();
343        assert_eq!(config.api_key, "secret123");
344        assert_eq!(config.timeout_ms, 5000);
345        assert!(config.enabled);
346    }
347
348    #[test]
349    fn test_module_ctx_config_returns_default_for_missing_module() {
350        let provider = Arc::new(MockConfigProvider::new());
351        let ctx = ModuleCtx::new(
352            "nonexistent_module",
353            Uuid::new_v4(),
354            provider,
355            Arc::new(crate::client_hub::ClientHub::default()),
356            CancellationToken::new(),
357            None,
358        );
359
360        let result: Result<TestConfig, ConfigError> = ctx.config();
361        assert!(result.is_ok());
362
363        let config = result.unwrap();
364        assert_eq!(config, TestConfig::default());
365    }
366
367    #[test]
368    fn test_module_ctx_instance_id() {
369        let provider = Arc::new(MockConfigProvider::new());
370        let instance_id = Uuid::new_v4();
371        let ctx = ModuleCtx::new(
372            "test_module",
373            instance_id,
374            provider,
375            Arc::new(crate::client_hub::ClientHub::default()),
376            CancellationToken::new(),
377            None,
378        );
379
380        assert_eq!(ctx.instance_id(), instance_id);
381    }
382}