Skip to main content

drasi_plugin_sdk/
mapper.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! DTO-to-domain model mapping service with value resolution.
16//!
17//! The [`DtoMapper`] is the main mapping service that plugins use to convert their
18//! DTO configuration structs into domain model values. It resolves [`ConfigValue`]
19//! references (environment variables, secrets) into their actual values.
20//!
21//! # Usage in Plugin Descriptors
22//!
23//! ```rust,ignore
24//! use drasi_plugin_sdk::prelude::*;
25//!
26//! struct MySourceDescriptor;
27//!
28//! #[async_trait]
29//! impl SourcePluginDescriptor for MySourceDescriptor {
30//!     // ... other methods ...
31//!
32//!     async fn create_source(
33//!         &self,
34//!         id: &str,
35//!         config_json: &serde_json::Value,
36//!         auto_start: bool,
37//!     ) -> anyhow::Result<Box<dyn drasi_lib::Source>> {
38//!         // Deserialize the JSON into the plugin's DTO
39//!         let dto: MySourceConfigDto = serde_json::from_value(config_json.clone())?;
40//!
41//!         // Create a mapper to resolve config values
42//!         let mapper = DtoMapper::new();
43//!
44//!         // Resolve individual fields
45//!         let host = mapper.resolve_string(&dto.host)?;
46//!         let port = mapper.resolve_typed(&dto.port)?;
47//!
48//!         // Build the source using resolved values
49//!         Ok(Box::new(MySource::new(id, host, port, auto_start)))
50//!     }
51//! }
52//! ```
53//!
54//! # The ConfigMapper Pattern
55//!
56//! For complex mappings, implement the [`ConfigMapper`] trait to encapsulate the
57//! conversion logic:
58//!
59//! ```rust,ignore
60//! use drasi_plugin_sdk::prelude::*;
61//!
62//! struct MyConfigMapper;
63//!
64//! impl ConfigMapper<MySourceConfigDto, MySourceConfig> for MyConfigMapper {
65//!     fn map(&self, dto: &MySourceConfigDto, resolver: &DtoMapper) -> Result<MySourceConfig, MappingError> {
66//!         Ok(MySourceConfig {
67//!             host: resolver.resolve_string(&dto.host)?,
68//!             port: resolver.resolve_typed(&dto.port)?,
69//!             timeout: resolver.resolve_optional(&dto.timeout_ms)?,
70//!         })
71//!     }
72//! }
73//! ```
74
75use crate::config_value::ConfigValue;
76use crate::resolver::{
77    get_secret_resolver, EnvironmentVariableResolver, ResolverError, SecretResolver, ValueResolver,
78};
79use std::collections::HashMap;
80use std::str::FromStr;
81use std::sync::Arc;
82use thiserror::Error;
83
84/// Errors that can occur during DTO-to-domain mapping.
85#[derive(Debug, Error)]
86pub enum MappingError {
87    /// A [`ConfigValue`] reference could not be resolved.
88    #[error("Failed to resolve config value: {0}")]
89    ResolutionError(#[from] ResolverError),
90
91    /// No mapper was found for the given config type.
92    #[error("No mapper found for config type: {0}")]
93    NoMapperFound(String),
94
95    /// The mapper received a DTO type it doesn't handle.
96    #[error("Mapper type mismatch")]
97    MapperTypeMismatch,
98
99    /// Source creation failed.
100    #[error("Failed to create source: {0}")]
101    SourceCreationError(String),
102
103    /// Reaction creation failed.
104    #[error("Failed to create reaction: {0}")]
105    ReactionCreationError(String),
106
107    /// A configuration value was invalid.
108    #[error("Invalid value: {0}")]
109    InvalidValue(String),
110}
111
112/// Trait for converting a specific DTO config type to its domain model.
113///
114/// Implement this trait when you have a complex mapping between a DTO and its
115/// corresponding domain type. The `resolver` parameter provides access to
116/// [`DtoMapper`] for resolving [`ConfigValue`] references.
117///
118/// # Type Parameters
119///
120/// - `TDto` — The DTO (Data Transfer Object) type from the API layer.
121/// - `TDomain` — The domain model type used internally by the plugin.
122pub trait ConfigMapper<TDto, TDomain>: Send + Sync {
123    /// Convert a DTO to its domain model, resolving any config value references.
124    fn map(&self, dto: &TDto, resolver: &DtoMapper) -> Result<TDomain, MappingError>;
125}
126
127/// Main mapping service that resolves [`ConfigValue`] references in plugin DTOs.
128///
129/// Provides methods to resolve `ConfigValue<T>` fields into their actual values
130/// by dispatching to the appropriate [`ValueResolver`] based on the variant.
131///
132/// # Default Resolvers
133///
134/// - `"EnvironmentVariable"` → [`EnvironmentVariableResolver`]
135/// - `"Secret"` → [`SecretResolver`] (currently returns `NotImplemented`)
136pub struct DtoMapper {
137    resolvers: HashMap<&'static str, Arc<dyn ValueResolver>>,
138}
139
140impl DtoMapper {
141    /// Create a new mapper with the default resolvers (environment variable + secret).
142    ///
143    /// If a global secret resolver has been registered via
144    /// [`register_secret_resolver`](crate::resolver::register_secret_resolver),
145    /// it will be used automatically. Otherwise, the default [`SecretResolver`]
146    /// stub is used (which returns `NotImplemented`).
147    pub fn new() -> Self {
148        let mut resolvers: HashMap<&'static str, Arc<dyn ValueResolver>> = HashMap::new();
149        resolvers.insert("EnvironmentVariable", Arc::new(EnvironmentVariableResolver));
150
151        let secret_resolver = get_secret_resolver().unwrap_or_else(|| Arc::new(SecretResolver));
152        resolvers.insert("Secret", secret_resolver);
153
154        Self { resolvers }
155    }
156
157    /// Register a custom [`ValueResolver`] for a given reference kind.
158    ///
159    /// This replaces any previously registered resolver for the same kind.
160    pub fn with_resolver(mut self, kind: &'static str, resolver: Arc<dyn ValueResolver>) -> Self {
161        self.resolvers.insert(kind, resolver);
162        self
163    }
164
165    /// Resolve a `ConfigValue<String>` to its actual string value.
166    pub fn resolve_string(&self, value: &ConfigValue<String>) -> Result<String, ResolverError> {
167        match value {
168            ConfigValue::Static(s) => Ok(s.clone()),
169
170            ConfigValue::Secret { .. } => {
171                let resolver = self
172                    .resolvers
173                    .get("Secret")
174                    .ok_or_else(|| ResolverError::NoResolverFound("Secret".to_string()))?;
175                resolver.resolve_to_string(value)
176            }
177
178            ConfigValue::EnvironmentVariable { .. } => {
179                let resolver = self.resolvers.get("EnvironmentVariable").ok_or_else(|| {
180                    ResolverError::NoResolverFound("EnvironmentVariable".to_string())
181                })?;
182                resolver.resolve_to_string(value)
183            }
184        }
185    }
186
187    /// Resolve a `ConfigValue<T>` to its typed value.
188    ///
189    /// For `Static` values, returns the value directly. For `EnvironmentVariable` and
190    /// `Secret` references, resolves to a string first, then parses to `T` via [`FromStr`].
191    pub fn resolve_typed<T>(&self, value: &ConfigValue<T>) -> Result<T, ResolverError>
192    where
193        T: FromStr + Clone + serde::Serialize + serde::de::DeserializeOwned,
194        T::Err: std::fmt::Display,
195    {
196        match value {
197            ConfigValue::Static(v) => Ok(v.clone()),
198
199            ConfigValue::Secret { name } => {
200                let resolver = self
201                    .resolvers
202                    .get("Secret")
203                    .ok_or_else(|| ResolverError::NoResolverFound("Secret".to_string()))?;
204                let string_cv = ConfigValue::Secret { name: name.clone() };
205                let string_val = resolver.resolve_to_string(&string_cv)?;
206                string_val.parse::<T>().map_err(|e| {
207                    ResolverError::ParseError(format!("Failed to parse secret '{name}': {e}"))
208                })
209            }
210
211            ConfigValue::EnvironmentVariable { name, default } => {
212                let string_val = std::env::var(name).or_else(|_| {
213                    default
214                        .clone()
215                        .ok_or_else(|| ResolverError::EnvVarNotFound(name.clone()))
216                })?;
217
218                string_val.parse::<T>().map_err(|e| {
219                    ResolverError::ParseError(format!("Failed to parse env var '{name}': {e}"))
220                })
221            }
222        }
223    }
224
225    /// Resolve an optional `ConfigValue<T>`. Returns `Ok(None)` if the value is `None`.
226    pub fn resolve_optional<T>(
227        &self,
228        value: &Option<ConfigValue<T>>,
229    ) -> Result<Option<T>, ResolverError>
230    where
231        T: FromStr + Clone + serde::Serialize + serde::de::DeserializeOwned,
232        T::Err: std::fmt::Display,
233    {
234        value.as_ref().map(|v| self.resolve_typed(v)).transpose()
235    }
236
237    /// Resolve an optional `ConfigValue<String>` to `Option<String>`.
238    pub fn resolve_optional_string(
239        &self,
240        value: &Option<ConfigValue<String>>,
241    ) -> Result<Option<String>, ResolverError> {
242        value.as_ref().map(|v| self.resolve_string(v)).transpose()
243    }
244
245    /// Resolve a slice of `ConfigValue<String>` to `Vec<String>`.
246    pub fn resolve_string_vec(
247        &self,
248        values: &[ConfigValue<String>],
249    ) -> Result<Vec<String>, ResolverError> {
250        values.iter().map(|v| self.resolve_string(v)).collect()
251    }
252
253    /// Map a DTO using a [`ConfigMapper`] implementation.
254    pub fn map_with<TDto, TDomain>(
255        &self,
256        dto: &TDto,
257        mapper: &impl ConfigMapper<TDto, TDomain>,
258    ) -> Result<TDomain, MappingError> {
259        mapper.map(dto, self)
260    }
261}
262
263impl Default for DtoMapper {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_resolve_string_static() {
275        let mapper = DtoMapper::new();
276        let value = ConfigValue::Static("hello".to_string());
277
278        let result = mapper.resolve_string(&value).expect("resolve");
279        assert_eq!(result, "hello");
280    }
281
282    #[test]
283    fn test_resolve_string_env_var() {
284        std::env::set_var("TEST_SDK_MAPPER_VAR", "mapped_value");
285
286        let mapper = DtoMapper::new();
287        let value = ConfigValue::EnvironmentVariable {
288            name: "TEST_SDK_MAPPER_VAR".to_string(),
289            default: None,
290        };
291
292        let result = mapper.resolve_string(&value).expect("resolve");
293        assert_eq!(result, "mapped_value");
294
295        std::env::remove_var("TEST_SDK_MAPPER_VAR");
296    }
297
298    #[test]
299    fn test_resolve_typed_u16() {
300        let mapper = DtoMapper::new();
301        let value = ConfigValue::Static(5432u16);
302
303        let result = mapper.resolve_typed(&value).expect("resolve");
304        assert_eq!(result, 5432u16);
305    }
306
307    #[test]
308    fn test_resolve_typed_u16_from_env() {
309        std::env::set_var("TEST_SDK_PORT", "8080");
310
311        let mapper = DtoMapper::new();
312        let value: ConfigValue<u16> = ConfigValue::EnvironmentVariable {
313            name: "TEST_SDK_PORT".to_string(),
314            default: None,
315        };
316
317        let result = mapper.resolve_typed(&value).expect("resolve");
318        assert_eq!(result, 8080u16);
319
320        std::env::remove_var("TEST_SDK_PORT");
321    }
322
323    #[test]
324    fn test_resolve_typed_parse_error() {
325        std::env::set_var("TEST_SDK_INVALID_PORT", "not_a_number");
326
327        let mapper = DtoMapper::new();
328        let value: ConfigValue<u16> = ConfigValue::EnvironmentVariable {
329            name: "TEST_SDK_INVALID_PORT".to_string(),
330            default: None,
331        };
332
333        let result = mapper.resolve_typed(&value);
334        assert!(result.is_err());
335        assert!(matches!(
336            result.expect_err("should fail"),
337            ResolverError::ParseError(_)
338        ));
339
340        std::env::remove_var("TEST_SDK_INVALID_PORT");
341    }
342
343    #[test]
344    fn test_resolve_optional_some() {
345        let mapper = DtoMapper::new();
346        let value = Some(ConfigValue::Static("test".to_string()));
347
348        let result = mapper.resolve_optional(&value).expect("resolve");
349        assert_eq!(result, Some("test".to_string()));
350    }
351
352    #[test]
353    fn test_resolve_optional_none() {
354        let mapper = DtoMapper::new();
355        let value: Option<ConfigValue<String>> = None;
356
357        let result = mapper.resolve_optional(&value).expect("resolve");
358        assert_eq!(result, None);
359    }
360
361    #[test]
362    fn test_resolve_string_vec() {
363        let mapper = DtoMapper::new();
364        let values = vec![
365            ConfigValue::Static("a".to_string()),
366            ConfigValue::Static("b".to_string()),
367        ];
368
369        let result = mapper.resolve_string_vec(&values).expect("resolve");
370        assert_eq!(result, vec!["a", "b"]);
371    }
372
373    #[test]
374    fn test_config_mapper_trait() {
375        struct TestMapper;
376
377        #[derive(Debug)]
378        struct TestDto {
379            host: ConfigValue<String>,
380        }
381
382        struct TestDomain {
383            host: String,
384        }
385
386        impl ConfigMapper<TestDto, TestDomain> for TestMapper {
387            fn map(&self, dto: &TestDto, resolver: &DtoMapper) -> Result<TestDomain, MappingError> {
388                Ok(TestDomain {
389                    host: resolver.resolve_string(&dto.host)?,
390                })
391            }
392        }
393
394        let mapper = DtoMapper::new();
395        let dto = TestDto {
396            host: ConfigValue::Static("localhost".to_string()),
397        };
398
399        let domain = mapper.map_with(&dto, &TestMapper).expect("map");
400        assert_eq!(domain.host, "localhost");
401    }
402
403    #[test]
404    fn test_custom_resolver() {
405        struct AlwaysResolver;
406        impl ValueResolver for AlwaysResolver {
407            fn resolve_to_string(
408                &self,
409                _value: &ConfigValue<String>,
410            ) -> Result<String, ResolverError> {
411                Ok("custom-resolved".to_string())
412            }
413        }
414
415        let mapper = DtoMapper::new().with_resolver("Secret", Arc::new(AlwaysResolver));
416        let value = ConfigValue::Secret {
417            name: "test".to_string(),
418        };
419
420        let result = mapper.resolve_string(&value).expect("resolve");
421        assert_eq!(result, "custom-resolved");
422    }
423}