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#[derive(Clone, Serialize, Deserialize)]
10#[serde(default)]
11pub struct GrpcClientConfig {
12 pub target: String,
14
15 pub tls: Option<TlsConfig>,
17
18 pub timeout: Duration,
20
21 pub connect_timeout: Duration,
23
24 pub keepalive_interval: Option<Duration>,
26
27 pub keepalive_timeout: Option<Duration>,
29
30 pub max_message_size: usize,
32
33 pub max_send_message_size: usize,
35
36 #[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 #[must_use]
76 pub fn new(target: impl Into<String>) -> Self {
77 Self {
78 target: target.into(),
79 ..Default::default()
80 }
81 }
82
83 #[must_use]
85 pub fn with_tls(mut self, tls: TlsConfig) -> Self {
86 self.tls = Some(tls);
87 self
88 }
89
90 #[must_use]
92 pub fn with_resilience_policy(mut self, policy: Policy) -> Self {
93 self.resilience_policy = Some(policy);
94 self
95 }
96
97 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 #[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}