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 db: Option<DbProvider>,
38}
39
40pub 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>>, }
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 #[must_use]
71 pub fn instance_id(&self) -> Uuid {
72 self.instance_id
73 }
74
75 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; 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 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 #[inline]
130 #[must_use]
131 pub fn module_name(&self) -> &str {
132 &self.module_name
133 }
134
135 #[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 #[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 #[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 #[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 pub fn config<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
222 module_config_required(self.config_provider.as_ref(), &self.module_name)
223 }
224
225 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 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 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 #[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 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 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 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 #[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 #[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}