Skip to main content

cf_system_sdk_directory/grpc/
client.rs

1//! gRPC client implementation of Directory API
2//!
3//! This client allows remote gears to discover and resolve services via gRPC.
4
5use anyhow::Result;
6use async_trait::async_trait;
7use tonic::transport::Channel;
8
9use crate::api::{DirectoryClient, RegisterInstanceInfo, ServiceEndpoint, ServiceInstanceInfo};
10use toolkit_transport_grpc::client::{GrpcClientConfig, connect_with_retry};
11
12use crate::{
13    DeregisterInstanceRequest, DirectoryServiceClient, GetOpenApiSpecRequest, GrpcServiceEndpoint,
14    HeartbeatRequest, ListInstancesRequest, RegisterInstanceRequest, ResolveGrpcServiceRequest,
15    ResolveRestServiceRequest,
16};
17
18/// gRPC client for Directory API
19///
20/// This client connects to a remote `DirectoryService` via gRPC and provides
21/// typed access to service discovery functionality. It includes:
22/// - Configurable timeouts and retries via transport stack
23/// - Automatic proto ↔ domain type conversions
24/// - Distributed tracing and metrics
25pub struct DirectoryGrpcClient {
26    inner: DirectoryServiceClient<Channel>,
27}
28
29impl DirectoryGrpcClient {
30    /// Connect to a directory service using default configuration with retries.
31    ///
32    /// Uses exponential backoff retry logic for reliable connection establishment.
33    /// This is the recommended method for `OoP` gears connecting to the master host.
34    ///
35    /// # Errors
36    /// It will return an error when it fails
37    pub async fn connect(uri: impl Into<String>) -> Result<Self> {
38        let cfg = GrpcClientConfig::new("directory");
39        Self::connect_with_retry(uri, &cfg).await
40    }
41
42    /// Connect to a directory service with custom configuration and retry logic.
43    ///
44    /// Uses exponential backoff based on `cfg.max_retries`, `cfg.base_backoff`,
45    /// and `cfg.max_backoff` settings.
46    ///
47    /// # Errors
48    /// It will return an error when it fails
49    pub async fn connect_with_retry(
50        uri: impl Into<String>,
51        cfg: &GrpcClientConfig,
52    ) -> Result<Self> {
53        let channel: Channel = connect_with_retry(uri, cfg).await?;
54        Ok(Self {
55            inner: DirectoryServiceClient::new(channel),
56        })
57    }
58
59    /// Connect to a directory service without retry logic.
60    ///
61    /// This method attempts a single connection. Use `connect` or `connect_with_retry`
62    /// for production scenarios where the directory service may not be immediately available.
63    ///
64    /// # Errors
65    /// It will return an error when it fails
66    pub async fn connect_no_retry(uri: impl Into<String>, cfg: &GrpcClientConfig) -> Result<Self> {
67        let uri_string = uri.into();
68
69        // Create endpoint with timeouts from config
70        let endpoint = tonic::transport::Endpoint::from_shared(uri_string)?
71            .connect_timeout(cfg.connect_timeout)
72            .timeout(cfg.rpc_timeout);
73
74        // Connect to the service
75        let channel = endpoint.connect().await?;
76
77        if cfg.enable_tracing {
78            tracing::debug!(
79                service_name = cfg.service_name,
80                connect_timeout_ms = cfg.connect_timeout.as_millis(),
81                rpc_timeout_ms = cfg.rpc_timeout.as_millis(),
82                "directory gRPC client connected"
83            );
84        }
85
86        Ok(Self {
87            inner: DirectoryServiceClient::new(channel),
88        })
89    }
90
91    /// Create from an existing channel (useful for testing or custom setup)
92    #[must_use]
93    pub fn from_channel(channel: Channel) -> Self {
94        Self {
95            inner: DirectoryServiceClient::new(channel),
96        }
97    }
98}
99
100#[async_trait]
101impl DirectoryClient for DirectoryGrpcClient {
102    async fn resolve_grpc_service(&self, service_name: &str) -> Result<ServiceEndpoint> {
103        let mut client = self.inner.clone();
104        let request = tonic::Request::new(ResolveGrpcServiceRequest {
105            service_name: service_name.to_owned(),
106        });
107
108        let response = client
109            .resolve_grpc_service(request)
110            .await
111            .map_err(|e| anyhow::anyhow!("gRPC call failed: {e}"))?;
112
113        let proto_response = response.into_inner();
114        Ok(ServiceEndpoint::new(proto_response.endpoint_uri))
115    }
116
117    async fn resolve_rest_service(&self, gear_name: &str) -> Result<ServiceEndpoint> {
118        let mut client = self.inner.clone();
119        let request = tonic::Request::new(ResolveRestServiceRequest {
120            gear_name: gear_name.to_owned(),
121        });
122
123        let response = client
124            .resolve_rest_service(request)
125            .await
126            .map_err(|e| anyhow::anyhow!("gRPC call failed: {e}"))?;
127
128        let proto_response = response.into_inner();
129        Ok(ServiceEndpoint::new(proto_response.endpoint_uri))
130    }
131
132    async fn get_openapi_spec(&self, gear_name: &str) -> Result<String> {
133        let mut client = self.inner.clone();
134        let request = tonic::Request::new(GetOpenApiSpecRequest {
135            gear_name: gear_name.to_owned(),
136        });
137
138        let response = client
139            .get_open_api_spec(request)
140            .await
141            .map_err(|e| anyhow::anyhow!("gRPC call failed: {e}"))?;
142
143        Ok(response.into_inner().openapi_spec)
144    }
145
146    async fn list_instances(&self, gear: &str) -> Result<Vec<ServiceInstanceInfo>> {
147        let mut client = self.inner.clone();
148        let request = tonic::Request::new(ListInstancesRequest {
149            gear_name: gear.to_owned(),
150        });
151
152        let response = client
153            .list_instances(request)
154            .await
155            .map_err(|e| anyhow::anyhow!("gRPC call failed: {e}"))?;
156
157        let proto_response = response.into_inner();
158
159        // Convert proto instances to domain types
160        let instances = proto_response
161            .instances
162            .into_iter()
163            .map(|proto_inst| ServiceInstanceInfo {
164                gear: proto_inst.gear_name,
165                instance_id: proto_inst.instance_id,
166                endpoint: ServiceEndpoint::new(proto_inst.endpoint_uri),
167                version: if proto_inst.version.is_empty() {
168                    None
169                } else {
170                    Some(proto_inst.version)
171                },
172                rest_endpoint: proto_inst.rest_endpoint_uri.map(ServiceEndpoint::new),
173            })
174            .collect();
175
176        Ok(instances)
177    }
178
179    async fn register_instance(&self, info: RegisterInstanceInfo) -> Result<()> {
180        let mut client = self.inner.clone();
181
182        // Convert gRPC service endpoints
183        let grpc_services = info
184            .grpc_services
185            .into_iter()
186            .map(|(name, ep)| GrpcServiceEndpoint {
187                service_name: name,
188                endpoint_uri: ep.uri,
189            })
190            .collect();
191
192        let req = RegisterInstanceRequest {
193            gear_name: info.gear,
194            instance_id: info.instance_id,
195            grpc_services,
196            version: info.version.unwrap_or_default(),
197            rest_endpoint_uri: info.rest_endpoint.map(|ep| ep.uri),
198            openapi_spec: info.openapi_spec,
199        };
200
201        client
202            .register_instance(tonic::Request::new(req))
203            .await
204            .map_err(|e| anyhow::anyhow!("gRPC register_instance failed: {e}"))?;
205
206        Ok(())
207    }
208
209    async fn deregister_instance(&self, gear: &str, instance_id: &str) -> Result<()> {
210        let mut client = self.inner.clone();
211
212        let req = DeregisterInstanceRequest {
213            gear_name: gear.to_owned(),
214            instance_id: instance_id.to_owned(),
215        };
216
217        client
218            .deregister_instance(tonic::Request::new(req))
219            .await
220            .map_err(|e| anyhow::anyhow!("gRPC deregister_instance failed: {e}"))?;
221
222        Ok(())
223    }
224
225    async fn send_heartbeat(&self, gear: &str, instance_id: &str) -> Result<()> {
226        let mut client = self.inner.clone();
227
228        let req = HeartbeatRequest {
229            gear_name: gear.to_owned(),
230            instance_id: instance_id.to_owned(),
231        };
232
233        client
234            .heartbeat(tonic::Request::new(req))
235            .await
236            .map_err(|e| anyhow::anyhow!("gRPC heartbeat failed: {e}"))?;
237
238        Ok(())
239    }
240}
241
242#[cfg(test)]
243#[cfg_attr(coverage_nightly, coverage(off))]
244mod tests {
245    use super::*;
246
247    #[tokio::test]
248    async fn test_grpc_client_can_be_constructed() {
249        // Smoke test to ensure types compile and connect
250        let endpoint = tonic::transport::Endpoint::from_static("http://[::1]:50051");
251
252        // We can't actually connect without a server, but we can construct the client type
253        // This ensures the API is correct
254        let channel_result = endpoint.connect().await;
255
256        // It's expected to fail since there's no server, but if it does somehow succeed:
257        if let Ok(channel) = channel_result {
258            let _client = DirectoryGrpcClient::from_channel(channel);
259        }
260    }
261}