1use serde::de::DeserializeOwned;
2use std::sync::Arc;
3use tokio_util::sync::CancellationToken;
4use uuid::Uuid;
5
6use crate::{
8 config::{ConfigError, ConfigProvider, module_config_or_default},
9 module_config_required,
10};
11
12#[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#[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
41pub 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>>, }
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 #[must_use]
73 pub fn instance_id(&self) -> Uuid {
74 self.instance_id
75 }
76
77 #[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; 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 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 #[inline]
133 #[must_use]
134 pub fn module_name(&self) -> &str {
135 &self.module_name
136 }
137
138 #[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 #[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 #[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 #[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 pub fn config<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
225 module_config_required(self.config_provider.as_ref(), &self.module_name)
226 }
227
228 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 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 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 #[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 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 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 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 #[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 #[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}