Skip to main content

slim_config/
client.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Transport-agnostic client configuration.
5//!
6//! [`ClientConfig`] carries all the connection settings (endpoint, TLS,
7//! keepalive, auth, headers, proxy, etc.) and exposes the polymorphic
8//! [`ClientConfig::to_channel`] entry point. The transport-specific
9//! channel-construction code lives in:
10//!
11//! * `crate::grpc::client` — gRPC tonic channel building
12//! * `crate::websocket::client` — WebSocket channel building
13
14use duration_string::DurationString;
15use std::{collections::HashMap, time::Duration};
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::component::configuration::Configuration;
21use crate::conn_type::ConnType;
22use crate::errors::ConfigError;
23#[cfg(not(target_arch = "wasm32"))]
24use crate::grpc::compression::CompressionType;
25#[cfg(not(target_arch = "wasm32"))]
26use crate::grpc::proxy::ProxyConfig;
27#[cfg(not(target_arch = "wasm32"))]
28use crate::tls::client::TlsClientConfig as TLSSetting;
29#[cfg(not(target_arch = "wasm32"))]
30use crate::tls::errors::ConfigError as TlsConfigError;
31use crate::transport::{TransportProtocol, validate_endpoint_scheme};
32use crate::websocket::client::WebSocketClientChannel;
33
34// Auth, TLS, proxy, compression, gRPC and the backoff/retry strategy are
35// native-only: the browser build connects out over `wss://` (TLS handled by the
36// browser) without these layers, so they are gated off wasm32.
37cfg_if::cfg_if! {
38    if #[cfg(not(target_arch = "wasm32"))] {
39        use display_error_chain::ErrorChainExt;
40        use slim_auth::metadata::MetadataMap;
41        use tonic::codegen::{Body, Bytes, StdError};
42
43        use crate::auth::basic::Config as BasicAuthenticationConfig;
44        use crate::auth::jwt::Config as JwtAuthenticationConfig;
45        use crate::auth::oidc::Config as OidcAuthConfig;
46        #[cfg(not(target_family = "windows"))]
47        use crate::auth::spire::SpireConfig as SpireAuthConfig;
48        use crate::auth::static_jwt::Config as BearerAuthenticationConfig;
49        use crate::backoff::exponential::Config as ExponentialBackoff;
50        use crate::backoff::fixedinterval::Config as FixedIntervalBackoff;
51        use crate::backoff::Strategy;
52    }
53}
54
55/// Result of [`ClientConfig::to_channel`]: either a gRPC channel (the generic
56/// `G` parameter) or a WebSocket channel.
57///
58/// [`WebSocketClientChannel`] is internally `Arc`-backed and cheap to clone,
59/// matching the gRPC variant (a `tonic::transport::Channel` also wraps an
60/// internal `Arc`).
61pub enum TransportChannel<G> {
62    Grpc(G),
63    Websocket(WebSocketClientChannel),
64}
65
66/// Keepalive configuration for the client.
67/// This struct contains the keepalive time for TCP and HTTP2,
68/// the timeout duration for the keepalive, and whether to permit
69/// keepalive without an active stream.
70#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, JsonSchema)]
71pub struct KeepaliveConfig {
72    /// The duration of the keepalive time for TCP
73    #[serde(default = "default_tcp_keepalive")]
74    #[schemars(with = "String")]
75    pub tcp_keepalive: DurationString,
76
77    /// The duration of the keepalive time for HTTP2
78    #[serde(default = "default_http2_keepalive")]
79    #[schemars(with = "String")]
80    pub http2_keepalive: DurationString,
81
82    /// The timeout duration for the keepalive
83    #[serde(default = "default_timeout")]
84    #[schemars(with = "String")]
85    pub timeout: DurationString,
86
87    /// Whether to permit keepalive without an active stream
88    #[serde(default = "default_keep_alive_while_idle")]
89    pub keep_alive_while_idle: bool,
90}
91
92/// Defaults for KeepaliveConfig
93impl Default for KeepaliveConfig {
94    fn default() -> Self {
95        KeepaliveConfig {
96            tcp_keepalive: default_tcp_keepalive(),
97            http2_keepalive: default_http2_keepalive(),
98            timeout: default_timeout(),
99            keep_alive_while_idle: default_keep_alive_while_idle(),
100        }
101    }
102}
103
104fn default_tcp_keepalive() -> DurationString {
105    Duration::from_secs(60).into()
106}
107
108fn default_http2_keepalive() -> DurationString {
109    Duration::from_secs(60).into()
110}
111
112fn default_timeout() -> DurationString {
113    Duration::from_secs(10).into()
114}
115
116fn default_keep_alive_while_idle() -> bool {
117    false
118}
119
120cfg_if::cfg_if! {
121    if #[cfg(not(target_arch = "wasm32"))] {
122        /// Enum holding one authentication configuration for the client.
123        #[derive(Debug, Serialize, Default, Deserialize, Clone, PartialEq, JsonSchema)]
124        #[serde(rename_all = "snake_case", tag = "type")]
125        pub enum AuthenticationConfig {
126            /// Basic authentication configuration.
127            Basic(BasicAuthenticationConfig),
128            /// Bearer authentication configuration.
129            StaticJwt(BearerAuthenticationConfig),
130            /// JWT authentication configuration.
131            Jwt(JwtAuthenticationConfig),
132            /// OIDC client-credentials or refresh-token flow.
133            Oidc(OidcAuthConfig),
134            /// SPIRE/SPIFFE authentication configuration.
135            #[cfg(not(target_family = "windows"))]
136            Spire(SpireAuthConfig),
137            /// None
138            #[default]
139            None,
140        }
141
142        /// Enum holding one backoff configuration for the client.
143        #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, JsonSchema)]
144        #[serde(rename_all = "snake_case", tag = "type")]
145        pub enum BackoffConfig {
146            // Exponential backoff retry config.
147            Exponential(ExponentialBackoff),
148            /// FixedInterval backoff retry config.
149            FixedInterval(FixedIntervalBackoff),
150        }
151
152        impl BackoffConfig {
153            /// Creates a new Exponential backoff configuration
154            pub fn new_exponential(
155                base: u64,
156                factor: u64,
157                max_delay: Duration,
158                max_attempts: usize,
159                jitter: bool,
160            ) -> Self {
161                BackoffConfig::Exponential(ExponentialBackoff::new(
162                    base,
163                    factor,
164                    max_delay,
165                    max_attempts,
166                    jitter,
167                ))
168            }
169
170            /// Creates a new FixedInterval backoff configuration
171            pub fn new_fixed_interval(interval: Duration, max_attempts: usize) -> Self {
172                BackoffConfig::FixedInterval(FixedIntervalBackoff::new(interval, max_attempts))
173            }
174        }
175
176        impl Default for BackoffConfig {
177            fn default() -> Self {
178                BackoffConfig::Exponential(ExponentialBackoff::default())
179            }
180        }
181
182        impl Strategy for BackoffConfig {
183            fn get_strategy(&self) -> Box<dyn Iterator<Item = Duration> + Send> {
184                match self {
185                    BackoffConfig::Exponential(b) => b.get_strategy(),
186                    BackoffConfig::FixedInterval(b) => b.get_strategy(),
187                }
188            }
189        }
190    }
191}
192
193/// Struct for the client configuration.
194/// This struct contains the endpoint, origin, compression type, rate limit,
195/// TLS settings, keepalive settings, proxy settings, timeout settings, buffer size settings,
196/// headers, and auth settings.
197/// The client configuration can be converted to a transport channel via
198/// [`ClientConfig::to_channel`].
199#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, JsonSchema)]
200pub struct ClientConfig {
201    /// The target the client will connect to.
202    ///
203    /// The transport protocol is inferred from the endpoint scheme:
204    /// * `ws://`, `wss://`  → WebSocket (TLS when `wss`)
205    /// * `http://`, `https://`, `unix://`, bare `host:port` → gRPC
206    pub endpoint: String,
207
208    /// Origin (HTTP Host authority override) for the client.
209    pub origin: Option<String>,
210
211    /// Optional TLS SNI server name override. If set, this value is used for TLS
212    /// server name verification (SNI) instead of the host extracted from endpoint/origin.
213    pub server_name: Option<String>,
214
215    /// Compression type - TODO(msardara): not implemented yet.
216    #[cfg(not(target_arch = "wasm32"))]
217    pub compression: Option<CompressionType>,
218
219    /// Rate Limits
220    pub rate_limit: Option<String>,
221
222    /// TLS client configuration.
223    #[cfg(not(target_arch = "wasm32"))]
224    #[serde(default, rename = "tls")]
225    pub tls_setting: TLSSetting,
226
227    /// Keepalive parameters.
228    pub keepalive: Option<KeepaliveConfig>,
229
230    /// HTTP Proxy configuration.
231    #[cfg(not(target_arch = "wasm32"))]
232    #[serde(default)]
233    pub proxy: ProxyConfig,
234
235    /// Timeout for the connection.
236    #[serde(default = "default_connect_timeout")]
237    #[schemars(with = "String")]
238    pub connect_timeout: DurationString,
239
240    /// Timeout per request.
241    #[serde(default = "default_request_timeout")]
242    #[schemars(with = "String")]
243    pub request_timeout: DurationString,
244
245    /// ReadBufferSize.
246    pub buffer_size: Option<usize>,
247
248    /// The headers associated with gRPC requests.
249    #[serde(default)]
250    pub headers: HashMap<String, String>,
251
252    /// Auth configuration for outgoing RPCs.
253    #[cfg(not(target_arch = "wasm32"))]
254    #[serde(default)]
255    pub auth: AuthenticationConfig,
256
257    /// Backoff retry configuration.
258    #[cfg(not(target_arch = "wasm32"))]
259    #[serde(default = "default_backoff")]
260    pub backoff: BackoffConfig,
261
262    /// Arbitrary user-provided metadata.
263    #[cfg(not(target_arch = "wasm32"))]
264    pub metadata: Option<MetadataMap>,
265
266    /// Link identifier for this connection, used during link negotiation.
267    /// Defaults to a randomly generated UUID v4.
268    #[serde(default = "default_link_id")]
269    pub link_id: String,
270
271    /// Flag to enforce header integrity validation
272    #[serde(default = "default_require_header_mac")]
273    pub require_header_mac: bool,
274
275    /// The type of connection this client establishes.
276    /// Defaults to `edge`. Set to `peer` for intra-deployment peer connections,
277    /// or `remote` for control-plane-managed inter-deployment links.
278    #[serde(default)]
279    pub connection_type: ConnType,
280}
281
282/// Defaults for ClientConfig
283impl Default for ClientConfig {
284    fn default() -> Self {
285        ClientConfig {
286            endpoint: String::new(),
287            origin: None,
288            server_name: None,
289            #[cfg(not(target_arch = "wasm32"))]
290            compression: None,
291            rate_limit: None,
292            #[cfg(not(target_arch = "wasm32"))]
293            tls_setting: TLSSetting::default(),
294            keepalive: None,
295            #[cfg(not(target_arch = "wasm32"))]
296            proxy: ProxyConfig::default(),
297            connect_timeout: default_connect_timeout(),
298            request_timeout: default_request_timeout(),
299            buffer_size: None,
300            headers: HashMap::new(),
301            #[cfg(not(target_arch = "wasm32"))]
302            auth: AuthenticationConfig::None,
303            #[cfg(not(target_arch = "wasm32"))]
304            backoff: default_backoff(),
305            #[cfg(not(target_arch = "wasm32"))]
306            metadata: None,
307            link_id: default_link_id(),
308            require_header_mac: true,
309            connection_type: ConnType::default(),
310        }
311    }
312}
313
314fn default_link_id() -> String {
315    uuid::Uuid::new_v4().to_string()
316}
317
318fn default_require_header_mac() -> bool {
319    true
320}
321
322#[cfg(not(target_arch = "wasm32"))]
323fn default_backoff() -> BackoffConfig {
324    BackoffConfig::new_fixed_interval(Duration::from_secs(2), usize::MAX)
325}
326
327fn default_connect_timeout() -> DurationString {
328    Duration::from_secs(0).into()
329}
330
331fn default_request_timeout() -> DurationString {
332    Duration::from_secs(0).into()
333}
334
335// `Display` prints the target's field set (the browser build has no
336// auth/TLS/proxy/compression fields), so each impl lives in its own file and is
337// selected here — file-split rather than an in-line `cfg_if!` so "what exists on
338// wasm" is answerable by `ls`.
339#[cfg(not(target_arch = "wasm32"))]
340#[path = "client_display_native.rs"]
341mod client_display;
342#[cfg(target_arch = "wasm32")]
343#[path = "client_display_wasm.rs"]
344mod client_display;
345
346impl Configuration for ClientConfig {
347    type Error = ConfigError;
348
349    fn validate(&self) -> Result<(), Self::Error> {
350        if self.endpoint.is_empty() {
351            return Err(ConfigError::MissingEndpoint);
352        }
353
354        // Validate the client configuration
355        #[cfg(not(target_arch = "wasm32"))]
356        self.tls_setting.validate()?;
357        validate_endpoint_scheme(&self.endpoint)?;
358
359        Ok(())
360    }
361}
362
363// Transport-agnostic surface: the builder methods and helpers that exist on
364// every target. Native-only builders (TLS/auth/proxy/compression/backoff/
365// metadata) and the native `to_channel` live in the `#[cfg(not(wasm32))]` impl
366// below; the browser `to_channel` lives in the `#[cfg(wasm32)]` impl.
367impl ClientConfig {
368    /// Creates a new client configuration with the given endpoint.
369    /// This function will return a ClientConfig with the endpoint set
370    /// and all other fields set to default.
371    pub fn with_endpoint(endpoint: &str) -> Self {
372        Self {
373            endpoint: endpoint.to_string(),
374            ..Self::default()
375        }
376    }
377
378    pub fn with_origin(self, origin: &str) -> Self {
379        Self {
380            origin: Some(origin.to_string()),
381            ..self
382        }
383    }
384
385    pub fn with_server_name(self, server_name: &str) -> Self {
386        Self {
387            server_name: Some(server_name.to_string()),
388            ..self
389        }
390    }
391
392    pub fn with_rate_limit(self, rate_limit: &str) -> Self {
393        Self {
394            rate_limit: Some(rate_limit.to_string()),
395            ..self
396        }
397    }
398
399    pub fn with_keepalive(self, keepalive: KeepaliveConfig) -> Self {
400        Self {
401            keepalive: Some(keepalive),
402            ..self
403        }
404    }
405
406    pub fn with_connect_timeout(self, connect_timeout: Duration) -> Self {
407        Self {
408            connect_timeout: connect_timeout.into(),
409            ..self
410        }
411    }
412
413    pub fn with_request_timeout(self, request_timeout: Duration) -> Self {
414        Self {
415            request_timeout: request_timeout.into(),
416            ..self
417        }
418    }
419
420    pub fn with_buffer_size(self, buffer_size: usize) -> Self {
421        Self {
422            buffer_size: Some(buffer_size),
423            ..self
424        }
425    }
426
427    pub fn with_headers(self, headers: HashMap<String, String>) -> Self {
428        Self { headers, ..self }
429    }
430
431    pub fn with_connection_type(self, connection_type: ConnType) -> Self {
432        Self {
433            connection_type,
434            ..self
435        }
436    }
437
438    /// Resolve the transport protocol for this configuration by inspecting
439    /// the endpoint URI scheme. See [`TransportProtocol::from_endpoint`].
440    pub fn resolved_transport(&self) -> TransportProtocol {
441        TransportProtocol::from_endpoint(&self.endpoint)
442    }
443}
444
445// Native surface. The browser build connects out over `wss://` (TLS handled by
446// the browser) without auth/TLS/proxy/compression/backoff layers, so these
447// builders, the shared connect-retry helper, and the gRPC-capable `to_channel`
448// only exist off wasm32.
449#[cfg(not(target_arch = "wasm32"))]
450impl ClientConfig {
451    pub fn merge_server_requirements(
452        &mut self,
453        server: &ServerConnectionConfig,
454    ) -> Result<(), TlsConfigError> {
455        self.endpoint = server.endpoint.clone();
456        self.tls_setting.insecure = !server.tls_required;
457
458        match &server.auth_method {
459            RequiredAuthMethod::None => {}
460            #[cfg(not(target_family = "windows"))]
461            RequiredAuthMethod::Spire { trust_domain } => {
462                use crate::auth::spire::SpireConfig;
463                use crate::tls::common::{CaSource, TlsSource};
464
465                let trust_domains = trust_domain
466                    .as_ref()
467                    .map(|td| vec![td.clone()])
468                    .unwrap_or_default();
469
470                if server.tls_required {
471                    // outbound_clients: socket comes from auth.spire only (not tls.source).
472                    let socket_path = match &self.auth {
473                        AuthenticationConfig::Spire(auth_cfg) => auth_cfg.socket_path.clone(),
474                        _ => None,
475                    };
476                    self.tls_setting.config.source = TlsSource::Spire {
477                        config: SpireConfig {
478                            socket_path: socket_path.clone(),
479                            ..Default::default()
480                        },
481                    };
482                    self.tls_setting.config.ca_source = CaSource::Spire {
483                        config: SpireConfig {
484                            socket_path,
485                            trust_domains,
486                            ..Default::default()
487                        },
488                    };
489                } else {
490                    return Err(TlsConfigError::Spire(
491                        "TLS needs to be enabled to use Spire".to_string(),
492                    ));
493                }
494            }
495            RequiredAuthMethod::Basic => {}
496            RequiredAuthMethod::Jwt => {}
497            RequiredAuthMethod::Oidc => {}
498        }
499        if let Some(ms) = server.timeout {
500            self.connect_timeout = Duration::from_millis(ms as u64).into();
501        }
502        if let Some(ms) = server.backoff {
503            self.backoff =
504                BackoffConfig::new_fixed_interval(Duration::from_millis(ms as u64), usize::MAX);
505        }
506        if let Some(ka) = &server.keepalive {
507            self.keepalive = Some(ka.clone());
508        }
509        Ok(())
510    }
511
512    pub fn with_compression(self, compression: CompressionType) -> Self {
513        Self {
514            compression: Some(compression),
515            ..self
516        }
517    }
518
519    pub fn with_tls_setting(self, tls_setting: TLSSetting) -> Self {
520        Self {
521            tls_setting,
522            ..self
523        }
524    }
525
526    pub fn with_proxy(self, proxy: ProxyConfig) -> Self {
527        Self { proxy, ..self }
528    }
529
530    pub fn with_auth(self, auth: AuthenticationConfig) -> Self {
531        Self { auth, ..self }
532    }
533
534    pub fn with_backoff(self, backoff: BackoffConfig) -> Self {
535        Self { backoff, ..self }
536    }
537
538    pub fn with_metadata(self, metadata: MetadataMap) -> Self {
539        Self {
540            metadata: Some(metadata),
541            ..self
542        }
543    }
544
545    /// Run a single connect attempt under this config's backoff/retry policy.
546    ///
547    /// This is the single place where the retry loop lives: all transports
548    /// (gRPC, WebSocket, future ones) share the same backoff strategy and the
549    /// same retryable-error classification ([`ConfigError::is_retryable_connect_error`]).
550    /// Each transport-specific builder only needs to expose a one-shot attempt
551    /// and call this helper.
552    pub(crate) async fn retry_connect<T, F, Fut>(&self, attempt: F) -> Result<T, ConfigError>
553    where
554        F: FnMut() -> Fut,
555        Fut: std::future::Future<Output = Result<T, ConfigError>>,
556    {
557        use crate::backoff::Strategy;
558        use tokio_retry::RetryIf;
559
560        let strategy = self.backoff.get_strategy();
561        RetryIf::start(strategy, attempt, |e: &ConfigError| {
562            let retry = e.is_retryable_connect_error();
563            if retry {
564                tracing::warn!(error = %e.chain(), "transient connect error, retrying");
565            } else {
566                tracing::error!(error = %e.chain(), "non-retryable connect error");
567            }
568            retry
569        })
570        .await
571    }
572
573    /// Build a transport channel from this configuration. The returned
574    /// [`TransportChannel`] variant matches `self.transport`.
575    ///
576    /// This is the single public entry point callers should use; the
577    /// transport-specific builders (`to_grpc_channel` / `to_websocket_channel`)
578    /// are crate-private.
579    pub async fn to_channel(
580        &self,
581    ) -> Result<
582        TransportChannel<
583            impl tonic::client::GrpcService<
584                tonic::body::Body,
585                Error: Into<StdError> + Send,
586                ResponseBody: Body<Data = Bytes, Error: Into<StdError> + std::marker::Send>
587                                  + Send
588                                  + 'static,
589                Future: Send,
590            >
591            + Send
592            + Clone
593            + 'static
594            + use<>,
595        >,
596        ConfigError,
597    > {
598        match self.resolved_transport() {
599            TransportProtocol::Grpc => Ok(TransportChannel::Grpc(self.to_grpc_channel().await?)),
600            TransportProtocol::Websocket => Ok(TransportChannel::Websocket(
601                self.to_websocket_channel().await?,
602            )),
603        }
604    }
605}
606
607// Browser surface: gRPC is unavailable on wasm, so only the WebSocket transport
608// is supported.
609#[cfg(target_arch = "wasm32")]
610impl ClientConfig {
611    /// Build a transport channel (browser build). gRPC is unavailable on wasm,
612    /// so only the WebSocket transport is supported; a `ws://`/`wss://` endpoint
613    /// is required. The `Grpc` generic is filled with [`std::convert::Infallible`]
614    /// so callers can still pattern-match the (uninhabited) gRPC arm.
615    pub async fn to_channel(
616        &self,
617    ) -> Result<TransportChannel<std::convert::Infallible>, ConfigError> {
618        match self.resolved_transport() {
619            TransportProtocol::Websocket => Ok(TransportChannel::Websocket(
620                self.to_websocket_channel().await?,
621            )),
622            TransportProtocol::Grpc => Err(ConfigError::WebSocketClientUnsupportedTransport),
623        }
624    }
625}
626
627#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
628#[serde(rename_all = "snake_case")]
629pub enum RequiredAuthMethod {
630    #[default]
631    None,
632    Basic,
633    Jwt,
634    Oidc,
635    #[cfg(not(target_family = "windows"))]
636    Spire {
637        trust_domain: Option<String>,
638    },
639}
640
641#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, JsonSchema)]
642pub struct ServerConnectionConfig {
643    pub endpoint: String,
644    pub tls_required: bool,
645    pub auth_method: RequiredAuthMethod,
646    pub timeout: Option<u32>,
647    pub backoff: Option<u32>,
648    pub keepalive: Option<KeepaliveConfig>,
649}
650
651impl ServerConnectionConfig {
652    #[cfg(not(target_arch = "wasm32"))]
653    pub fn from_client_config(client: &ClientConfig) -> Self {
654        let tls_required = !client.tls_setting.insecure;
655        let auth_method = match &client.auth {
656            AuthenticationConfig::None => RequiredAuthMethod::None,
657            AuthenticationConfig::Basic(_) => RequiredAuthMethod::Basic,
658            AuthenticationConfig::StaticJwt(_) | AuthenticationConfig::Jwt(_) => {
659                RequiredAuthMethod::Jwt
660            }
661            AuthenticationConfig::Oidc(_) => RequiredAuthMethod::Oidc,
662            #[cfg(not(target_family = "windows"))]
663            AuthenticationConfig::Spire(cfg) => RequiredAuthMethod::Spire {
664                trust_domain: cfg.trust_domains.first().cloned(),
665            },
666        };
667        Self {
668            endpoint: client.endpoint.clone(),
669            tls_required,
670            auth_method,
671            timeout: None,
672            backoff: None,
673            keepalive: None,
674        }
675    }
676
677    #[cfg(target_arch = "wasm32")]
678    pub fn from_client_config(client: &ClientConfig) -> Self {
679        Self {
680            endpoint: client.endpoint.clone(),
681            tls_required: false,
682            auth_method: RequiredAuthMethod::None,
683            timeout: None,
684            backoff: None,
685            keepalive: None,
686        }
687    }
688}
689
690#[cfg(test)]
691mod metadata_tests {
692    use super::*;
693
694    #[test]
695    fn client_config_with_metadata_roundtrip_json() {
696        let mut md = MetadataMap::default();
697        md.insert("feature", "alpha");
698        md.insert("level", 2u64);
699
700        let cfg = ClientConfig::with_endpoint("http://localhost:1234").with_metadata(md.clone());
701        let s = serde_json::to_string(&cfg).expect("serialize");
702        let deser: ClientConfig = serde_json::from_str(&s).expect("deserialize");
703        assert_eq!(deser.metadata, Some(md));
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    #[test]
712    fn test_default_keepalive_config() {
713        let keepalive = KeepaliveConfig::default();
714        assert_eq!(keepalive.tcp_keepalive, Duration::from_secs(60));
715        assert_eq!(keepalive.http2_keepalive, Duration::from_secs(60));
716        assert_eq!(keepalive.timeout, Duration::from_secs(10));
717        assert!(!keepalive.keep_alive_while_idle);
718    }
719
720    #[test]
721    fn test_default_client_config() {
722        let client = ClientConfig::default();
723        assert_eq!(client.endpoint, String::new());
724        assert_eq!(client.resolved_transport(), TransportProtocol::Grpc);
725        assert_eq!(client.origin, None);
726        assert_eq!(client.compression, None);
727        assert_eq!(client.rate_limit, None);
728        assert_eq!(client.tls_setting, TLSSetting::default());
729        assert_eq!(client.keepalive, None);
730        assert_eq!(client.connect_timeout, Duration::from_secs(0));
731        assert_eq!(client.request_timeout, Duration::from_secs(0));
732        assert_eq!(client.buffer_size, None);
733        assert_eq!(client.headers, HashMap::new());
734        assert_eq!(client.auth, AuthenticationConfig::None);
735    }
736
737    #[test]
738    fn test_transport_inferred_from_endpoint_scheme() {
739        assert_eq!(
740            ClientConfig::with_endpoint("ws://localhost:46357").resolved_transport(),
741            TransportProtocol::Websocket
742        );
743        assert_eq!(
744            ClientConfig::with_endpoint("wss://localhost:46357").resolved_transport(),
745            TransportProtocol::Websocket
746        );
747        assert_eq!(
748            ClientConfig::with_endpoint("http://localhost:46357").resolved_transport(),
749            TransportProtocol::Grpc
750        );
751        assert_eq!(
752            ClientConfig::with_endpoint("https://localhost:46357").resolved_transport(),
753            TransportProtocol::Grpc
754        );
755        assert_eq!(
756            ClientConfig::with_endpoint("127.0.0.1:46357").resolved_transport(),
757            TransportProtocol::Grpc
758        );
759
760        // Validation rejects unknown schemes.
761        let invalid = ClientConfig::with_endpoint("ftp://localhost:46357");
762        assert!(matches!(
763            invalid.validate(),
764            Err(ConfigError::InvalidEndpointScheme)
765        ));
766    }
767
768    #[test]
769    fn test_client_config_with_proxy() {
770        let test_password: String = format!("test-{}-{}", std::process::id(), line!()); // pragma: allowlist secret
771        let proxy =
772            ProxyConfig::new("http://proxy.example.com:8080").with_auth("user", &test_password);
773        let client = ClientConfig::with_endpoint("http://localhost:8080").with_proxy(proxy.clone());
774        assert_eq!(client.proxy, proxy);
775    }
776
777    #[test]
778    fn test_connect_and_request_timeout_valid_durations_deserialize() {
779        let json = r#"{
780            "endpoint": "http://localhost:1234",
781            "connect_timeout": "1m30s",
782            "request_timeout": "250ms"
783        }"#;
784
785        let cfg: ClientConfig = serde_json::from_str(json).expect("deserialization should succeed");
786        assert_eq!(cfg.connect_timeout, Duration::from_secs(90));
787        assert_eq!(cfg.request_timeout, Duration::from_millis(250));
788
789        // More complex duration
790        let json = r#"{
791            "endpoint": "http://localhost:1234",
792            "connect_timeout": "1h2m3s4ms",
793            "request_timeout": "1500ms"
794        }"#;
795        let cfg: ClientConfig =
796            serde_json::from_str(json).expect("complex duration should deserialize");
797        assert_eq!(
798            cfg.connect_timeout,
799            Duration::from_secs(3723) + Duration::from_millis(4)
800        );
801        assert_eq!(cfg.request_timeout, Duration::from_millis(1500));
802    }
803
804    #[test]
805    fn test_invalid_duration_strings_fail_deserialize() {
806        let invalids = [
807            r#"{ "endpoint": "http://localhost:1234", "connect_timeout": "abc" }"#,
808            r#"{ "endpoint": "http://localhost:1234", "request_timeout": "10x" }"#,
809            r#"{ "endpoint": "http://localhost:1234", "request_timeout": "--5s" }"#,
810        ];
811        for js in invalids {
812            let res: Result<ClientConfig, _> = serde_json::from_str(js);
813            assert!(res.is_err(), "expected error for json: {}", js);
814        }
815    }
816
817    #[test]
818    fn test_keepalive_config_duration_parsing() {
819        let json = r#"{
820            "endpoint": "http://localhost:1234",
821            "keepalive": {
822                "tcp_keepalive": "30s",
823                "http2_keepalive": "45s",
824                "timeout": "5s",
825                "keep_alive_while_idle": true
826            }
827        }"#;
828        let cfg: ClientConfig = serde_json::from_str(json).expect("keepalive should deserialize");
829        let ka = cfg.keepalive.expect("keepalive should be present");
830        assert_eq!(ka.tcp_keepalive, Duration::from_secs(30));
831        assert_eq!(ka.http2_keepalive, Duration::from_secs(45));
832        assert_eq!(ka.timeout, Duration::from_secs(5));
833        assert!(ka.keep_alive_while_idle);
834
835        // Invalid keepalive duration
836        let invalid_json = r#"{
837            "endpoint": "http://localhost:1234",
838            "keepalive": { "tcp_keepalive": "zz", "http2_keepalive": "10s", "timeout": "5s", "keep_alive_while_idle": false }
839        }"#;
840        let res: Result<ClientConfig, _> = serde_json::from_str(invalid_json);
841        assert!(res.is_err(), "invalid tcp_keepalive should fail");
842    }
843
844    #[test]
845    fn test_client_config_roundtrip_duration_serialization() {
846        let mut cfg = ClientConfig::with_endpoint("http://localhost:9999")
847            .with_connect_timeout(Duration::from_secs(90))
848            .with_request_timeout(Duration::from_millis(750));
849
850        cfg.keepalive = Some(KeepaliveConfig {
851            tcp_keepalive: Duration::from_secs(11).into(),
852            http2_keepalive: Duration::from_secs(22).into(),
853            timeout: Duration::from_secs(3).into(),
854            keep_alive_while_idle: true,
855        });
856
857        let serialized = serde_json::to_string(&cfg).expect("serialize");
858        let deserialized: ClientConfig = serde_json::from_str(&serialized).expect("deserialize");
859
860        assert_eq!(deserialized.connect_timeout, Duration::from_secs(90));
861        assert_eq!(deserialized.request_timeout, Duration::from_millis(750));
862        let ka = deserialized.keepalive.expect("keepalive present");
863        assert_eq!(ka.tcp_keepalive, Duration::from_secs(11));
864        assert_eq!(ka.http2_keepalive, Duration::from_secs(22));
865        assert_eq!(ka.timeout, Duration::from_secs(3));
866        assert!(ka.keep_alive_while_idle);
867    }
868
869    #[test]
870    fn test_validate_accepts_any_link_id() {
871        let mut config = ClientConfig::with_endpoint("http://localhost:1234");
872        config.link_id = "my-custom-link-id".to_string();
873        assert!(config.validate().is_ok());
874    }
875
876    #[test]
877    fn test_merge_server_requirements_none_auth() {
878        let mut client = ClientConfig::with_endpoint("http://old:1234")
879            .with_auth(AuthenticationConfig::Basic(Default::default()));
880        let server = ServerConnectionConfig {
881            endpoint: "http://new:5678".to_string(),
882            tls_required: false,
883            auth_method: RequiredAuthMethod::None,
884            ..Default::default()
885        };
886        client.merge_server_requirements(&server).unwrap();
887        assert_eq!(client.endpoint, "http://new:5678");
888        assert!(client.tls_setting.insecure);
889        // None means "no auth required" — local credentials are preserved so that
890        // clients with OIDC/JWT configured (but served by a CP that doesn't model
891        // OIDC) can still authenticate.
892        assert_eq!(client.auth, AuthenticationConfig::Basic(Default::default()));
893    }
894
895    #[test]
896    fn test_merge_server_requirements_basic_auth_preserved() {
897        let basic = AuthenticationConfig::Basic(Default::default());
898        let mut client = ClientConfig::with_endpoint("http://old:1234").with_auth(basic.clone());
899        let server = ServerConnectionConfig {
900            endpoint: "http://new:5678".to_string(),
901            tls_required: true,
902            auth_method: RequiredAuthMethod::Basic,
903            ..Default::default()
904        };
905        client.merge_server_requirements(&server).unwrap();
906        assert_eq!(client.endpoint, "http://new:5678");
907        assert!(!client.tls_setting.insecure);
908        assert_eq!(client.auth, basic);
909    }
910
911    #[test]
912    fn test_merge_server_requirements_jwt_auth_preserved() {
913        let jwt = AuthenticationConfig::StaticJwt(Default::default());
914        let mut client = ClientConfig::with_endpoint("http://old:1234").with_auth(jwt.clone());
915        let server = ServerConnectionConfig {
916            endpoint: "http://new:9999".to_string(),
917            tls_required: false,
918            auth_method: RequiredAuthMethod::Jwt,
919            ..Default::default()
920        };
921        client.merge_server_requirements(&server).unwrap();
922        assert_eq!(client.endpoint, "http://new:9999");
923        assert_eq!(client.auth, jwt);
924    }
925
926    #[cfg(not(target_family = "windows"))]
927    #[test]
928    fn test_merge_server_requirements_spire_with_tls_sets_sources() {
929        use crate::tls::common::{CaSource, TlsSource};
930
931        let mut client = ClientConfig::with_endpoint("http://old:1234");
932        let server = ServerConnectionConfig {
933            endpoint: "http://new:5678".to_string(),
934            tls_required: true,
935            auth_method: RequiredAuthMethod::Spire {
936                trust_domain: Some("example.org".to_string()),
937            },
938            ..Default::default()
939        };
940        client.merge_server_requirements(&server).unwrap();
941        assert_eq!(client.endpoint, "http://new:5678");
942        assert!(!client.tls_setting.insecure);
943        assert!(matches!(
944            client.tls_setting.config.source,
945            TlsSource::Spire { .. }
946        ));
947        assert!(matches!(
948            client.tls_setting.config.ca_source,
949            CaSource::Spire { .. }
950        ));
951    }
952
953    #[cfg(not(target_family = "windows"))]
954    #[test]
955    fn test_merge_server_requirements_spire_auth_socket_path_propagates_to_tls() {
956        use crate::auth::spire::SpireConfig;
957        use crate::tls::common::{CaSource, TlsSource};
958
959        let mut client = ClientConfig::with_endpoint("https://127.0.0.1:46201").with_auth(
960            AuthenticationConfig::Spire(
961                SpireConfig::new()
962                    .with_socket_path("/run/spire/agent.sock")
963                    .with_trust_domain("example.org"),
964            ),
965        );
966        let server = ServerConnectionConfig {
967            endpoint: "https://127.0.0.1:46201".to_string(),
968            tls_required: true,
969            auth_method: RequiredAuthMethod::Spire {
970                trust_domain: Some("example.org".to_string()),
971            },
972            ..Default::default()
973        };
974        client.merge_server_requirements(&server).unwrap();
975        if let TlsSource::Spire { config } = &client.tls_setting.config.source {
976            assert_eq!(config.socket_path.as_deref(), Some("/run/spire/agent.sock"));
977        } else {
978            panic!("expected TlsSource::Spire");
979        }
980        if let CaSource::Spire { config } = &client.tls_setting.config.ca_source {
981            assert_eq!(config.socket_path.as_deref(), Some("/run/spire/agent.sock"));
982            assert_eq!(config.trust_domains, vec!["example.org"]);
983        } else {
984            panic!("expected CaSource::Spire");
985        }
986        if let AuthenticationConfig::Spire(auth) = &client.auth {
987            assert_eq!(auth.socket_path.as_deref(), Some("/run/spire/agent.sock"));
988        } else {
989            panic!("expected AuthenticationConfig::Spire");
990        }
991    }
992
993    #[cfg(not(target_family = "windows"))]
994    #[test]
995    fn test_merge_server_requirements_spire_ignores_tls_socket_path() {
996        use crate::auth::spire::SpireConfig;
997        use crate::tls::common::{CaSource, TlsSource};
998
999        let mut client = ClientConfig::with_endpoint("http://old:1234");
1000        client.tls_setting.config.source = TlsSource::Spire {
1001            config: SpireConfig {
1002                socket_path: Some("/run/spire.sock".to_string()),
1003                ..Default::default()
1004            },
1005        };
1006        let server = ServerConnectionConfig {
1007            endpoint: "http://new:5678".to_string(),
1008            tls_required: true,
1009            auth_method: RequiredAuthMethod::Spire {
1010                trust_domain: Some("example.org".to_string()),
1011            },
1012            ..Default::default()
1013        };
1014        client.merge_server_requirements(&server).unwrap();
1015        if let TlsSource::Spire { config } = &client.tls_setting.config.source {
1016            assert_eq!(config.socket_path, None);
1017        } else {
1018            panic!("expected TlsSource::Spire");
1019        }
1020        if let CaSource::Spire { config } = &client.tls_setting.config.ca_source {
1021            assert_eq!(config.socket_path, None);
1022            assert_eq!(config.trust_domains, vec!["example.org"]);
1023        } else {
1024            panic!("expected CaSource::Spire");
1025        }
1026    }
1027
1028    #[cfg(not(target_family = "windows"))]
1029    #[test]
1030    fn test_merge_server_requirements_spire_no_trust_domain() {
1031        use crate::tls::common::CaSource;
1032
1033        let mut client = ClientConfig::with_endpoint("http://old:1234");
1034        let server = ServerConnectionConfig {
1035            endpoint: "http://new:5678".to_string(),
1036            tls_required: true,
1037            auth_method: RequiredAuthMethod::Spire { trust_domain: None },
1038            ..Default::default()
1039        };
1040        client.merge_server_requirements(&server).unwrap();
1041        if let CaSource::Spire { config } = &client.tls_setting.config.ca_source {
1042            assert!(config.trust_domains.is_empty());
1043        } else {
1044            panic!("expected CaSource::Spire");
1045        }
1046    }
1047
1048    #[cfg(not(target_family = "windows"))]
1049    #[test]
1050    fn test_merge_server_requirements_spire_without_tls_errors() {
1051        let mut client = ClientConfig::with_endpoint("http://old:1234");
1052        let server = ServerConnectionConfig {
1053            endpoint: "http://new:5678".to_string(),
1054            tls_required: false,
1055            auth_method: RequiredAuthMethod::Spire {
1056                trust_domain: Some("example.org".to_string()),
1057            },
1058            ..Default::default()
1059        };
1060        assert!(client.merge_server_requirements(&server).is_err());
1061    }
1062
1063    #[test]
1064    fn merge_server_requirements_cp_backoff_overrides_local() {
1065        let local_backoff =
1066            BackoffConfig::new_exponential(100, 2, Duration::from_secs(30), 10, false);
1067        let mut client =
1068            ClientConfig::with_endpoint("http://host:8080").with_backoff(local_backoff);
1069        let server = ServerConnectionConfig {
1070            endpoint: "http://host:8080".to_string(),
1071            backoff: Some(5000),
1072            ..Default::default()
1073        };
1074        client.merge_server_requirements(&server).unwrap();
1075        assert_eq!(
1076            client.backoff,
1077            BackoffConfig::new_fixed_interval(Duration::from_millis(5000), usize::MAX)
1078        );
1079    }
1080
1081    #[test]
1082    fn merge_server_requirements_preserves_local_backoff_when_cp_none() {
1083        let local_backoff = BackoffConfig::new_fixed_interval(Duration::from_secs(2), usize::MAX);
1084        let mut client =
1085            ClientConfig::with_endpoint("http://host:8080").with_backoff(local_backoff.clone());
1086        let server = ServerConnectionConfig {
1087            endpoint: "http://host:8080".to_string(),
1088            backoff: None,
1089            ..Default::default()
1090        };
1091        client.merge_server_requirements(&server).unwrap();
1092        assert_eq!(client.backoff, local_backoff);
1093    }
1094
1095    #[test]
1096    fn merge_server_requirements_cp_timeout_overrides_local() {
1097        let mut client = ClientConfig::with_endpoint("http://host:8080")
1098            .with_connect_timeout(Duration::from_secs(10));
1099        let server = ServerConnectionConfig {
1100            endpoint: "http://host:8080".to_string(),
1101            timeout: Some(3000),
1102            ..Default::default()
1103        };
1104        client.merge_server_requirements(&server).unwrap();
1105        assert_eq!(client.connect_timeout, Duration::from_millis(3000));
1106    }
1107
1108    #[test]
1109    fn merge_server_requirements_preserves_local_timeout_when_cp_none() {
1110        let mut client = ClientConfig::with_endpoint("http://host:8080")
1111            .with_connect_timeout(Duration::from_secs(10));
1112        let server = ServerConnectionConfig {
1113            endpoint: "http://host:8080".to_string(),
1114            timeout: None,
1115            ..Default::default()
1116        };
1117        client.merge_server_requirements(&server).unwrap();
1118        assert_eq!(client.connect_timeout, Duration::from_secs(10));
1119    }
1120
1121    #[test]
1122    fn merge_server_requirements_cp_keepalive_overrides_local() {
1123        let local_ka = KeepaliveConfig {
1124            tcp_keepalive: Duration::from_secs(30).into(),
1125            http2_keepalive: Duration::from_secs(30).into(),
1126            timeout: Duration::from_secs(5).into(),
1127            keep_alive_while_idle: false,
1128        };
1129        let cp_ka = KeepaliveConfig {
1130            tcp_keepalive: Duration::from_secs(60).into(),
1131            http2_keepalive: Duration::from_secs(60).into(),
1132            timeout: Duration::from_secs(10).into(),
1133            keep_alive_while_idle: true,
1134        };
1135        let mut client = ClientConfig::with_endpoint("http://host:8080").with_keepalive(local_ka);
1136        let server = ServerConnectionConfig {
1137            endpoint: "http://host:8080".to_string(),
1138            keepalive: Some(cp_ka.clone()),
1139            ..Default::default()
1140        };
1141        client.merge_server_requirements(&server).unwrap();
1142        assert_eq!(client.keepalive, Some(cp_ka));
1143    }
1144
1145    #[test]
1146    fn merge_server_requirements_preserves_local_keepalive_when_cp_none() {
1147        let local_ka = KeepaliveConfig {
1148            tcp_keepalive: Duration::from_secs(30).into(),
1149            http2_keepalive: Duration::from_secs(30).into(),
1150            timeout: Duration::from_secs(5).into(),
1151            keep_alive_while_idle: true,
1152        };
1153        let mut client =
1154            ClientConfig::with_endpoint("http://host:8080").with_keepalive(local_ka.clone());
1155        let server = ServerConnectionConfig {
1156            endpoint: "http://host:8080".to_string(),
1157            keepalive: None,
1158            ..Default::default()
1159        };
1160        client.merge_server_requirements(&server).unwrap();
1161        assert_eq!(client.keepalive, Some(local_ka));
1162    }
1163
1164    #[test]
1165    fn test_from_client_config_none_auth() {
1166        let client = ClientConfig::with_endpoint("http://host:1234")
1167            .with_tls_setting(TLSSetting::insecure());
1168        let server = ServerConnectionConfig::from_client_config(&client);
1169        assert_eq!(server.endpoint, "http://host:1234");
1170        assert!(!server.tls_required);
1171        assert_eq!(server.auth_method, RequiredAuthMethod::None);
1172        assert!(server.timeout.is_none());
1173        assert!(server.backoff.is_none());
1174    }
1175
1176    #[test]
1177    fn test_from_client_config_basic_auth() {
1178        let client = ClientConfig::with_endpoint("http://host:1234")
1179            .with_auth(AuthenticationConfig::Basic(Default::default()));
1180        let server = ServerConnectionConfig::from_client_config(&client);
1181        assert_eq!(server.auth_method, RequiredAuthMethod::Basic);
1182    }
1183
1184    #[test]
1185    fn test_from_client_config_jwt_auth() {
1186        let client = ClientConfig::with_endpoint("http://host:1234")
1187            .with_auth(AuthenticationConfig::StaticJwt(Default::default()));
1188        let server = ServerConnectionConfig::from_client_config(&client);
1189        assert_eq!(server.auth_method, RequiredAuthMethod::Jwt);
1190    }
1191
1192    #[cfg(not(target_family = "windows"))]
1193    #[test]
1194    fn test_from_client_config_spire_auth() {
1195        use crate::auth::spire::SpireConfig;
1196        let spire = SpireConfig {
1197            trust_domains: vec!["example.org".to_string()],
1198            ..Default::default()
1199        };
1200        let client = ClientConfig::with_endpoint("https://host:1234")
1201            .with_auth(AuthenticationConfig::Spire(spire));
1202        let server = ServerConnectionConfig::from_client_config(&client);
1203        assert_eq!(
1204            server.auth_method,
1205            RequiredAuthMethod::Spire {
1206                trust_domain: Some("example.org".to_string())
1207            }
1208        );
1209        assert!(server.tls_required);
1210    }
1211}