Skip to main content

drasi_plugin_sdk/
config_value.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//! Configuration value types that support static values or dynamic references.
16//!
17//! [`ConfigValue<T>`] is the core type used in plugin DTOs for configuration fields that
18//! may be provided as static values, environment variable references, or secret references.
19//!
20//! # Supported Formats
21//!
22//! Each `ConfigValue<T>` field can be specified in three formats:
23//!
24//! ## Static Value
25//! A plain value of type `T`:
26//! ```yaml
27//! port: 5432
28//! host: "localhost"
29//! ```
30//!
31//! ## POSIX Environment Variable
32//! A `${VAR}` or `${VAR:-default}` reference:
33//! ```yaml
34//! port: "${DB_PORT:-5432}"
35//! host: "${DB_HOST}"
36//! ```
37//!
38//! ## Structured Reference
39//! An object with a `kind` discriminator:
40//! ```yaml
41//! password:
42//!   kind: Secret
43//!   name: db-password
44//! port:
45//!   kind: EnvironmentVariable
46//!   name: DB_PORT
47//!   default: "5432"
48//! ```
49//!
50//! # Usage in Plugin DTOs
51//!
52//! ```rust,ignore
53//! use drasi_plugin_sdk::prelude::*;
54//!
55//! #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
56//! #[serde(rename_all = "camelCase")]
57//! pub struct MySourceConfigDto {
58//!     /// Database host
59//!     #[schema(value_type = ConfigValueString)]
60//!     pub host: ConfigValue<String>,
61//!
62//!     /// Database port
63//!     #[schema(value_type = ConfigValueU16)]
64//!     pub port: ConfigValue<u16>,
65//!
66//!     /// Optional connection timeout in milliseconds
67//!     #[serde(skip_serializing_if = "Option::is_none")]
68//!     #[schema(value_type = Option<ConfigValueU32>)]
69//!     pub timeout_ms: Option<ConfigValue<u32>>,
70//! }
71//! ```
72
73use serde::{de::DeserializeOwned, Deserialize, Serialize};
74
75/// A configuration value that can be a static value or a reference to be resolved at runtime.
76///
77/// This enum is the building block for plugin configuration DTOs. It supports three variants:
78///
79/// - **Static** — A plain value of type `T`, provided directly in the configuration.
80/// - **Secret** — A reference to a named secret, resolved at runtime by the server.
81/// - **EnvironmentVariable** — A reference to an environment variable, with an optional default.
82///
83/// # Serialization
84///
85/// - `Static` values serialize as the plain `T` value.
86/// - `Secret` serializes as `{"kind": "Secret", "name": "..."}`.
87/// - `EnvironmentVariable` serializes as `{"kind": "EnvironmentVariable", "name": "...", "default": "..."}`.
88///
89/// # Deserialization
90///
91/// Supports three input formats (see module docs for examples):
92/// 1. Plain value → `Static(T)`
93/// 2. POSIX `${VAR:-default}` string → `EnvironmentVariable { name, default }`
94/// 3. Structured `{"kind": "...", ...}` → `Secret` or `EnvironmentVariable`
95#[derive(Debug, Clone, PartialEq)]
96pub enum ConfigValue<T>
97where
98    T: Serialize + DeserializeOwned + Clone,
99{
100    /// A reference to a secret (resolved to string at runtime, then parsed to `T`).
101    Secret { name: String },
102
103    /// A reference to an environment variable.
104    EnvironmentVariable {
105        name: String,
106        default: Option<String>,
107    },
108
109    /// A static value of type `T`.
110    Static(T),
111}
112
113/// Type alias for `ConfigValue<String>` — the most common config value type.
114pub type ConfigValueString = ConfigValue<String>;
115/// Type alias for `ConfigValue<u16>` — commonly used for port numbers.
116pub type ConfigValueU16 = ConfigValue<u16>;
117/// Type alias for `ConfigValue<u32>` — commonly used for timeouts and counts.
118pub type ConfigValueU32 = ConfigValue<u32>;
119/// Type alias for `ConfigValue<u64>` — commonly used for large numeric values.
120pub type ConfigValueU64 = ConfigValue<u64>;
121/// Type alias for `ConfigValue<usize>` — commonly used for sizes and capacities.
122pub type ConfigValueUsize = ConfigValue<usize>;
123/// Type alias for `ConfigValue<bool>` — commonly used for feature flags.
124pub type ConfigValueBool = ConfigValue<bool>;
125
126/// OpenAPI schema wrapper for [`ConfigValueString`].
127///
128/// Use this as `#[schema(value_type = ConfigValueString)]` in utoipa-annotated DTOs.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
130#[schema(as = ConfigValueString)]
131pub struct ConfigValueStringSchema(pub ConfigValueString);
132
133/// OpenAPI schema wrapper for [`ConfigValueU16`].
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
135#[schema(as = ConfigValueU16)]
136pub struct ConfigValueU16Schema(pub ConfigValueU16);
137
138/// OpenAPI schema wrapper for [`ConfigValueU32`].
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
140#[schema(as = ConfigValueU32)]
141pub struct ConfigValueU32Schema(pub ConfigValueU32);
142
143/// OpenAPI schema wrapper for [`ConfigValueU64`].
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
145#[schema(as = ConfigValueU64)]
146pub struct ConfigValueU64Schema(pub ConfigValueU64);
147
148/// OpenAPI schema wrapper for [`ConfigValueUsize`].
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
150#[schema(as = ConfigValueUsize)]
151pub struct ConfigValueUsizeSchema(pub ConfigValueUsize);
152
153/// OpenAPI schema wrapper for [`ConfigValueBool`].
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
155#[schema(as = ConfigValueBool)]
156pub struct ConfigValueBoolSchema(pub ConfigValueBool);
157
158// Custom serialization to support the discriminated union format
159impl<T> Serialize for ConfigValue<T>
160where
161    T: Serialize + DeserializeOwned + Clone,
162{
163    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
164    where
165        S: serde::Serializer,
166    {
167        use serde::ser::SerializeMap;
168
169        match self {
170            ConfigValue::Secret { name } => {
171                let mut map = serializer.serialize_map(Some(2))?;
172                map.serialize_entry("kind", "Secret")?;
173                map.serialize_entry("name", name)?;
174                map.end()
175            }
176            ConfigValue::EnvironmentVariable { name, default } => {
177                let size = if default.is_some() { 3 } else { 2 };
178                let mut map = serializer.serialize_map(Some(size))?;
179                map.serialize_entry("kind", "EnvironmentVariable")?;
180                map.serialize_entry("name", name)?;
181                if let Some(d) = default {
182                    map.serialize_entry("default", d)?;
183                }
184                map.end()
185            }
186            ConfigValue::Static(value) => value.serialize(serializer),
187        }
188    }
189}
190
191// Custom deserialization to support POSIX format, structured format, and static values
192impl<'de, T> Deserialize<'de> for ConfigValue<T>
193where
194    T: Serialize + DeserializeOwned + Clone + 'static,
195{
196    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
197    where
198        D: serde::Deserializer<'de>,
199    {
200        use serde::de::Error;
201        use serde_json::Value;
202
203        let value = Value::deserialize(deserializer)?;
204
205        // Try to deserialize as structured format with "kind" field
206        if let Value::Object(ref map) = value {
207            if let Some(Value::String(kind)) = map.get("kind") {
208                match kind.as_str() {
209                    "Secret" => {
210                        let name = map
211                            .get("name")
212                            .and_then(|v| v.as_str())
213                            .ok_or_else(|| D::Error::missing_field("name"))?
214                            .to_string();
215
216                        return Ok(ConfigValue::Secret { name });
217                    }
218                    "EnvironmentVariable" => {
219                        let name = map
220                            .get("name")
221                            .and_then(|v| v.as_str())
222                            .ok_or_else(|| D::Error::missing_field("name"))?
223                            .to_string();
224
225                        let default = map
226                            .get("default")
227                            .and_then(|v| v.as_str())
228                            .map(|s| s.to_string());
229
230                        return Ok(ConfigValue::EnvironmentVariable { name, default });
231                    }
232                    _ => {
233                        return Err(D::Error::custom(format!("Unknown kind: {kind}")));
234                    }
235                }
236            }
237        }
238
239        // Try to parse POSIX format for any type (the string will be parsed to T later)
240        if let Value::String(s) = &value {
241            if let Some(env_ref) = parse_posix_env_var(s) {
242                return Ok(env_ref);
243            }
244        }
245
246        // Otherwise, deserialize as static value
247        let static_value: T = serde_json::from_value(value)
248            .map_err(|e| D::Error::custom(format!("Failed to deserialize as static value: {e}")))?;
249
250        Ok(ConfigValue::Static(static_value))
251    }
252}
253
254/// Parse POSIX-style environment variable reference like `${VAR:-default}` or `${VAR}`.
255fn parse_posix_env_var<T>(s: &str) -> Option<ConfigValue<T>>
256where
257    T: Clone + Serialize + DeserializeOwned,
258{
259    if !s.starts_with("${") || !s.ends_with('}') {
260        return None;
261    }
262
263    let inner = &s[2..s.len() - 1];
264
265    if let Some(colon_pos) = inner.find(":-") {
266        let name = inner[..colon_pos].to_string();
267        let default = Some(inner[colon_pos + 2..].to_string());
268        Some(ConfigValue::EnvironmentVariable { name, default })
269    } else {
270        let name = inner.to_string();
271        Some(ConfigValue::EnvironmentVariable {
272            name,
273            default: None,
274        })
275    }
276}
277
278impl<T> Default for ConfigValue<T>
279where
280    T: Serialize + DeserializeOwned + Clone + Default,
281{
282    fn default() -> Self {
283        ConfigValue::Static(T::default())
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_deserialize_static_string() {
293        let json = r#""hello""#;
294        let value: ConfigValue<String> = serde_json::from_str(json).expect("deserialize");
295        assert_eq!(value, ConfigValue::Static("hello".to_string()));
296    }
297
298    #[test]
299    fn test_deserialize_static_number() {
300        let json = r#"5432"#;
301        let value: ConfigValue<u16> = serde_json::from_str(json).expect("deserialize");
302        assert_eq!(value, ConfigValue::Static(5432));
303    }
304
305    #[test]
306    fn test_deserialize_posix_with_default() {
307        let json = r#""${DB_PORT:-5432}""#;
308        let value: ConfigValue<String> = serde_json::from_str(json).expect("deserialize");
309        match value {
310            ConfigValue::EnvironmentVariable { name, default } => {
311                assert_eq!(name, "DB_PORT");
312                assert_eq!(default, Some("5432".to_string()));
313            }
314            _ => panic!("Expected EnvironmentVariable"),
315        }
316    }
317
318    #[test]
319    fn test_deserialize_posix_without_default() {
320        let json = r#""${DB_HOST}""#;
321        let value: ConfigValue<String> = serde_json::from_str(json).expect("deserialize");
322        match value {
323            ConfigValue::EnvironmentVariable { name, default } => {
324                assert_eq!(name, "DB_HOST");
325                assert_eq!(default, None);
326            }
327            _ => panic!("Expected EnvironmentVariable"),
328        }
329    }
330
331    #[test]
332    fn test_deserialize_structured_secret() {
333        let json = r#"{"kind": "Secret", "name": "db-password"}"#;
334        let value: ConfigValue<String> = serde_json::from_str(json).expect("deserialize");
335        match value {
336            ConfigValue::Secret { name } => assert_eq!(name, "db-password"),
337            _ => panic!("Expected Secret"),
338        }
339    }
340
341    #[test]
342    fn test_deserialize_structured_env() {
343        let json = r#"{"kind": "EnvironmentVariable", "name": "DB_HOST", "default": "localhost"}"#;
344        let value: ConfigValue<String> = serde_json::from_str(json).expect("deserialize");
345        match value {
346            ConfigValue::EnvironmentVariable { name, default } => {
347                assert_eq!(name, "DB_HOST");
348                assert_eq!(default, Some("localhost".to_string()));
349            }
350            _ => panic!("Expected EnvironmentVariable"),
351        }
352    }
353
354    #[test]
355    fn test_serialize_static() {
356        let value = ConfigValue::Static("hello".to_string());
357        let json = serde_json::to_string(&value).expect("serialize");
358        assert_eq!(json, r#""hello""#);
359    }
360
361    #[test]
362    fn test_serialize_env_var() {
363        let value: ConfigValue<String> = ConfigValue::EnvironmentVariable {
364            name: "DB_HOST".to_string(),
365            default: Some("localhost".to_string()),
366        };
367        let json = serde_json::to_string(&value).expect("serialize");
368        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse json");
369        assert_eq!(parsed["kind"], "EnvironmentVariable");
370        assert_eq!(parsed["name"], "DB_HOST");
371        assert_eq!(parsed["default"], "localhost");
372    }
373
374    #[test]
375    fn test_serialize_secret() {
376        let value: ConfigValue<String> = ConfigValue::Secret {
377            name: "my-secret".to_string(),
378        };
379        let json = serde_json::to_string(&value).expect("serialize");
380        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse json");
381        assert_eq!(parsed["kind"], "Secret");
382        assert_eq!(parsed["name"], "my-secret");
383    }
384
385    #[test]
386    fn test_roundtrip_static() {
387        let original = ConfigValue::Static(42u16);
388        let json = serde_json::to_string(&original).expect("serialize");
389        let deserialized: ConfigValue<u16> = serde_json::from_str(&json).expect("deserialize");
390        assert_eq!(original, deserialized);
391    }
392
393    #[test]
394    fn test_default() {
395        let value: ConfigValue<String> = ConfigValue::default();
396        assert_eq!(value, ConfigValue::Static(String::default()));
397    }
398}