Skip to main content

temporalio_client/
envconfig.rs

1//! Conversion from [`temporalio_common::envconfig::ClientConfigProfile`] to [`ConnectionOptions`] and [`ClientOptions`].
2//!
3//! This module bridges the environment/file-based configuration in `temporalio-common` with
4//! the client connection types.
5
6use std::{collections::HashMap, fs};
7use url::Url;
8
9pub use temporalio_common::envconfig::{
10    ClientConfigProfile, ConfigError, DataSource, LoadClientConfigProfileOptions,
11};
12use temporalio_common::envconfig::{ClientConfigTLS, load_client_config_profile};
13
14use crate::{ClientOptions, ClientTlsOptions, ConnectionOptions, TlsOptions};
15
16const DEFAULT_ADDRESS: &str = "http://localhost:7233";
17const DEFAULT_NAMESPACE: &str = "default";
18
19impl ClientOptions {
20    /// Load client and connection options from environment variables and/or a TOML config file.
21    pub fn load_from_config(
22        options: LoadClientConfigProfileOptions,
23    ) -> Result<(ConnectionOptions, ClientOptions), ConfigError> {
24        load_from_config_with_env(options, None)
25    }
26}
27
28// Separate function allows injecting env vars for testing.
29fn load_from_config_with_env(
30    options: LoadClientConfigProfileOptions,
31    env_vars: Option<&HashMap<String, String>>,
32) -> Result<(ConnectionOptions, ClientOptions), ConfigError> {
33    let profile = load_client_config_profile(options, env_vars)?;
34    let namespace = profile
35        .namespace
36        .clone()
37        .unwrap_or_else(|| DEFAULT_NAMESPACE.to_owned());
38    let conn_opts = ConnectionOptions::try_from(profile)?;
39    let client_opts = ClientOptions::new(namespace).build();
40    Ok((conn_opts, client_opts))
41}
42
43/// Parse an address string into a [`Url`], prepending a scheme if none is present.
44///
45/// Other SDKs pass addresses as bare `host:port` strings. Our [`ConnectionOptions`] requires a
46/// [`Url`], so we attempt a direct parse first and fall back to prepending a scheme.
47/// When the user omits a scheme, we use `https://` if TLS will be enabled, otherwise `http://`.
48fn parse_address(address: &str, use_tls: bool) -> Result<Url, ConfigError> {
49    // Try parsing as-is. `Url::parse("localhost:7233")` "succeeds" by treating `localhost` as
50    // the scheme, so reject parses that have no host — those need a scheme prefix.
51    if let Ok(url) = Url::parse(address)
52        && url.host().is_some()
53    {
54        return Ok(url);
55    }
56    let scheme = if use_tls { "https" } else { "http" };
57    Url::parse(&format!("{scheme}://{address}"))
58        .map_err(|e| ConfigError::InvalidConfig(format!("Invalid address: {e}")))
59}
60
61/// Build [`TlsOptions`] from a [`ClientConfigTLS`] config, resolving any file-based data sources.
62fn build_tls_options(tls: ClientConfigTLS) -> Result<TlsOptions, ConfigError> {
63    let client_tls_options = match (tls.client_cert, tls.client_key) {
64        (Some(cert), Some(key)) => {
65            let cert_bytes =
66                resolve_datasource(cert).map_err(|e| ConfigError::LoadError(e.into()))?;
67            let key_bytes =
68                resolve_datasource(key).map_err(|e| ConfigError::LoadError(e.into()))?;
69            Some(
70                ClientTlsOptions::builder()
71                    .client_cert(cert_bytes)
72                    .client_private_key(key_bytes)
73                    .build(),
74            )
75        }
76        (Some(_), None) | (None, Some(_)) => {
77            return Err(ConfigError::InvalidConfig(
78                "Both client certificate and client key must be provided together".to_string(),
79            ));
80        }
81        (None, None) => None,
82    };
83
84    let server_root_ca_cert = tls
85        .server_ca_cert
86        .map(resolve_datasource)
87        .transpose()
88        .map_err(|e| ConfigError::LoadError(e.into()))?;
89
90    Ok(TlsOptions::builder()
91        .maybe_server_root_ca_cert(server_root_ca_cert)
92        .maybe_domain(tls.server_name)
93        .maybe_client_tls_options(client_tls_options)
94        .build())
95}
96
97/// Determine whether TLS should be enabled based on the profile's TLS config and API key.
98///
99/// TLS is enabled when:
100/// - There is a TLS section that is not explicitly disabled, OR
101/// - An API key is set and TLS is not explicitly disabled
102fn should_enable_tls(tls: &Option<ClientConfigTLS>, has_api_key: bool) -> bool {
103    match tls {
104        Some(t) => t.disabled != Some(true),
105        None => has_api_key,
106    }
107}
108
109impl TryFrom<ClientConfigProfile> for ConnectionOptions {
110    type Error = ConfigError;
111
112    fn try_from(profile: ClientConfigProfile) -> Result<Self, Self::Error> {
113        let ClientConfigProfile {
114            address,
115            namespace: _,
116            api_key,
117            tls,
118            codec: _,
119            grpc_meta,
120            ..
121        } = profile;
122
123        let has_api_key = api_key.is_some();
124        let use_tls = should_enable_tls(&tls, has_api_key);
125        let target = parse_address(address.as_deref().unwrap_or(DEFAULT_ADDRESS), use_tls)?;
126
127        let tls_options = if use_tls {
128            match tls {
129                Some(tls_cfg) => Some(build_tls_options(tls_cfg)?),
130                None => Some(TlsOptions::default()),
131            }
132        } else {
133            None
134        };
135
136        let headers = (!grpc_meta.is_empty()).then_some(grpc_meta);
137
138        Ok(ConnectionOptions::new(target)
139            .maybe_api_key(api_key)
140            .maybe_tls_options(tls_options)
141            .maybe_headers(headers)
142            .build())
143    }
144}
145
146/// Resolve a data source to its raw bytes.
147fn resolve_datasource(data_source: DataSource) -> Result<Vec<u8>, std::io::Error> {
148    match data_source {
149        DataSource::Path(path) => fs::read(path),
150        DataSource::Data(data) => Ok(data),
151        _ => Err(std::io::Error::new(
152            std::io::ErrorKind::Unsupported,
153            "unsupported envconfig data source",
154        )),
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use rstest::{fixture, rstest};
162    use std::path::PathBuf;
163    use tempfile::TempDir;
164    use temporalio_common::envconfig::{ClientConfigTLS, DataSource};
165
166    /// Write a TOML config file into a temp directory and return (dir, path).
167    /// The `TempDir` handle keeps the directory alive; it is cleaned up on drop.
168    #[fixture]
169    fn config_dir() -> TempDir {
170        TempDir::new().unwrap()
171    }
172
173    /// Write `content` to `temporal.toml` inside `dir`, returning the file path.
174    fn write_config(dir: &TempDir, content: &str) -> PathBuf {
175        let path = dir.path().join("temporal.toml");
176        std::fs::write(&path, content).unwrap();
177        path
178    }
179
180    #[rstest]
181    #[case::default(None, false, "http://localhost:7233/")]
182    #[case::with_scheme(Some("https://my-server:7233"), false, "https://my-server:7233/")]
183    #[case::without_scheme(Some("localhost:7233"), false, "http://localhost:7233/")]
184    #[case::without_scheme_tls(Some("localhost:7233"), true, "https://localhost:7233/")]
185    #[case::explicit_http_with_tls(Some("http://my-server:7233"), true, "http://my-server:7233/")]
186    fn address_parsing(
187        #[case] address: Option<&str>,
188        #[case] enable_tls: bool,
189        #[case] expected: &str,
190    ) {
191        let tls = enable_tls.then(ClientConfigTLS::default);
192        let profile = ClientConfigProfile::builder()
193            .maybe_address(address.map(str::to_string))
194            .maybe_tls(tls)
195            .build();
196        let conn: ConnectionOptions = profile.try_into().unwrap();
197        assert_eq!(conn.target.as_str(), expected);
198    }
199
200    #[test]
201    fn invalid_address_errors() {
202        let profile = ClientConfigProfile::builder().address("://bad").build();
203        assert!(ConnectionOptions::try_from(profile).is_err());
204    }
205
206    #[test]
207    fn empty_profile_defaults() {
208        let env = HashMap::new();
209        let opts = LoadClientConfigProfileOptions::builder()
210            .disable_file(true)
211            .build();
212        let (conn, client) = load_from_config_with_env(opts, Some(&env)).unwrap();
213
214        assert_eq!(conn.target.as_str(), "http://localhost:7233/");
215        assert_eq!(client.namespace, "default");
216        assert!(conn.tls_options.is_none());
217        assert!(conn.headers.is_none());
218        assert!(conn.api_key.is_none());
219    }
220
221    #[test]
222    fn namespace_override() {
223        let mut env = HashMap::new();
224        env.insert("TEMPORAL_NAMESPACE".to_string(), "my-namespace".to_string());
225        let opts = LoadClientConfigProfileOptions::builder()
226            .disable_file(true)
227            .build();
228        let (_, client) = load_from_config_with_env(opts, Some(&env)).unwrap();
229        assert_eq!(client.namespace, "my-namespace");
230    }
231
232    #[test]
233    fn grpc_metadata_passthrough() {
234        let mut meta = HashMap::new();
235        meta.insert("x-custom".to_string(), "value".to_string());
236        meta.insert("another".to_string(), "header".to_string());
237        let profile = ClientConfigProfile::builder()
238            .grpc_meta(meta.clone())
239            .build();
240        let conn: ConnectionOptions = profile.try_into().unwrap();
241        assert_eq!(conn.headers.unwrap(), meta);
242    }
243
244    #[test]
245    fn api_key_populates_field() {
246        let profile = ClientConfigProfile::builder().api_key("my-key").build();
247        let conn: ConnectionOptions = profile.try_into().unwrap();
248        assert_eq!(conn.api_key.as_deref(), Some("my-key"));
249    }
250
251    #[rstest]
252    #[case::no_tls_no_key(None, None, false)]
253    #[case::no_tls_with_key(None, Some("key"), true)]
254    #[case::tls_disabled_false(Some(Some(false)), None, true)]
255    #[case::tls_disabled_true(Some(Some(true)), None, false)]
256    #[case::tls_disabled_none(Some(None), None, true)]
257    #[case::key_with_tls_disabled(Some(Some(true)), Some("key"), false)]
258    #[case::key_with_tls_enabled(Some(Some(false)), Some("key"), true)]
259    fn tls_enablement(
260        #[case] tls_disabled: Option<Option<bool>>,
261        #[case] api_key: Option<&str>,
262        #[case] expect_tls: bool,
263    ) {
264        let profile = ClientConfigProfile::builder()
265            .maybe_api_key(api_key.map(str::to_string))
266            .maybe_tls(
267                tls_disabled
268                    .map(|disabled| ClientConfigTLS::builder().maybe_disabled(disabled).build()),
269            )
270            .build();
271        let conn: ConnectionOptions = profile.try_into().unwrap();
272        assert_eq!(conn.tls_options.is_some(), expect_tls);
273    }
274
275    #[test]
276    fn data_source_certs() {
277        let profile = ClientConfigProfile::builder()
278            .tls(
279                ClientConfigTLS::builder()
280                    .client_cert(DataSource::Data(b"cert-data".to_vec()))
281                    .client_key(DataSource::Data(b"key-data".to_vec()))
282                    .build(),
283            )
284            .build();
285        let conn: ConnectionOptions = profile.try_into().unwrap();
286        let tls = conn.tls_options.unwrap();
287        let mtls = tls.client_tls_options.unwrap();
288        assert_eq!(mtls.client_cert, b"cert-data");
289        assert_eq!(mtls.client_private_key, b"key-data");
290    }
291
292    #[rstest]
293    fn path_source_certs(config_dir: TempDir) {
294        let cert_path = config_dir.path().join("cert.pem");
295        let key_path = config_dir.path().join("key.pem");
296        std::fs::write(&cert_path, b"file-cert").unwrap();
297        std::fs::write(&key_path, b"file-key").unwrap();
298
299        let profile = ClientConfigProfile::builder()
300            .tls(
301                ClientConfigTLS::builder()
302                    .client_cert(DataSource::Path(cert_path.to_str().unwrap().to_string()))
303                    .client_key(DataSource::Path(key_path.to_str().unwrap().to_string()))
304                    .build(),
305            )
306            .build();
307        let conn: ConnectionOptions = profile.try_into().unwrap();
308        let tls = conn.tls_options.unwrap();
309        let mtls = tls.client_tls_options.unwrap();
310        assert_eq!(mtls.client_cert, b"file-cert");
311        assert_eq!(mtls.client_private_key, b"file-key");
312    }
313
314    #[test]
315    fn server_ca_cert() {
316        let profile = ClientConfigProfile::builder()
317            .tls(
318                ClientConfigTLS::builder()
319                    .server_ca_cert(DataSource::Data(b"ca-data".to_vec()))
320                    .build(),
321            )
322            .build();
323        let conn: ConnectionOptions = profile.try_into().unwrap();
324        let tls = conn.tls_options.unwrap();
325        assert_eq!(tls.server_root_ca_cert.unwrap(), b"ca-data");
326    }
327
328    #[test]
329    fn server_name_sni() {
330        let profile = ClientConfigProfile::builder()
331            .tls(
332                ClientConfigTLS::builder()
333                    .server_name("my.server.com")
334                    .build(),
335            )
336            .build();
337        let conn: ConnectionOptions = profile.try_into().unwrap();
338        let tls = conn.tls_options.unwrap();
339        assert_eq!(tls.domain.as_deref(), Some("my.server.com"));
340    }
341
342    #[rstest]
343    #[case::cert_without_key(Some(DataSource::Data(b"cert".to_vec())), None)]
344    #[case::key_without_cert(None, Some(DataSource::Data(b"key".to_vec())))]
345    fn partial_tls_errors(
346        #[case] client_cert: Option<DataSource>,
347        #[case] client_key: Option<DataSource>,
348    ) {
349        let profile = ClientConfigProfile::builder()
350            .tls(
351                ClientConfigTLS::builder()
352                    .maybe_client_cert(client_cert)
353                    .maybe_client_key(client_key)
354                    .build(),
355            )
356            .build();
357        assert!(ConnectionOptions::try_from(profile).is_err());
358    }
359
360    #[rstest]
361    fn load_from_config_from_toml(config_dir: TempDir) {
362        let config_path = write_config(
363            &config_dir,
364            r#"
365[profile.default]
366address = "toml-server:7233"
367namespace = "toml-ns"
368api_key = "toml-key"
369
370[profile.default.grpc_meta]
371x-custom = "value"
372
373[profile.custom]
374address = "custom-server:9090"
375namespace = "custom-ns"
376"#,
377        );
378
379        // Default profile
380        let opts = LoadClientConfigProfileOptions::builder()
381            .config_source(DataSource::Path(config_path.to_str().unwrap().to_string()))
382            .disable_env(true)
383            .build();
384        let (conn, client) = ClientOptions::load_from_config(opts).unwrap();
385        assert_eq!(conn.target.as_str(), "https://toml-server:7233/");
386        assert_eq!(client.namespace, "toml-ns");
387        assert_eq!(conn.api_key.as_deref(), Some("toml-key"));
388        assert!(conn.tls_options.is_some());
389        assert_eq!(
390            conn.headers.as_ref().unwrap().get("x-custom").unwrap(),
391            "value"
392        );
393
394        // Custom profile
395        let opts = LoadClientConfigProfileOptions::builder()
396            .config_source(DataSource::Path(config_path.to_str().unwrap().to_string()))
397            .config_file_profile("custom".to_string())
398            .disable_env(true)
399            .build();
400        let (conn, client) = ClientOptions::load_from_config(opts).unwrap();
401        assert_eq!(conn.target.as_str(), "http://custom-server:9090/");
402        assert_eq!(client.namespace, "custom-ns");
403    }
404}