wasmcloud_provider_keyvalue_nats/
config.rs

1use std::collections::HashMap;
2
3use anyhow::{bail, Result};
4use serde::{Deserialize, Serialize};
5
6use tracing::warn;
7use wasmcloud_provider_sdk::core::secrets::SecretValue;
8
9const DEFAULT_NATS_URI: &str = "nats://0.0.0.0:4222";
10
11const CONFIG_NATS_URI: &str = "cluster_uri";
12const CONFIG_NATS_JETSTREAM_DOMAIN: &str = "js_domain";
13const CONFIG_NATS_KV_STORE: &str = "bucket";
14const CONFIG_NATS_CLIENT_JWT: &str = "client_jwt";
15const CONFIG_NATS_CLIENT_SEED: &str = "client_seed";
16const CONFIG_NATS_TLS_CA: &str = "tls_ca";
17const CONFIG_NATS_TLS_CA_FILE: &str = "tls_ca_file";
18
19/// Configuration for connecting a NATS client.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct NatsConnectionConfig {
22    /// Cluster(s) to connect to
23    #[serde(default)]
24    pub cluster_uri: Option<String>,
25
26    /// JetStream Domain to connect to
27    #[serde(default)]
28    pub js_domain: Option<String>,
29
30    /// NATS Kv Store to open
31    #[serde(default)]
32    pub bucket: String,
33
34    /// Auth JWT to use (if necessary)
35    #[serde(default)]
36    pub auth_jwt: Option<String>,
37
38    /// Auth seed to use (if necessary)
39    #[serde(default)]
40    pub auth_seed: Option<String>,
41
42    /// TLS Certificate Authority, encoded as a string
43    #[serde(default)]
44    pub tls_ca: Option<String>,
45
46    /// TLS Certificate Authority, as a path on disk
47    #[serde(default)]
48    pub tls_ca_file: Option<String>,
49}
50
51impl NatsConnectionConfig {
52    /// Merge a given [`NatsConnectionConfig`] with another, coalescing fields and overriding
53    /// where necessary
54    pub fn merge(&self, extra: &NatsConnectionConfig) -> NatsConnectionConfig {
55        let mut out = self.clone();
56        // If the default configuration has a URI in it, and then the link definition
57        // also provides a URI, the assumption is to replace/override rather than combine
58        // the two into a potentially incompatible set of URIs
59        if extra.cluster_uri.is_some() {
60            out.cluster_uri.clone_from(&extra.cluster_uri);
61        }
62        if extra.js_domain.is_some() {
63            out.js_domain.clone_from(&extra.js_domain);
64        }
65        if !extra.bucket.is_empty() {
66            out.bucket.clone_from(&extra.bucket);
67        }
68        if extra.auth_jwt.is_some() {
69            out.auth_jwt.clone_from(&extra.auth_jwt);
70        }
71        if extra.auth_seed.is_some() {
72            out.auth_seed.clone_from(&extra.auth_seed);
73        }
74        if extra.tls_ca.is_some() {
75            out.tls_ca.clone_from(&extra.tls_ca);
76        }
77        if extra.tls_ca_file.is_some() {
78            out.tls_ca_file.clone_from(&extra.tls_ca_file);
79        }
80        out
81    }
82}
83
84/// Default implementation for [`NatsConnectionConfig`]
85impl Default for NatsConnectionConfig {
86    fn default() -> NatsConnectionConfig {
87        NatsConnectionConfig {
88            cluster_uri: Some(DEFAULT_NATS_URI.into()),
89            js_domain: None,
90            bucket: String::new(),
91            auth_jwt: None,
92            auth_seed: None,
93            tls_ca: None,
94            tls_ca_file: None,
95        }
96    }
97}
98
99impl NatsConnectionConfig {
100    /// Construct a [`NatsConnectionConfig`] from a given [`HashMap`] (normally containing a combination of config and secrets)
101    ///
102    /// Do not use this directly, but instead use [`NatsConnectionConfig::from_link_config`] instead
103    pub fn from_map(values: &HashMap<String, String>) -> Result<NatsConnectionConfig> {
104        let mut config = NatsConnectionConfig::default();
105
106        if let Some(uri) = values.get(CONFIG_NATS_URI) {
107            config.cluster_uri = Some(uri.clone());
108        }
109        if let Some(domain) = values.get(CONFIG_NATS_JETSTREAM_DOMAIN) {
110            config.js_domain = Some(domain.clone());
111        }
112        if let Some(bucket) = values.get(CONFIG_NATS_KV_STORE) {
113            config.bucket.clone_from(bucket);
114        } else {
115            bail!(
116                "missing required configuration item: {}",
117                CONFIG_NATS_KV_STORE
118            );
119        }
120        if let Some(jwt) = values.get(CONFIG_NATS_CLIENT_JWT) {
121            config.auth_jwt = Some(jwt.clone());
122        }
123        if let Some(seed) = values.get(CONFIG_NATS_CLIENT_SEED) {
124            config.auth_seed = Some(seed.clone());
125        }
126        if let Some(tls_ca) = values.get(CONFIG_NATS_TLS_CA) {
127            config.tls_ca = Some(tls_ca.clone());
128        } else if let Some(tls_ca_file) = values.get(CONFIG_NATS_TLS_CA_FILE) {
129            config.tls_ca_file = Some(tls_ca_file.clone());
130        }
131        if config.auth_jwt.is_some() && config.auth_seed.is_none() {
132            bail!("if you specify jwt, you must also specify a seed");
133        }
134
135        Ok(config)
136    }
137
138    /// Construct configuration  from a given [`LinkConfig`], utilizing both config and secrets provided
139    pub fn from_config_and_secrets(
140        config: &HashMap<String, String>,
141        secrets: &HashMap<String, SecretValue>,
142    ) -> Result<NatsConnectionConfig> {
143        let mut map = HashMap::clone(config);
144
145        if let Some(jwt) = secrets
146            .get(CONFIG_NATS_CLIENT_JWT)
147            .and_then(SecretValue::as_string)
148            .or_else(|| config.get(CONFIG_NATS_CLIENT_JWT).map(String::as_str))
149        {
150            if secrets.get(CONFIG_NATS_CLIENT_JWT).is_none() {
151                warn!("secret value [{CONFIG_NATS_CLIENT_JWT}] was missing, but was found configuration. Please prefer using secrets for sensitive values.");
152            }
153            map.insert(CONFIG_NATS_CLIENT_JWT.into(), jwt.to_string());
154        }
155
156        if let Some(seed) = secrets
157            .get(CONFIG_NATS_CLIENT_SEED)
158            .and_then(SecretValue::as_string)
159            .or_else(|| config.get(CONFIG_NATS_CLIENT_SEED).map(String::as_str))
160        {
161            if secrets.get(CONFIG_NATS_CLIENT_SEED).is_none() {
162                warn!("secret value [{CONFIG_NATS_CLIENT_SEED}] was missing, but was found configuration. Please prefer using secrets for sensitive values.");
163            }
164            map.insert(CONFIG_NATS_CLIENT_SEED.into(), seed.to_string());
165        }
166
167        if let Some(tls_ca) = secrets
168            .get(CONFIG_NATS_TLS_CA)
169            .and_then(SecretValue::as_string)
170            .or_else(|| config.get(CONFIG_NATS_TLS_CA).map(String::as_str))
171        {
172            if secrets.get(CONFIG_NATS_TLS_CA).is_none() {
173                warn!("secret value [{CONFIG_NATS_TLS_CA}] was missing, but was found configuration. Please prefer using secrets for sensitive values.");
174            }
175            map.insert(CONFIG_NATS_TLS_CA.into(), tls_ca.to_string());
176        }
177
178        Self::from_map(&map)
179    }
180}
181
182// Performing various provider configuration tests
183#[cfg(test)]
184mod test {
185    use super::*;
186    use std::collections::HashMap;
187
188    // Verify that a NatsConnectionConfig could be constructed from partial input
189    #[test]
190    fn test_default_connection_serialize() {
191        let input = r#"
192{
193    "cluster_uri": "nats://super-cluster",
194    "js_domain": "optional",
195    "bucket": "kv_store",
196    "auth_jwt": "authy",
197    "auth_seed": "seedy"
198}
199"#;
200
201        let config: NatsConnectionConfig = serde_json::from_str(input).unwrap();
202        assert_eq!(config.cluster_uri, Some("nats://super-cluster".to_string()));
203        assert_eq!(config.js_domain, Some("optional".to_string()));
204        assert_eq!(config.bucket, "kv_store");
205        assert_eq!(config.auth_jwt.unwrap(), "authy");
206        assert_eq!(config.auth_seed.unwrap(), "seedy");
207    }
208
209    // Verify that two NatsConnectionConfigs could be merged
210    #[test]
211    fn test_connectionconfig_merge() {
212        let ncc1 = NatsConnectionConfig {
213            cluster_uri: Some("old_server".to_string()),
214            ..Default::default()
215        };
216        let ncc2 = NatsConnectionConfig {
217            cluster_uri: Some("server1".to_string()),
218            js_domain: Some("new_domain".to_string()),
219            bucket: "new_bucket".to_string(),
220            auth_jwt: Some("jawty".to_string()),
221            ..Default::default()
222        };
223        let ncc3 = ncc1.merge(&ncc2);
224        assert_eq!(ncc3.cluster_uri, ncc2.cluster_uri);
225        assert_eq!(ncc3.js_domain, ncc2.js_domain);
226        assert_eq!(ncc3.bucket, ncc2.bucket);
227        assert_eq!(ncc3.auth_jwt, Some("jawty".to_string()));
228    }
229
230    // Verify that a NatsConnectionConfig could be constructed from a HashMap
231    #[test]
232    fn test_from_map_multiple_entries() -> anyhow::Result<()> {
233        const CONFIG_NATS_CLIENT_JWT: &str = "client_jwt";
234        const CONFIG_NATS_CLIENT_SEED: &str = "client_seed";
235        let ncc = NatsConnectionConfig::from_map(&HashMap::from([
236            ("tls_ca".to_string(), "rootCA".to_string()),
237            ("js_domain".to_string(), "optional".to_string()),
238            ("bucket".to_string(), "kv_store".to_string()),
239            (CONFIG_NATS_CLIENT_JWT.to_string(), "authy".to_string()),
240            (CONFIG_NATS_CLIENT_SEED.to_string(), "seedy".to_string()),
241        ]))?;
242        assert_eq!(ncc.tls_ca, Some("rootCA".to_string()));
243        assert_eq!(ncc.js_domain, Some("optional".to_string()));
244        assert_eq!(ncc.bucket, "kv_store");
245        assert_eq!(ncc.auth_jwt, Some("authy".to_string()));
246        assert_eq!(ncc.auth_seed, Some("seedy".to_string()));
247        Ok(())
248    }
249
250    // Verify that a default NatsConnectionConfig will be constructed from an empty HashMap
251    #[test]
252    fn test_from_map_empty() {
253        let ncc = NatsConnectionConfig::from_map(&HashMap::new());
254        assert!(ncc.is_err());
255    }
256
257    // Verify that a NatsConnectionConfig will be constructed from an empty HashMap, plus a required bucket
258    #[test]
259    fn test_from_map_with_minimal_valid_bucket() -> anyhow::Result<()> {
260        let mut map = HashMap::new();
261        map.insert("bucket".to_string(), "some_bucket_value".to_string()); // Providing a minimal valid 'bucket' attribute
262        let ncc = NatsConnectionConfig::from_map(&map)?;
263        assert_eq!(ncc.bucket, "some_bucket_value".to_string());
264        Ok(())
265    }
266
267    // Verify that the NatsConnectionConfig's merge function prioritizes the new values over the old ones
268    #[test]
269    fn test_merge_non_default_values() {
270        let ncc1 = NatsConnectionConfig {
271            cluster_uri: Some("old_server".to_string()),
272            js_domain: Some("old_domain".to_string()),
273            bucket: "old_bucket".to_string(),
274            auth_jwt: Some("old_jawty".to_string()),
275            ..Default::default()
276        };
277        let ncc2 = NatsConnectionConfig {
278            cluster_uri: Some("server1".to_string()),
279            js_domain: Some("new_domain".to_string()),
280            bucket: "kv_store".to_string(),
281            auth_jwt: Some("new_jawty".to_string()),
282            ..Default::default()
283        };
284        let ncc3 = ncc1.merge(&ncc2);
285        assert_eq!(ncc3.cluster_uri, ncc2.cluster_uri);
286        assert_eq!(ncc3.js_domain, ncc2.js_domain);
287        assert_eq!(ncc3.bucket, ncc2.bucket);
288        assert_eq!(ncc3.auth_jwt, ncc2.auth_jwt);
289    }
290}