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    /// Deserialize the module's config section into T, or use defaults if missing.
212    ///
213    /// This method uses lenient configuration loading: if the module is not present in config,
214    /// has no config section, or the module entry is not an object, it returns `T::default()`.
215    /// This allows modules to exist without configuration sections in the main config file.
216    ///
217    /// It extracts the 'config' field from: `modules.<name> = { database: ..., config: ... }`
218    ///
219    /// # Example
220    ///
221    /// ```rust,ignore
222    /// #[derive(serde::Deserialize, Default)]
223    /// struct MyConfig {
224    ///     api_key: String,
225    ///     timeout_ms: u64,
226    /// }
227    ///
228    /// let config: MyConfig = ctx.config()?;
229    /// ```
230    ///
231    /// # Errors
232    /// Returns `ConfigError` if deserialization fails.
233    pub fn config<T: DeserializeOwned + Default>(&self) -> Result<T, ConfigError> {
234        module_config_or_default(self.config_provider.as_ref(), &self.module_name)
235    }
236
237    /// Like [`config()`](Self::config), but additionally expands `${VAR}` placeholders
238    /// in fields marked with `#[expand_vars]` (requires `#[derive(ExpandVars)]` on the config
239    /// struct).
240    ///
241    /// Modules that do not need environment variable expansion should use [`config()`](Self::config).
242    ///
243    /// # Example
244    ///
245    /// ```rust,ignore
246    /// #[derive(serde::Deserialize, Default, ExpandVars)]
247    /// struct MyConfig {
248    ///     #[expand_vars]
249    ///     api_key: String,
250    ///     timeout_ms: u64,
251    /// }
252    ///
253    /// let config: MyConfig = ctx.config_expanded()?;
254    /// ```
255    ///
256    /// # Errors
257    /// Returns `ConfigError` if deserialization fails or if environment variable expansion fails.
258    pub fn config_expanded<T>(&self) -> Result<T, ConfigError>
259    where
260        T: DeserializeOwned + Default + crate::var_expand::ExpandVars,
261    {
262        let mut cfg: T = self.config()?;
263        cfg.expand_vars().map_err(|e| ConfigError::VarExpand {
264            module: self.module_name.to_string(),
265            source: e,
266        })?;
267        Ok(cfg)
268    }
269
270    /// Get the raw JSON value of the module's config section.
271    /// Returns the 'config' field from: modules.<name> = { database: ..., config: ... }
272    #[must_use]
273    pub fn raw_config(&self) -> &serde_json::Value {
274        use std::sync::LazyLock;
275
276        static EMPTY: LazyLock<serde_json::Value> =
277            LazyLock::new(|| serde_json::Value::Object(serde_json::Map::new()));
278
279        if let Some(module_raw) = self.config_provider.get_module_config(&self.module_name) {
280            // Try new structure first: modules.<name> = { database: ..., config: ... }
281            if let Some(obj) = module_raw.as_object()
282                && let Some(config_section) = obj.get("config")
283            {
284                return config_section;
285            }
286        }
287        &EMPTY
288    }
289
290    /// Create a derivative context with the same references but no DB handle.
291    /// Useful for modules that don't require database access.
292    pub fn without_db(&self) -> ModuleCtx {
293        ModuleCtx {
294            module_name: self.module_name.clone(),
295            instance_id: self.instance_id,
296            config_provider: self.config_provider.clone(),
297            client_hub: self.client_hub.clone(),
298            cancellation_token: self.cancellation_token.clone(),
299            db: None,
300        }
301    }
302}
303
304#[cfg(test)]
305#[cfg_attr(coverage_nightly, coverage(off))]
306mod tests {
307    use super::*;
308    use serde::Deserialize;
309    use serde_json::json;
310    use std::collections::HashMap;
311
312    #[derive(Debug, PartialEq, Deserialize, Default)]
313    struct TestConfig {
314        #[serde(default)]
315        api_key: String,
316        #[serde(default)]
317        timeout_ms: u64,
318        #[serde(default)]
319        enabled: bool,
320    }
321
322    struct MockConfigProvider {
323        modules: HashMap<String, serde_json::Value>,
324    }
325
326    impl MockConfigProvider {
327        fn new() -> Self {
328            let mut modules = HashMap::new();
329
330            // Valid module config
331            modules.insert(
332                "test_module".to_owned(),
333                json!({
334                    "database": {
335                        "url": "postgres://localhost/test"
336                    },
337                    "config": {
338                        "api_key": "secret123",
339                        "timeout_ms": 5000,
340                        "enabled": true
341                    }
342                }),
343            );
344
345            Self { modules }
346        }
347    }
348
349    impl ConfigProvider for MockConfigProvider {
350        fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value> {
351            self.modules.get(module_name)
352        }
353    }
354
355    #[test]
356    fn test_module_ctx_config_with_valid_config() {
357        let provider = Arc::new(MockConfigProvider::new());
358        let ctx = ModuleCtx::new(
359            "test_module",
360            Uuid::new_v4(),
361            provider,
362            Arc::new(crate::client_hub::ClientHub::default()),
363            CancellationToken::new(),
364            None,
365        );
366
367        let result: Result<TestConfig, ConfigError> = ctx.config();
368        assert!(result.is_ok());
369
370        let config = result.unwrap();
371        assert_eq!(config.api_key, "secret123");
372        assert_eq!(config.timeout_ms, 5000);
373        assert!(config.enabled);
374    }
375
376    #[test]
377    fn test_module_ctx_config_returns_default_for_missing_module() {
378        let provider = Arc::new(MockConfigProvider::new());
379        let ctx = ModuleCtx::new(
380            "nonexistent_module",
381            Uuid::new_v4(),
382            provider,
383            Arc::new(crate::client_hub::ClientHub::default()),
384            CancellationToken::new(),
385            None,
386        );
387
388        let result: Result<TestConfig, ConfigError> = ctx.config();
389        assert!(result.is_ok());
390
391        let config = result.unwrap();
392        assert_eq!(config, TestConfig::default());
393    }
394
395    #[test]
396    fn test_module_ctx_instance_id() {
397        let provider = Arc::new(MockConfigProvider::new());
398        let instance_id = Uuid::new_v4();
399        let ctx = ModuleCtx::new(
400            "test_module",
401            instance_id,
402            provider,
403            Arc::new(crate::client_hub::ClientHub::default()),
404            CancellationToken::new(),
405            None,
406        );
407
408        assert_eq!(ctx.instance_id(), instance_id);
409    }
410
411    // --- config_expanded tests ---
412
413    #[derive(Debug, PartialEq, Deserialize, Default, modkit_macros::ExpandVars)]
414    struct ExpandableConfig {
415        #[expand_vars]
416        #[serde(default)]
417        api_key: String,
418        #[expand_vars]
419        #[serde(default)]
420        endpoint: Option<String>,
421        #[serde(default)]
422        retries: u32,
423    }
424
425    fn make_ctx(module_name: &str, config_json: serde_json::Value) -> ModuleCtx {
426        let mut modules = HashMap::new();
427        modules.insert(module_name.to_owned(), config_json);
428        let provider = Arc::new(MockConfigProvider { modules });
429        ModuleCtx::new(
430            module_name,
431            Uuid::new_v4(),
432            provider,
433            Arc::new(crate::client_hub::ClientHub::default()),
434            CancellationToken::new(),
435            None,
436        )
437    }
438
439    #[test]
440    fn config_expanded_resolves_env_vars() {
441        let ctx = make_ctx(
442            "expand_mod",
443            json!({
444                "config": {
445                    "api_key": "${MODKIT_TEST_KEY}",
446                    "endpoint": "https://${MODKIT_TEST_HOST}/api",
447                    "retries": 3
448                }
449            }),
450        );
451
452        temp_env::with_vars(
453            [
454                ("MODKIT_TEST_KEY", Some("secret-42")),
455                ("MODKIT_TEST_HOST", Some("example.com")),
456            ],
457            || {
458                let cfg: ExpandableConfig = ctx.config_expanded().unwrap();
459                assert_eq!(cfg.api_key, "secret-42");
460                assert_eq!(cfg.endpoint.as_deref(), Some("https://example.com/api"));
461                assert_eq!(cfg.retries, 3);
462            },
463        );
464    }
465
466    #[test]
467    fn config_expanded_returns_error_on_missing_var() {
468        let ctx = make_ctx(
469            "expand_mod",
470            json!({
471                "config": {
472                    "api_key": "${MODKIT_TEST_MISSING_VAR_XYZ}"
473                }
474            }),
475        );
476
477        temp_env::with_vars([("MODKIT_TEST_MISSING_VAR_XYZ", None::<&str>)], || {
478            let err = ctx.config_expanded::<ExpandableConfig>().unwrap_err();
479            assert!(
480                matches!(err, ConfigError::VarExpand { ref module, .. } if module == "expand_mod"),
481                "expected EnvExpand error, got: {err:?}"
482            );
483        });
484    }
485
486    #[test]
487    fn config_expanded_skips_none_option_fields() {
488        let ctx = make_ctx(
489            "expand_mod",
490            json!({
491                "config": {
492                    "api_key": "literal-key",
493                    "retries": 5
494                }
495            }),
496        );
497
498        let cfg: ExpandableConfig = ctx.config_expanded().unwrap();
499        assert_eq!(cfg.api_key, "literal-key");
500        assert_eq!(cfg.endpoint, None);
501        assert_eq!(cfg.retries, 5);
502    }
503
504    #[test]
505    fn config_expanded_falls_back_to_default_when_missing() {
506        let ctx = make_ctx("missing_mod", json!({}));
507        let cfg: ExpandableConfig = ctx.config_expanded().unwrap();
508        assert_eq!(cfg, ExpandableConfig::default());
509    }
510
511    // --- nested struct expansion ---
512
513    #[derive(Debug, PartialEq, Deserialize, Default, modkit_macros::ExpandVars)]
514    struct NestedProvider {
515        #[expand_vars]
516        #[serde(default)]
517        host: String,
518        #[expand_vars]
519        #[serde(default)]
520        token: Option<String>,
521        #[expand_vars]
522        #[serde(default)]
523        auth_config: Option<HashMap<String, String>>,
524        #[serde(default)]
525        port: u16,
526    }
527
528    #[derive(Debug, PartialEq, Deserialize, Default, modkit_macros::ExpandVars)]
529    struct NestedConfig {
530        #[expand_vars]
531        #[serde(default)]
532        name: String,
533        #[expand_vars]
534        #[serde(default)]
535        providers: HashMap<String, NestedProvider>,
536        #[expand_vars]
537        #[serde(default)]
538        tags: Vec<String>,
539    }
540
541    #[test]
542    fn config_expanded_resolves_nested_structs() {
543        let ctx = make_ctx(
544            "nested_mod",
545            json!({
546                "config": {
547                    "name": "${MODKIT_NESTED_NAME}",
548                    "providers": {
549                        "primary": {
550                            "host": "${MODKIT_NESTED_HOST}",
551                            "token": "${MODKIT_NESTED_TOKEN}",
552                            "auth_config": {
553                                "header": "X-Api-Key",
554                                "secret_ref": "${MODKIT_NESTED_SECRET}"
555                            },
556                            "port": 443
557                        }
558                    },
559                    "tags": ["${MODKIT_NESTED_TAG}", "literal"]
560                }
561            }),
562        );
563
564        temp_env::with_vars(
565            [
566                ("MODKIT_NESTED_NAME", Some("my-service")),
567                ("MODKIT_NESTED_HOST", Some("api.example.com")),
568                ("MODKIT_NESTED_TOKEN", Some("sk-secret")),
569                ("MODKIT_NESTED_SECRET", Some("key-12345")),
570                ("MODKIT_NESTED_TAG", Some("production")),
571            ],
572            || {
573                let cfg: NestedConfig = ctx.config_expanded().unwrap();
574                assert_eq!(cfg.name, "my-service");
575                assert_eq!(cfg.tags, vec!["production", "literal"]);
576
577                let primary = cfg.providers.get("primary").expect("primary provider");
578                assert_eq!(primary.host, "api.example.com");
579                assert_eq!(primary.token.as_deref(), Some("sk-secret"));
580                assert_eq!(primary.port, 443);
581
582                let auth = primary.auth_config.as_ref().expect("auth_config present");
583                assert_eq!(auth.get("header").map(String::as_str), Some("X-Api-Key"));
584                assert_eq!(
585                    auth.get("secret_ref").map(String::as_str),
586                    Some("key-12345")
587                );
588            },
589        );
590    }
591
592    #[test]
593    fn config_expanded_nested_missing_var_returns_error() {
594        let ctx = make_ctx(
595            "nested_mod",
596            json!({
597                "config": {
598                    "name": "ok",
599                    "providers": {
600                        "bad": { "host": "${MODKIT_NESTED_GONE}", "port": 80 }
601                    }
602                }
603            }),
604        );
605
606        temp_env::with_vars([("MODKIT_NESTED_GONE", None::<&str>)], || {
607            let err = ctx.config_expanded::<NestedConfig>().unwrap_err();
608            assert!(
609                matches!(err, ConfigError::VarExpand { ref module, .. } if module == "nested_mod"),
610                "expected EnvExpand, got: {err:?}"
611            );
612        });
613    }
614}