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::{
8    config::{ConfigError, ConfigProvider, module_config_or_default},
9    module_config_required,
10};
11
12// Note: runtime-dependent features are conditionally compiled
13
14// DB types are available only when feature "db" is enabled.
15// We keep local aliases so the rest of this file can compile without importing `modkit_db`.
16#[cfg(feature = "db")]
17pub(crate) type DbManager = modkit_db::DbManager;
18#[cfg(feature = "db")]
19pub(crate) type DbProvider = modkit_db::DBProvider<modkit_db::DbError>;
20
21// Stub types for no-db builds (never exposed; methods that would use them are cfg'd out).
22#[cfg(not(feature = "db"))]
23#[derive(Clone, Debug)]
24pub struct DbManager;
25#[cfg(not(feature = "db"))]
26#[derive(Clone, Debug)]
27pub struct DbProvider;
28
29#[derive(Clone)]
30#[must_use]
31pub struct ModuleCtx {
32    module_name: Arc<str>,
33    instance_id: Uuid,
34    config_provider: Arc<dyn ConfigProvider>,
35    client_hub: Arc<crate::client_hub::ClientHub>,
36    cancellation_token: CancellationToken,
37    db: Option<DbProvider>,
38}
39
40/// Builder for creating module-scoped contexts with resolved database handles.
41///
42/// This builder internally uses `DbManager` to resolve per-module `Db` instances
43/// at build time, ensuring `ModuleCtx` contains only the final, ready-to-use entrypoint.
44pub struct ModuleContextBuilder {
45    instance_id: Uuid,
46    config_provider: Arc<dyn ConfigProvider>,
47    client_hub: Arc<crate::client_hub::ClientHub>,
48    root_token: CancellationToken,
49    db_manager: Option<Arc<DbManager>>, // internal only, never exposed to modules
50}
51
52impl ModuleContextBuilder {
53    pub fn new(
54        instance_id: Uuid,
55        config_provider: Arc<dyn ConfigProvider>,
56        client_hub: Arc<crate::client_hub::ClientHub>,
57        root_token: CancellationToken,
58        db_manager: Option<Arc<DbManager>>,
59    ) -> Self {
60        Self {
61            instance_id,
62            config_provider,
63            client_hub,
64            root_token,
65            db_manager,
66        }
67    }
68
69    /// Returns the process-level instance ID.
70    #[must_use]
71    pub fn instance_id(&self) -> Uuid {
72        self.instance_id
73    }
74
75    /// Build a module-scoped context, resolving the `DbHandle` for the given module.
76    ///
77    /// # Errors
78    /// Returns an error if database resolution fails.
79    pub async fn for_module(&self, module_name: &str) -> anyhow::Result<ModuleCtx> {
80        let db: Option<DbProvider> = {
81            #[cfg(feature = "db")]
82            {
83                if let Some(mgr) = &self.db_manager {
84                    mgr.get(module_name).await?.map(modkit_db::DBProvider::new)
85                } else {
86                    None
87                }
88            }
89            #[cfg(not(feature = "db"))]
90            {
91                let _ = module_name; // avoid unused in no-db builds
92                None
93            }
94        };
95
96        Ok(ModuleCtx::new(
97            Arc::<str>::from(module_name),
98            self.instance_id,
99            self.config_provider.clone(),
100            self.client_hub.clone(),
101            self.root_token.child_token(),
102            db,
103        ))
104    }
105}
106
107impl ModuleCtx {
108    /// Create a new module-scoped context with all required fields.
109    pub fn new(
110        module_name: impl Into<Arc<str>>,
111        instance_id: Uuid,
112        config_provider: Arc<dyn ConfigProvider>,
113        client_hub: Arc<crate::client_hub::ClientHub>,
114        cancellation_token: CancellationToken,
115        db: Option<DbProvider>,
116    ) -> Self {
117        Self {
118            module_name: module_name.into(),
119            instance_id,
120            config_provider,
121            client_hub,
122            cancellation_token,
123            db,
124        }
125    }
126
127    // ---- public read-only API for modules ----
128
129    #[inline]
130    #[must_use]
131    pub fn module_name(&self) -> &str {
132        &self.module_name
133    }
134
135    /// Returns the process-level instance ID.
136    ///
137    /// This is a unique identifier for this process instance, shared by all modules
138    /// in the same process. It is generated once at bootstrap.
139    #[inline]
140    #[must_use]
141    pub fn instance_id(&self) -> Uuid {
142        self.instance_id
143    }
144
145    #[inline]
146    #[must_use]
147    pub fn config_provider(&self) -> &dyn ConfigProvider {
148        &*self.config_provider
149    }
150
151    /// Get the `ClientHub` for dependency resolution.
152    #[inline]
153    #[must_use]
154    pub fn client_hub(&self) -> Arc<crate::client_hub::ClientHub> {
155        self.client_hub.clone()
156    }
157
158    #[inline]
159    #[must_use]
160    pub fn cancellation_token(&self) -> &CancellationToken {
161        &self.cancellation_token
162    }
163
164    /// Get a module-scoped DB entrypoint for secure database operations.
165    ///
166    /// Returns `None` if no database is configured for this module.
167    ///
168    /// # Security
169    ///
170    /// The returned `DBProvider<modkit_db::DbError>`:
171    /// - Is cheap to clone (shares an internal `Db`)
172    /// - Provides `conn()` for non-transactional access (fails inside tx via guard)
173    /// - Provides `transaction(..)` for transactional operations
174    ///
175    /// # Example
176    ///
177    /// ```ignore
178    /// let db = ctx.db().ok_or_else(|| anyhow!("no db"))?;
179    /// let conn = db.conn()?;
180    /// let user = svc.get_user(&conn, &scope, id).await?;
181    /// ```
182    #[must_use]
183    #[cfg(feature = "db")]
184    pub fn db(&self) -> Option<modkit_db::DBProvider<modkit_db::DbError>> {
185        self.db.clone()
186    }
187
188    /// Get a database handle, returning an error if not configured.
189    ///
190    /// This is a convenience method that combines `db()` with an error for
191    /// modules that require database access.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the database is not configured for this module.
196    ///
197    /// # Example
198    ///
199    /// ```ignore
200    /// let db = ctx.db_required()?;
201    /// let conn = db.conn()?;
202    /// let user = svc.get_user(&conn, &scope, id).await?;
203    /// ```
204    #[cfg(feature = "db")]
205    pub fn db_required(&self) -> anyhow::Result<modkit_db::DBProvider<modkit_db::DbError>> {
206        self.db().ok_or_else(|| {
207            anyhow::anyhow!(
208                "Database is not configured for module '{}'",
209                self.module_name
210            )
211        })
212    }
213
214    /// Deserialize the module's config section into `T`.
215    ///
216    /// This reads the `modules.<name>.config` object for the current module and
217    /// deserializes it into the requested type.
218    ///
219    /// # Errors
220    /// Returns `ConfigError` if the module config is missing or deserialization fails.
221    pub fn config<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
222        module_config_required(self.config_provider.as_ref(), &self.module_name)
223    }
224
225    /// Deserialize the module's config section into T, or use defaults if missing.
226    ///
227    /// This method uses lenient configuration loading: if the module is not present in config,
228    /// has no config section, or the module entry is not an object, it returns `T::default()`.
229    /// This allows modules to exist without configuration sections in the main config file.
230    ///
231    /// It extracts the 'config' field from: `modules.<name> = { database: ..., config: ... }`
232    ///
233    /// # Example
234    ///
235    /// ```rust,ignore
236    /// #[derive(serde::Deserialize, Default)]
237    /// struct MyConfig {
238    ///     api_key: String,
239    ///     timeout_ms: u64,
240    /// }
241    ///
242    /// let config: MyConfig = ctx.config_or_default()?;
243    /// ```
244    ///
245    /// # Errors
246    /// Returns `ConfigError` if deserialization fails.
247    pub fn config_or_default<T: DeserializeOwned + Default>(&self) -> Result<T, ConfigError> {
248        module_config_or_default(self.config_provider.as_ref(), &self.module_name)
249    }
250
251    /// Like [`config()`](Self::config), but additionally expands `${VAR}` placeholders
252    /// in fields marked with `#[expand_vars]`.
253    ///
254    /// # Errors
255    /// Returns `ConfigError` if the module config is missing, deserialization fails,
256    /// or environment variable expansion fails.
257    pub fn config_expanded<T>(&self) -> Result<T, ConfigError>
258    where
259        T: DeserializeOwned + crate::var_expand::ExpandVars,
260    {
261        let mut cfg: T = self.config()?;
262        cfg.expand_vars().map_err(|e| ConfigError::VarExpand {
263            module: self.module_name.to_string(),
264            source: e,
265        })?;
266        Ok(cfg)
267    }
268
269    /// Like [`config_or_default()`](Self::config_or_default), but additionally expands `${VAR}`
270    /// placeholders
271    /// in fields marked with `#[expand_vars]` (requires `#[derive(ExpandVars)]` on the config
272    /// struct).
273    ///
274    /// Modules that do not need environment variable expansion should use
275    /// [`config_or_default()`](Self::config_or_default).
276    ///
277    /// # Example
278    ///
279    /// ```rust,ignore
280    /// #[derive(serde::Deserialize, Default, ExpandVars)]
281    /// struct MyConfig {
282    ///     #[expand_vars]
283    ///     api_key: String,
284    ///     timeout_ms: u64,
285    /// }
286    ///
287    /// let config: MyConfig = ctx.config_expanded_or_default()?;
288    /// ```
289    ///
290    /// # Errors
291    /// Returns `ConfigError` if deserialization fails or if environment variable expansion fails.
292    pub fn config_expanded_or_default<T>(&self) -> Result<T, ConfigError>
293    where
294        T: DeserializeOwned + Default + crate::var_expand::ExpandVars,
295    {
296        let mut cfg: T = self.config_or_default()?;
297        cfg.expand_vars().map_err(|e| ConfigError::VarExpand {
298            module: self.module_name.to_string(),
299            source: e,
300        })?;
301        Ok(cfg)
302    }
303
304    /// Get the raw JSON value of the module's config section.
305    /// Returns the 'config' field from: modules.<name> = { database: ..., config: ... }
306    #[must_use]
307    pub fn raw_config(&self) -> &serde_json::Value {
308        use std::sync::LazyLock;
309
310        static EMPTY: LazyLock<serde_json::Value> =
311            LazyLock::new(|| serde_json::Value::Object(serde_json::Map::new()));
312
313        if let Some(module_raw) = self.config_provider.get_module_config(&self.module_name) {
314            // Try new structure first: modules.<name> = { database: ..., config: ... }
315            if let Some(obj) = module_raw.as_object()
316                && let Some(config_section) = obj.get("config")
317            {
318                return config_section;
319            }
320        }
321        &EMPTY
322    }
323
324    /// Create a derivative context with the same references but no DB handle.
325    /// Useful for modules that don't require database access.
326    pub fn without_db(&self) -> ModuleCtx {
327        ModuleCtx {
328            module_name: self.module_name.clone(),
329            instance_id: self.instance_id,
330            config_provider: self.config_provider.clone(),
331            client_hub: self.client_hub.clone(),
332            cancellation_token: self.cancellation_token.clone(),
333            db: None,
334        }
335    }
336}
337
338#[cfg(test)]
339#[cfg_attr(coverage_nightly, coverage(off))]
340mod tests {
341    use super::*;
342    use serde::Deserialize;
343    use serde_json::json;
344    use std::collections::HashMap;
345
346    #[derive(Debug, PartialEq, Deserialize, Default)]
347    struct TestConfig {
348        #[serde(default)]
349        api_key: String,
350        #[serde(default)]
351        timeout_ms: u64,
352        #[serde(default)]
353        enabled: bool,
354    }
355
356    struct MockConfigProvider {
357        modules: HashMap<String, serde_json::Value>,
358    }
359
360    impl MockConfigProvider {
361        fn new() -> Self {
362            let mut modules = HashMap::new();
363
364            // Valid module config
365            modules.insert(
366                "test_module".to_owned(),
367                json!({
368                    "database": {
369                        "url": "postgres://localhost/test"
370                    },
371                    "config": {
372                        "api_key": "secret123",
373                        "timeout_ms": 5000,
374                        "enabled": true
375                    }
376                }),
377            );
378
379            Self { modules }
380        }
381    }
382
383    impl ConfigProvider for MockConfigProvider {
384        fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value> {
385            self.modules.get(module_name)
386        }
387    }
388
389    #[test]
390    fn test_module_ctx_config_with_valid_config() {
391        let provider = Arc::new(MockConfigProvider::new());
392        let ctx = ModuleCtx::new(
393            "test_module",
394            Uuid::new_v4(),
395            provider,
396            Arc::new(crate::client_hub::ClientHub::default()),
397            CancellationToken::new(),
398            None,
399        );
400
401        let result: Result<TestConfig, ConfigError> = ctx.config();
402        assert!(result.is_ok());
403
404        let config = result.unwrap();
405        assert_eq!(config.api_key, "secret123");
406        assert_eq!(config.timeout_ms, 5000);
407        assert!(config.enabled);
408    }
409
410    #[test]
411    fn test_module_ctx_config_returns_error_for_missing_module() {
412        let provider = Arc::new(MockConfigProvider::new());
413        let ctx = ModuleCtx::new(
414            "nonexistent_module",
415            Uuid::new_v4(),
416            provider,
417            Arc::new(crate::client_hub::ClientHub::default()),
418            CancellationToken::new(),
419            None,
420        );
421
422        let result: Result<TestConfig, ConfigError> = ctx.config();
423        assert!(matches!(
424            result,
425            Err(ConfigError::ModuleNotFound { ref module }) if module == "nonexistent_module"
426        ));
427    }
428
429    #[test]
430    fn test_module_ctx_config_or_default_returns_default_for_missing_module() {
431        let provider = Arc::new(MockConfigProvider::new());
432        let ctx = ModuleCtx::new(
433            "nonexistent_module",
434            Uuid::new_v4(),
435            provider,
436            Arc::new(crate::client_hub::ClientHub::default()),
437            CancellationToken::new(),
438            None,
439        );
440
441        let result: Result<TestConfig, ConfigError> = ctx.config_or_default();
442        assert!(result.is_ok());
443
444        let config = result.unwrap();
445        assert_eq!(config, TestConfig::default());
446    }
447
448    #[test]
449    fn test_module_ctx_instance_id() {
450        let provider = Arc::new(MockConfigProvider::new());
451        let instance_id = Uuid::new_v4();
452        let ctx = ModuleCtx::new(
453            "test_module",
454            instance_id,
455            provider,
456            Arc::new(crate::client_hub::ClientHub::default()),
457            CancellationToken::new(),
458            None,
459        );
460
461        assert_eq!(ctx.instance_id(), instance_id);
462    }
463
464    // --- config_expanded tests ---
465
466    #[derive(Debug, PartialEq, Deserialize, Default, modkit_macros::ExpandVars)]
467    struct ExpandableConfig {
468        #[expand_vars]
469        #[serde(default)]
470        api_key: String,
471        #[expand_vars]
472        #[serde(default)]
473        endpoint: Option<String>,
474        #[serde(default)]
475        retries: u32,
476    }
477
478    fn make_ctx(module_name: &str, config_json: serde_json::Value) -> ModuleCtx {
479        let mut modules = HashMap::new();
480        modules.insert(module_name.to_owned(), config_json);
481        let provider = Arc::new(MockConfigProvider { modules });
482        ModuleCtx::new(
483            module_name,
484            Uuid::new_v4(),
485            provider,
486            Arc::new(crate::client_hub::ClientHub::default()),
487            CancellationToken::new(),
488            None,
489        )
490    }
491
492    #[test]
493    fn config_expanded_resolves_env_vars() {
494        let ctx = make_ctx(
495            "expand_mod",
496            json!({
497                "config": {
498                    "api_key": "${MODKIT_TEST_KEY}",
499                    "endpoint": "https://${MODKIT_TEST_HOST}/api",
500                    "retries": 3
501                }
502            }),
503        );
504
505        temp_env::with_vars(
506            [
507                ("MODKIT_TEST_KEY", Some("secret-42")),
508                ("MODKIT_TEST_HOST", Some("example.com")),
509            ],
510            || {
511                let cfg: ExpandableConfig = ctx.config_expanded().unwrap();
512                assert_eq!(cfg.api_key, "secret-42");
513                assert_eq!(cfg.endpoint.as_deref(), Some("https://example.com/api"));
514                assert_eq!(cfg.retries, 3);
515            },
516        );
517    }
518
519    #[test]
520    fn config_expanded_returns_error_on_missing_var() {
521        let ctx = make_ctx(
522            "expand_mod",
523            json!({
524                "config": {
525                    "api_key": "${MODKIT_TEST_MISSING_VAR_XYZ}"
526                }
527            }),
528        );
529
530        temp_env::with_vars([("MODKIT_TEST_MISSING_VAR_XYZ", None::<&str>)], || {
531            let err = ctx.config_expanded::<ExpandableConfig>().unwrap_err();
532            assert!(
533                matches!(err, ConfigError::VarExpand { ref module, .. } if module == "expand_mod"),
534                "expected EnvExpand error, got: {err:?}"
535            );
536        });
537    }
538
539    #[test]
540    fn config_expanded_skips_none_option_fields() {
541        let ctx = make_ctx(
542            "expand_mod",
543            json!({
544                "config": {
545                    "api_key": "literal-key",
546                    "retries": 5
547                }
548            }),
549        );
550
551        let cfg: ExpandableConfig = ctx.config_expanded().unwrap();
552        assert_eq!(cfg.api_key, "literal-key");
553        assert_eq!(cfg.endpoint, None);
554        assert_eq!(cfg.retries, 5);
555    }
556
557    #[test]
558    fn config_expanded_returns_error_when_missing() {
559        let ctx = make_ctx("missing_mod", json!({}));
560        let err = ctx.config_expanded::<ExpandableConfig>().unwrap_err();
561        assert!(matches!(
562            err,
563            ConfigError::MissingConfigSection { ref module } if module == "missing_mod"
564        ));
565    }
566
567    #[test]
568    fn config_expanded_or_default_falls_back_to_default_when_missing() {
569        let ctx = make_ctx("missing_mod", json!({}));
570        let cfg: ExpandableConfig = ctx.config_expanded_or_default().unwrap();
571        assert_eq!(cfg, ExpandableConfig::default());
572    }
573
574    // --- nested struct expansion ---
575
576    #[derive(Debug, PartialEq, Deserialize, Default, modkit_macros::ExpandVars)]
577    struct NestedProvider {
578        #[expand_vars]
579        #[serde(default)]
580        host: String,
581        #[expand_vars]
582        #[serde(default)]
583        token: Option<String>,
584        #[expand_vars]
585        #[serde(default)]
586        auth_config: Option<HashMap<String, String>>,
587        #[serde(default)]
588        port: u16,
589    }
590
591    #[derive(Debug, PartialEq, Deserialize, Default, modkit_macros::ExpandVars)]
592    struct NestedConfig {
593        #[expand_vars]
594        #[serde(default)]
595        name: String,
596        #[expand_vars]
597        #[serde(default)]
598        providers: HashMap<String, NestedProvider>,
599        #[expand_vars]
600        #[serde(default)]
601        tags: Vec<String>,
602    }
603
604    #[test]
605    fn config_expanded_resolves_nested_structs() {
606        let ctx = make_ctx(
607            "nested_mod",
608            json!({
609                "config": {
610                    "name": "${MODKIT_NESTED_NAME}",
611                    "providers": {
612                        "primary": {
613                            "host": "${MODKIT_NESTED_HOST}",
614                            "token": "${MODKIT_NESTED_TOKEN}",
615                            "auth_config": {
616                                "header": "X-Api-Key",
617                                "secret_ref": "${MODKIT_NESTED_SECRET}"
618                            },
619                            "port": 443
620                        }
621                    },
622                    "tags": ["${MODKIT_NESTED_TAG}", "literal"]
623                }
624            }),
625        );
626
627        temp_env::with_vars(
628            [
629                ("MODKIT_NESTED_NAME", Some("my-service")),
630                ("MODKIT_NESTED_HOST", Some("api.example.com")),
631                ("MODKIT_NESTED_TOKEN", Some("sk-secret")),
632                ("MODKIT_NESTED_SECRET", Some("key-12345")),
633                ("MODKIT_NESTED_TAG", Some("production")),
634            ],
635            || {
636                let cfg: NestedConfig = ctx.config_expanded().unwrap();
637                assert_eq!(cfg.name, "my-service");
638                assert_eq!(cfg.tags, vec!["production", "literal"]);
639
640                let primary = cfg.providers.get("primary").expect("primary provider");
641                assert_eq!(primary.host, "api.example.com");
642                assert_eq!(primary.token.as_deref(), Some("sk-secret"));
643                assert_eq!(primary.port, 443);
644
645                let auth = primary.auth_config.as_ref().expect("auth_config present");
646                assert_eq!(auth.get("header").map(String::as_str), Some("X-Api-Key"));
647                assert_eq!(
648                    auth.get("secret_ref").map(String::as_str),
649                    Some("key-12345")
650                );
651            },
652        );
653    }
654
655    #[test]
656    fn config_expanded_nested_missing_var_returns_error() {
657        let ctx = make_ctx(
658            "nested_mod",
659            json!({
660                "config": {
661                    "name": "ok",
662                    "providers": {
663                        "bad": { "host": "${MODKIT_NESTED_GONE}", "port": 80 }
664                    }
665                }
666            }),
667        );
668
669        temp_env::with_vars([("MODKIT_NESTED_GONE", None::<&str>)], || {
670            let err = ctx.config_expanded::<NestedConfig>().unwrap_err();
671            assert!(
672                matches!(err, ConfigError::VarExpand { ref module, .. } if module == "nested_mod"),
673                "expected EnvExpand, got: {err:?}"
674            );
675        });
676    }
677}