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).await?;
46//!         let port = mapper.resolve_typed(&dto.port).await?;
47//!
48//!         // Build the source using resolved values
49//!         Ok(Box::new(MySource::new(id, host, port, auto_start)))
50//!     }
51//! }
52//! ```
53//!
54
55use crate::config_value::ConfigValue;
56use crate::resolver::{
57    get_secret_resolver, EnvironmentVariableResolver, ResolverError, SecretResolver, ValueResolver,
58};
59use std::collections::HashMap;
60use std::str::FromStr;
61use std::sync::Arc;
62use thiserror::Error;
63
64/// Errors that can occur during DTO-to-domain mapping.
65#[derive(Debug, Error)]
66pub enum MappingError {
67    /// A [`ConfigValue`] reference could not be resolved.
68    #[error("Failed to resolve config value: {0}")]
69    ResolutionError(#[from] ResolverError),
70
71    /// Source creation failed.
72    #[error("Failed to create source: {0}")]
73    SourceCreationError(String),
74
75    /// Reaction creation failed.
76    #[error("Failed to create reaction: {0}")]
77    ReactionCreationError(String),
78
79    /// A configuration value was invalid.
80    #[error("Invalid value: {0}")]
81    InvalidValue(String),
82}
83
84/// Main mapping service that resolves [`ConfigValue`] references in plugin DTOs.
85///
86/// Provides methods to resolve `ConfigValue<T>` fields into their actual values
87/// by dispatching to the appropriate [`ValueResolver`] based on the variant.
88///
89/// # Default Resolvers
90///
91/// - `"EnvironmentVariable"` → [`EnvironmentVariableResolver`]
92/// - `"Secret"` → [`SecretResolver`] (currently returns `NotImplemented`)
93pub struct DtoMapper {
94    resolvers: HashMap<&'static str, Arc<dyn ValueResolver>>,
95}
96
97impl DtoMapper {
98    /// Create a new mapper with the default resolvers (environment variable + secret).
99    ///
100    /// If a global secret resolver has been registered via
101    /// [`register_secret_resolver`](crate::resolver::register_secret_resolver),
102    /// it will be used automatically. Otherwise, the default [`SecretResolver`]
103    /// stub is used (which returns `NotImplemented`).
104    pub fn new() -> Self {
105        let mut resolvers: HashMap<&'static str, Arc<dyn ValueResolver>> = HashMap::new();
106        resolvers.insert("EnvironmentVariable", Arc::new(EnvironmentVariableResolver));
107
108        let secret_resolver = get_secret_resolver().unwrap_or_else(|| Arc::new(SecretResolver));
109        resolvers.insert("Secret", secret_resolver);
110
111        Self { resolvers }
112    }
113
114    /// Register a custom [`ValueResolver`] for a given reference kind.
115    ///
116    /// This replaces any previously registered resolver for the same kind.
117    pub fn with_resolver(mut self, kind: &'static str, resolver: Arc<dyn ValueResolver>) -> Self {
118        self.resolvers.insert(kind, resolver);
119        self
120    }
121
122    /// Resolve a `ConfigValue<String>` to its actual string value.
123    pub async fn resolve_string(
124        &self,
125        value: &ConfigValue<String>,
126    ) -> Result<String, ResolverError> {
127        match value {
128            ConfigValue::Static(s) => Ok(s.clone()),
129
130            ConfigValue::Secret { .. } => {
131                let resolver = self
132                    .resolvers
133                    .get("Secret")
134                    .ok_or_else(|| ResolverError::NoResolverFound("Secret".to_string()))?;
135                resolver.resolve_to_string(value).await
136            }
137
138            ConfigValue::EnvironmentVariable { .. } => {
139                let resolver = self.resolvers.get("EnvironmentVariable").ok_or_else(|| {
140                    ResolverError::NoResolverFound("EnvironmentVariable".to_string())
141                })?;
142                resolver.resolve_to_string(value).await
143            }
144        }
145    }
146
147    /// Resolve a `ConfigValue<T>` to its typed value.
148    ///
149    /// For `Static` values, returns the value directly. For `EnvironmentVariable` and
150    /// `Secret` references, resolves to a string first, then parses to `T` via [`FromStr`].
151    pub async fn resolve_typed<T>(&self, value: &ConfigValue<T>) -> Result<T, ResolverError>
152    where
153        T: FromStr + Clone + serde::Serialize + serde::de::DeserializeOwned,
154        T::Err: std::fmt::Display,
155    {
156        match value {
157            ConfigValue::Static(v) => Ok(v.clone()),
158
159            ConfigValue::Secret { name } => {
160                let resolver = self
161                    .resolvers
162                    .get("Secret")
163                    .ok_or_else(|| ResolverError::NoResolverFound("Secret".to_string()))?;
164                let string_cv = ConfigValue::Secret { name: name.clone() };
165                let string_val = resolver.resolve_to_string(&string_cv).await?;
166                string_val.parse::<T>().map_err(|e| {
167                    ResolverError::ParseError(format!("Failed to parse secret '{name}': {e}"))
168                })
169            }
170
171            ConfigValue::EnvironmentVariable { name, default } => {
172                let string_val = std::env::var(name).or_else(|_| {
173                    default
174                        .clone()
175                        .ok_or_else(|| ResolverError::EnvVarNotFound(name.clone()))
176                })?;
177
178                string_val.parse::<T>().map_err(|e| {
179                    ResolverError::ParseError(format!("Failed to parse env var '{name}': {e}"))
180                })
181            }
182        }
183    }
184
185    /// Resolve an optional `ConfigValue<T>`. Returns `Ok(None)` if the value is `None`.
186    pub async fn resolve_optional<T>(
187        &self,
188        value: &Option<ConfigValue<T>>,
189    ) -> Result<Option<T>, ResolverError>
190    where
191        T: FromStr + Clone + serde::Serialize + serde::de::DeserializeOwned,
192        T::Err: std::fmt::Display,
193    {
194        match value {
195            Some(v) => self.resolve_typed(v).await.map(Some),
196            None => Ok(None),
197        }
198    }
199
200    /// Resolve an optional `ConfigValue<String>` to `Option<String>`.
201    pub async fn resolve_optional_string(
202        &self,
203        value: &Option<ConfigValue<String>>,
204    ) -> Result<Option<String>, ResolverError> {
205        match value {
206            Some(v) => self.resolve_string(v).await.map(Some),
207            None => Ok(None),
208        }
209    }
210
211    /// Resolve a slice of `ConfigValue<String>` to `Vec<String>`.
212    pub async fn resolve_string_vec(
213        &self,
214        values: &[ConfigValue<String>],
215    ) -> Result<Vec<String>, ResolverError> {
216        let mut result = Vec::with_capacity(values.len());
217        for v in values {
218            result.push(self.resolve_string(v).await?);
219        }
220        Ok(result)
221    }
222}
223
224impl Default for DtoMapper {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[tokio::test]
235    async fn test_resolve_string_static() {
236        let mapper = DtoMapper::new();
237        let value = ConfigValue::Static("hello".to_string());
238
239        let result = mapper.resolve_string(&value).await.expect("resolve");
240        assert_eq!(result, "hello");
241    }
242
243    #[tokio::test]
244    async fn test_resolve_string_env_var() {
245        std::env::set_var("TEST_SDK_MAPPER_VAR", "mapped_value");
246
247        let mapper = DtoMapper::new();
248        let value = ConfigValue::EnvironmentVariable {
249            name: "TEST_SDK_MAPPER_VAR".to_string(),
250            default: None,
251        };
252
253        let result = mapper.resolve_string(&value).await.expect("resolve");
254        assert_eq!(result, "mapped_value");
255
256        std::env::remove_var("TEST_SDK_MAPPER_VAR");
257    }
258
259    #[tokio::test]
260    async fn test_resolve_typed_u16() {
261        let mapper = DtoMapper::new();
262        let value = ConfigValue::Static(5432u16);
263
264        let result = mapper.resolve_typed(&value).await.expect("resolve");
265        assert_eq!(result, 5432u16);
266    }
267
268    #[tokio::test]
269    async fn test_resolve_typed_u16_from_env() {
270        std::env::set_var("TEST_SDK_PORT", "8080");
271
272        let mapper = DtoMapper::new();
273        let value: ConfigValue<u16> = ConfigValue::EnvironmentVariable {
274            name: "TEST_SDK_PORT".to_string(),
275            default: None,
276        };
277
278        let result = mapper.resolve_typed(&value).await.expect("resolve");
279        assert_eq!(result, 8080u16);
280
281        std::env::remove_var("TEST_SDK_PORT");
282    }
283
284    #[tokio::test]
285    async fn test_resolve_typed_parse_error() {
286        std::env::set_var("TEST_SDK_INVALID_PORT", "not_a_number");
287
288        let mapper = DtoMapper::new();
289        let value: ConfigValue<u16> = ConfigValue::EnvironmentVariable {
290            name: "TEST_SDK_INVALID_PORT".to_string(),
291            default: None,
292        };
293
294        let result = mapper.resolve_typed(&value).await;
295        assert!(result.is_err());
296        assert!(matches!(
297            result.expect_err("should fail"),
298            ResolverError::ParseError(_)
299        ));
300
301        std::env::remove_var("TEST_SDK_INVALID_PORT");
302    }
303
304    #[tokio::test]
305    async fn test_resolve_optional_some() {
306        let mapper = DtoMapper::new();
307        let value = Some(ConfigValue::Static("test".to_string()));
308
309        let result = mapper.resolve_optional(&value).await.expect("resolve");
310        assert_eq!(result, Some("test".to_string()));
311    }
312
313    #[tokio::test]
314    async fn test_resolve_optional_none() {
315        let mapper = DtoMapper::new();
316        let value: Option<ConfigValue<String>> = None;
317
318        let result = mapper.resolve_optional(&value).await.expect("resolve");
319        assert_eq!(result, None);
320    }
321
322    #[tokio::test]
323    async fn test_resolve_string_vec() {
324        let mapper = DtoMapper::new();
325        let values = vec![
326            ConfigValue::Static("a".to_string()),
327            ConfigValue::Static("b".to_string()),
328        ];
329
330        let result = mapper.resolve_string_vec(&values).await.expect("resolve");
331        assert_eq!(result, vec!["a", "b"]);
332    }
333
334    #[tokio::test]
335    async fn test_custom_resolver() {
336        struct AlwaysResolver;
337        #[async_trait::async_trait]
338        impl ValueResolver for AlwaysResolver {
339            async fn resolve_to_string(
340                &self,
341                _value: &ConfigValue<String>,
342            ) -> Result<String, ResolverError> {
343                Ok("custom-resolved".to_string())
344            }
345        }
346
347        let mapper = DtoMapper::new().with_resolver("Secret", Arc::new(AlwaysResolver));
348        let value = ConfigValue::Secret {
349            name: "test".to_string(),
350        };
351
352        let result = mapper.resolve_string(&value).await.expect("resolve");
353        assert_eq!(result, "custom-resolved");
354    }
355}