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