Skip to main content

rskit_grpc/
config.rs

1use std::time::Duration;
2
3use rskit_errors::{AppError, AppResult, ErrorCode};
4use rskit_resilience::Policy;
5use rskit_security::{TlsConfig, TlsVersion};
6use serde::{Deserialize, Serialize};
7
8/// Configuration for a gRPC client channel.
9#[derive(Clone, Serialize, Deserialize)]
10#[serde(default)]
11pub struct GrpcClientConfig {
12    /// Target address for the gRPC server (for example `localhost:50051`).
13    pub target: String,
14
15    /// Optional shared TLS configuration. When omitted, the channel uses plaintext.
16    pub tls: Option<TlsConfig>,
17
18    /// Default timeout for unary RPCs.
19    pub timeout: Duration,
20
21    /// Timeout for establishing a connection.
22    pub connect_timeout: Duration,
23
24    /// Keepalive interval (time between pings when no active streams).
25    pub keepalive_interval: Option<Duration>,
26
27    /// Keepalive timeout (how long to wait for ping ack before closing).
28    pub keepalive_timeout: Option<Duration>,
29
30    /// Maximum message size for receiving (in bytes).
31    pub max_message_size: usize,
32
33    /// Maximum message size for sending (in bytes).
34    pub max_send_message_size: usize,
35
36    /// Optional transport resilience policy.
37    #[serde(skip)]
38    pub resilience_policy: Option<Policy>,
39}
40
41impl std::fmt::Debug for GrpcClientConfig {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("GrpcClientConfig")
44            .field("target", &self.target)
45            .field("tls", &self.tls)
46            .field("timeout", &self.timeout)
47            .field("connect_timeout", &self.connect_timeout)
48            .field("keepalive_interval", &self.keepalive_interval)
49            .field("keepalive_timeout", &self.keepalive_timeout)
50            .field("max_message_size", &self.max_message_size)
51            .field("max_send_message_size", &self.max_send_message_size)
52            .field("has_resilience_policy", &self.resilience_policy.is_some())
53            .finish()
54    }
55}
56
57impl Default for GrpcClientConfig {
58    fn default() -> Self {
59        Self {
60            target: "localhost:50051".to_string(),
61            tls: None,
62            timeout: Duration::from_secs(30),
63            connect_timeout: Duration::from_secs(10),
64            keepalive_interval: Some(Duration::from_secs(30)),
65            keepalive_timeout: Some(Duration::from_secs(10)),
66            max_message_size: 4 * 1024 * 1024,
67            max_send_message_size: 4 * 1024 * 1024,
68            resilience_policy: None,
69        }
70    }
71}
72
73impl GrpcClientConfig {
74    /// Create a new [`GrpcClientConfig`] with the given target.
75    #[must_use]
76    pub fn new(target: impl Into<String>) -> Self {
77        Self {
78            target: target.into(),
79            ..Default::default()
80        }
81    }
82
83    /// Enable TLS with shared security configuration and default modern roots.
84    #[must_use]
85    pub fn with_tls(mut self, tls: TlsConfig) -> Self {
86        self.tls = Some(tls);
87        self
88    }
89
90    /// Set the transport resilience policy.
91    #[must_use]
92    pub fn with_resilience_policy(mut self, policy: Policy) -> Self {
93        self.resilience_policy = Some(policy);
94        self
95    }
96
97    /// Validate the configuration.
98    pub fn validate(&self) -> AppResult<()> {
99        if self.target.is_empty() {
100            return Err(AppError::new(
101                ErrorCode::InvalidInput,
102                "grpc client: target must not be empty",
103            ));
104        }
105
106        if self.max_message_size == 0 {
107            return Err(AppError::new(
108                ErrorCode::InvalidInput,
109                "grpc client: max_message_size must be positive",
110            ));
111        }
112
113        if self.max_send_message_size == 0 {
114            return Err(AppError::new(
115                ErrorCode::InvalidInput,
116                "grpc client: max_send_message_size must be positive",
117            ));
118        }
119
120        if let Some(tls) = &self.tls {
121            validate_grpc_tls(tls)?;
122        }
123
124        Ok(())
125    }
126
127    /// Get the target address for dialing.
128    #[must_use]
129    pub fn address(&self) -> &str {
130        &self.target
131    }
132}
133
134pub(crate) fn validate_grpc_tls(tls: &TlsConfig) -> AppResult<()> {
135    tls.validate()?;
136    if tls.skip_verify {
137        return Err(AppError::invalid_input(
138            "tls.skip_verify",
139            "gRPC client TLS does not allow disabling peer verification",
140        ));
141    }
142    if tls.min_version != TlsVersion::Tls12 {
143        return Err(AppError::invalid_input(
144            "tls.min_version",
145            "tonic ClientTlsConfig does not expose a minimum protocol-version floor; gRPC clients currently support the rustls default TLS 1.2 floor",
146        ));
147    }
148    Ok(())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn default_config_prefers_plaintext_until_tls_is_configured() {
157        let cfg = GrpcClientConfig::default();
158        assert_eq!(cfg.target, "localhost:50051");
159        assert!(cfg.tls.is_none());
160        assert_eq!(cfg.timeout, Duration::from_secs(30));
161    }
162
163    #[test]
164    fn with_tls_sets_explicit_tls_configuration() {
165        let cfg = GrpcClientConfig::new("example.com:443").with_tls(TlsConfig {
166            server_name: Some("api.example.com".to_string()),
167            ca_file: Some("certs/ca.pem".to_string()),
168            cert_file: Some("certs/client.pem".to_string()),
169            key_file: Some("certs/client.key".to_string()),
170            ..Default::default()
171        });
172        assert_eq!(
173            cfg.tls.as_ref().and_then(|tls| tls.server_name.as_deref()),
174            Some("api.example.com")
175        );
176        assert_eq!(
177            cfg.tls.as_ref().and_then(|tls| tls.ca_file.as_deref()),
178            Some("certs/ca.pem")
179        );
180    }
181
182    #[test]
183    fn validate_rejects_unsupported_insecure_tls_options() {
184        let cfg = GrpcClientConfig::new("example.com:443").with_tls(TlsConfig {
185            skip_verify: true,
186            ..Default::default()
187        });
188
189        assert!(cfg.validate().is_err());
190    }
191
192    #[test]
193    fn validate_rejects_unsupported_tls13_floor() {
194        let cfg = GrpcClientConfig::new("example.com:443").with_tls(TlsConfig {
195            min_version: rskit_security::TlsVersion::Tls13,
196            ..Default::default()
197        });
198
199        assert!(cfg.validate().is_err());
200    }
201
202    #[test]
203    fn validate_rejects_empty_target() {
204        let cfg = GrpcClientConfig {
205            target: String::new(),
206            ..Default::default()
207        };
208        assert!(cfg.validate().is_err());
209    }
210
211    #[test]
212    fn validate_rejects_zero_message_limits_independently() {
213        let recv_zero = GrpcClientConfig {
214            max_message_size: 0,
215            ..Default::default()
216        };
217        assert!(
218            recv_zero
219                .validate()
220                .unwrap_err()
221                .to_string()
222                .contains("max_message_size")
223        );
224
225        let send_zero = GrpcClientConfig {
226            max_send_message_size: 0,
227            ..Default::default()
228        };
229        assert!(
230            send_zero
231                .validate()
232                .unwrap_err()
233                .to_string()
234                .contains("max_send_message_size")
235        );
236    }
237
238    #[test]
239    fn debug_output_reports_resilience_policy_presence_without_dumping_policy() {
240        let cfg = GrpcClientConfig::new("example.com:443").with_resilience_policy(Policy::new());
241
242        let rendered = format!("{cfg:?}");
243
244        assert!(rendered.contains("example.com:443"));
245        assert!(rendered.contains("has_resilience_policy: true"));
246    }
247
248    #[test]
249    fn serde_defaults_fill_optional_transport_settings() {
250        let cfg: GrpcClientConfig = serde_json::from_value(serde_json::json!({
251            "target": "api.internal:443",
252            "max_message_size": 1024,
253            "max_send_message_size": 2048
254        }))
255        .unwrap();
256
257        assert_eq!(cfg.address(), "api.internal:443");
258        assert_eq!(cfg.timeout, Duration::from_secs(30));
259        assert_eq!(cfg.connect_timeout, Duration::from_secs(10));
260        assert_eq!(cfg.keepalive_interval, Some(Duration::from_secs(30)));
261        assert_eq!(cfg.max_message_size, 1024);
262        assert_eq!(cfg.max_send_message_size, 2048);
263    }
264}