1use serde::de::DeserializeOwned;
2use std::sync::Arc;
3use tokio_util::sync::CancellationToken;
4use uuid::Uuid;
5
6use crate::config::{ConfigError, ConfigProvider, module_config_or_default};
8
9#[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#[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
37pub 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>>, }
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 #[must_use]
68 pub fn instance_id(&self) -> Uuid {
69 self.instance_id
70 }
71
72 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; 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 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 #[inline]
127 #[must_use]
128 pub fn module_name(&self) -> &str {
129 &self.module_name
130 }
131
132 #[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 #[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 #[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 #[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 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 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 #[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 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 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 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 #[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 #[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}