1use 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 pub fn load_from_config(
22 options: LoadClientConfigProfileOptions,
23 ) -> Result<(ConnectionOptions, ClientOptions), ConfigError> {
24 load_from_config_with_env(options, None)
25 }
26}
27
28fn 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
43fn parse_address(address: &str, use_tls: bool) -> Result<Url, ConfigError> {
49 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
61fn 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
97fn 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
146fn 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 #[fixture]
169 fn config_dir() -> TempDir {
170 TempDir::new().unwrap()
171 }
172
173 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 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 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}