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    /// Get the raw JSON value of the module's config section.
238    /// Returns the 'config' field from: modules.<name> = { database: ..., config: ... }
239    #[must_use]
240    pub fn raw_config(&self) -> &serde_json::Value {
241        use std::sync::LazyLock;
242
243        static EMPTY: LazyLock<serde_json::Value> =
244            LazyLock::new(|| serde_json::Value::Object(serde_json::Map::new()));
245
246        if let Some(module_raw) = self.config_provider.get_module_config(&self.module_name) {
247            // Try new structure first: modules.<name> = { database: ..., config: ... }
248            if let Some(obj) = module_raw.as_object()
249                && let Some(config_section) = obj.get("config")
250            {
251                return config_section;
252            }
253        }
254        &EMPTY
255    }
256
257    /// Create a derivative context with the same references but no DB handle.
258    /// Useful for modules that don't require database access.
259    pub fn without_db(&self) -> ModuleCtx {
260        ModuleCtx {
261            module_name: self.module_name.clone(),
262            instance_id: self.instance_id,
263            config_provider: self.config_provider.clone(),
264            client_hub: self.client_hub.clone(),
265            cancellation_token: self.cancellation_token.clone(),
266            db: None,
267        }
268    }
269}
270
271#[cfg(test)]
272#[cfg_attr(coverage_nightly, coverage(off))]
273mod tests {
274    use super::*;
275    use serde::Deserialize;
276    use serde_json::json;
277    use std::collections::HashMap;
278
279    #[derive(Debug, PartialEq, Deserialize, Default)]
280    struct TestConfig {
281        #[serde(default)]
282        api_key: String,
283        #[serde(default)]
284        timeout_ms: u64,
285        #[serde(default)]
286        enabled: bool,
287    }
288
289    struct MockConfigProvider {
290        modules: HashMap<String, serde_json::Value>,
291    }
292
293    impl MockConfigProvider {
294        fn new() -> Self {
295            let mut modules = HashMap::new();
296
297            // Valid module config
298            modules.insert(
299                "test_module".to_owned(),
300                json!({
301                    "database": {
302                        "url": "postgres://localhost/test"
303                    },
304                    "config": {
305                        "api_key": "secret123",
306                        "timeout_ms": 5000,
307                        "enabled": true
308                    }
309                }),
310            );
311
312            Self { modules }
313        }
314    }
315
316    impl ConfigProvider for MockConfigProvider {
317        fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value> {
318            self.modules.get(module_name)
319        }
320    }
321
322    #[test]
323    fn test_module_ctx_config_with_valid_config() {
324        let provider = Arc::new(MockConfigProvider::new());
325        let ctx = ModuleCtx::new(
326            "test_module",
327            Uuid::new_v4(),
328            provider,
329            Arc::new(crate::client_hub::ClientHub::default()),
330            CancellationToken::new(),
331            None,
332        );
333
334        let result: Result<TestConfig, ConfigError> = ctx.config();
335        assert!(result.is_ok());
336
337        let config = result.unwrap();
338        assert_eq!(config.api_key, "secret123");
339        assert_eq!(config.timeout_ms, 5000);
340        assert!(config.enabled);
341    }
342
343    #[test]
344    fn test_module_ctx_config_returns_default_for_missing_module() {
345        let provider = Arc::new(MockConfigProvider::new());
346        let ctx = ModuleCtx::new(
347            "nonexistent_module",
348            Uuid::new_v4(),
349            provider,
350            Arc::new(crate::client_hub::ClientHub::default()),
351            CancellationToken::new(),
352            None,
353        );
354
355        let result: Result<TestConfig, ConfigError> = ctx.config();
356        assert!(result.is_ok());
357
358        let config = result.unwrap();
359        assert_eq!(config, TestConfig::default());
360    }
361
362    #[test]
363    fn test_module_ctx_instance_id() {
364        let provider = Arc::new(MockConfigProvider::new());
365        let instance_id = Uuid::new_v4();
366        let ctx = ModuleCtx::new(
367            "test_module",
368            instance_id,
369            provider,
370            Arc::new(crate::client_hub::ClientHub::default()),
371            CancellationToken::new(),
372            None,
373        );
374
375        assert_eq!(ctx.instance_id(), instance_id);
376    }
377}