cf_system_sdk_directory/grpc/
client.rs1use anyhow::Result;
6use async_trait::async_trait;
7use tonic::service::interceptor::InterceptedService;
8use tonic::transport::Channel;
9
10use crate::api::{
11 DirectoryClient, DirectoryInvalidArgument, DirectoryNotFound, RegisterInstanceInfo,
12 ServiceEndpoint, ServiceInstanceInfo,
13};
14use toolkit_transport_grpc::InternalAuthInterceptor;
15use toolkit_transport_grpc::client::{GrpcClientConfig, connect_with_retry};
16
17use crate::{
18 DeregisterInstanceRequest, DirectoryServiceClient, GetOpenApiSpecRequest, GrpcServiceEndpoint,
19 HeartbeatRequest, InstanceInfo, ListAllInstancesRequest, ListInstancesRequest,
20 RegisterInstanceRequest, ResolveGrpcServiceRequest, ResolveRestServiceRequest,
21};
22
23type AuthedChannel = InterceptedService<Channel, InternalAuthInterceptor>;
29
30fn lookup_error(resource: &str, status: &tonic::Status) -> anyhow::Error {
38 match status.code() {
39 tonic::Code::NotFound => DirectoryNotFound::new(resource.to_owned()).into(),
40 tonic::Code::InvalidArgument => {
41 DirectoryInvalidArgument::new(status.message().to_owned()).into()
42 }
43 code => anyhow::anyhow!(
44 "directory lookup for {resource} failed: gRPC {code:?}: {}",
45 status.message()
46 ),
47 }
48}
49
50fn call_error(op: &str, status: &tonic::Status) -> anyhow::Error {
56 anyhow::anyhow!(
57 "directory {op} failed: gRPC {:?}: {}",
58 status.code(),
59 status.message()
60 )
61}
62
63pub struct DirectoryGrpcClient {
74 inner: DirectoryServiceClient<AuthedChannel>,
75}
76
77impl DirectoryGrpcClient {
78 pub async fn connect(uri: impl Into<String>) -> Result<Self> {
86 let cfg = GrpcClientConfig::new("directory");
87 Self::connect_with_retry(uri, &cfg).await
88 }
89
90 pub async fn connect_with_interceptor(
102 uri: impl Into<String>,
103 interceptor: InternalAuthInterceptor,
104 ) -> Result<Self> {
105 let cfg = GrpcClientConfig::new("directory");
106 let channel: Channel = connect_with_retry(uri, &cfg).await?;
107 Ok(Self::from_channel_with_interceptor(channel, interceptor))
108 }
109
110 pub async fn connect_with_retry(
118 uri: impl Into<String>,
119 cfg: &GrpcClientConfig,
120 ) -> Result<Self> {
121 let channel: Channel = connect_with_retry(uri, cfg).await?;
122 Ok(Self::from_channel(channel))
123 }
124
125 pub async fn connect_no_retry(uri: impl Into<String>, cfg: &GrpcClientConfig) -> Result<Self> {
133 let uri_string = uri.into();
134
135 let endpoint = tonic::transport::Endpoint::from_shared(uri_string)?
137 .connect_timeout(cfg.connect_timeout)
138 .timeout(cfg.rpc_timeout);
139
140 let channel = endpoint.connect().await?;
142
143 if cfg.enable_tracing {
144 tracing::debug!(
145 service_name = cfg.service_name,
146 connect_timeout_ms = cfg.connect_timeout.as_millis(),
147 rpc_timeout_ms = cfg.rpc_timeout.as_millis(),
148 "directory gRPC client connected"
149 );
150 }
151
152 Ok(Self::from_channel(channel))
153 }
154
155 #[must_use]
161 pub fn from_channel(channel: Channel) -> Self {
162 Self::from_channel_with_interceptor(channel, InternalAuthInterceptor::disabled())
163 }
164
165 #[must_use]
168 pub fn from_channel_with_interceptor(
169 channel: Channel,
170 interceptor: InternalAuthInterceptor,
171 ) -> Self {
172 Self {
173 inner: DirectoryServiceClient::with_interceptor(channel, interceptor),
174 }
175 }
176}
177
178#[async_trait]
179impl DirectoryClient for DirectoryGrpcClient {
180 async fn resolve_grpc_service(&self, service_name: &str) -> Result<ServiceEndpoint> {
181 let mut client = self.inner.clone();
182 let request = tonic::Request::new(ResolveGrpcServiceRequest {
183 service_name: service_name.to_owned(),
184 });
185
186 let response = client
187 .resolve_grpc_service(request)
188 .await
189 .map_err(|e| lookup_error(&format!("service {service_name}"), &e))?;
190
191 let proto_response = response.into_inner();
192 Ok(ServiceEndpoint::new(proto_response.endpoint_uri))
193 }
194
195 async fn resolve_rest_service(&self, gear_name: &str) -> Result<ServiceEndpoint> {
196 let mut client = self.inner.clone();
197 let request = tonic::Request::new(ResolveRestServiceRequest {
198 gear_name: gear_name.to_owned(),
199 });
200
201 let response = client
202 .resolve_rest_service(request)
203 .await
204 .map_err(|e| lookup_error(&format!("gear {gear_name}"), &e))?;
205
206 let proto_response = response.into_inner();
207 Ok(ServiceEndpoint::new(proto_response.endpoint_uri))
208 }
209
210 async fn get_openapi_spec(&self, gear_name: &str) -> Result<String> {
211 let mut client = self.inner.clone();
212 let request = tonic::Request::new(GetOpenApiSpecRequest {
213 gear_name: gear_name.to_owned(),
214 });
215
216 let response = client
217 .get_open_api_spec(request)
218 .await
219 .map_err(|e| lookup_error(&format!("openapi spec for gear {gear_name}"), &e))?;
220
221 Ok(response.into_inner().openapi_spec)
222 }
223
224 async fn list_instances(&self, gear: &str) -> Result<Vec<ServiceInstanceInfo>> {
225 let mut client = self.inner.clone();
226 let request = tonic::Request::new(ListInstancesRequest {
227 gear_name: gear.to_owned(),
228 });
229
230 let response = client
231 .list_instances(request)
232 .await
233 .map_err(|e| lookup_error(&format!("instances of gear {gear}"), &e))?;
234
235 let instances = response
236 .into_inner()
237 .instances
238 .into_iter()
239 .map(proto_instance_to_domain)
240 .collect();
241
242 Ok(instances)
243 }
244
245 async fn list_all_instances(&self) -> Result<Vec<ServiceInstanceInfo>> {
246 let mut client = self.inner.clone();
247 let response = client
248 .list_all_instances(tonic::Request::new(ListAllInstancesRequest {}))
249 .await
250 .map_err(|e| lookup_error("all instances", &e))?;
251
252 let instances = response
253 .into_inner()
254 .instances
255 .into_iter()
256 .map(|proto| {
257 let mut info = proto_instance_to_domain(proto);
258 info.openapi_spec = None;
259 info
260 })
261 .collect();
262
263 Ok(instances)
264 }
265
266 async fn register_instance(&self, info: RegisterInstanceInfo) -> Result<()> {
267 let mut client = self.inner.clone();
268
269 let grpc_services = info
271 .grpc_services
272 .into_iter()
273 .map(|(name, ep)| GrpcServiceEndpoint {
274 service_name: name,
275 endpoint_uri: ep.uri,
276 })
277 .collect();
278
279 let req = RegisterInstanceRequest {
280 gear_name: info.gear,
281 instance_id: info.instance_id,
282 grpc_services,
283 version: info.version.unwrap_or_default(),
284 rest_endpoint_uri: info.rest_endpoint.map(|ep| ep.uri),
285 openapi_spec: info.openapi_spec,
286 };
287
288 client
289 .register_instance(tonic::Request::new(req))
290 .await
291 .map_err(|e| call_error("register_instance", &e))?;
292
293 Ok(())
294 }
295
296 async fn deregister_instance(&self, gear: &str, instance_id: &str) -> Result<()> {
297 let mut client = self.inner.clone();
298
299 let req = DeregisterInstanceRequest {
300 gear_name: gear.to_owned(),
301 instance_id: instance_id.to_owned(),
302 };
303
304 client
305 .deregister_instance(tonic::Request::new(req))
306 .await
307 .map_err(|e| call_error("deregister_instance", &e))?;
308
309 Ok(())
310 }
311
312 async fn send_heartbeat(&self, gear: &str, instance_id: &str) -> Result<()> {
313 let mut client = self.inner.clone();
314
315 let req = HeartbeatRequest {
316 gear_name: gear.to_owned(),
317 instance_id: instance_id.to_owned(),
318 };
319
320 client
321 .heartbeat(tonic::Request::new(req))
322 .await
323 .map_err(|e| call_error("heartbeat", &e))?;
324
325 Ok(())
326 }
327}
328
329fn proto_instance_to_domain(proto: InstanceInfo) -> ServiceInstanceInfo {
331 ServiceInstanceInfo {
332 gear: proto.gear_name,
333 instance_id: proto.instance_id,
334 endpoint: ServiceEndpoint::new(proto.endpoint_uri),
335 version: if proto.version.is_empty() {
336 None
337 } else {
338 Some(proto.version)
339 },
340 rest_endpoint: proto.rest_endpoint_uri.map(ServiceEndpoint::new),
341 openapi_spec: proto.openapi_spec,
342 openapi_spec_hash: proto.openapi_spec_hash,
343 grpc_services: Vec::new(),
348 }
349}
350
351#[cfg(test)]
352#[cfg_attr(coverage_nightly, coverage(off))]
353mod tests {
354 use super::*;
355
356 #[tokio::test]
357 async fn test_grpc_client_can_be_constructed() {
358 let endpoint = tonic::transport::Endpoint::from_static("http://[::1]:50051");
360
361 let channel_result = endpoint.connect().await;
364
365 if let Ok(channel) = channel_result {
367 let _client = DirectoryGrpcClient::from_channel(channel);
368 }
369 }
370
371 #[tokio::test]
372 async fn from_channel_constructs_without_connecting() {
373 let channel = Channel::from_static("http://[::1]:50051").connect_lazy();
377 let _default = DirectoryGrpcClient::from_channel(channel.clone());
378 let _authed = DirectoryGrpcClient::from_channel_with_interceptor(
379 channel,
380 InternalAuthInterceptor::disabled(),
381 );
382 }
383
384 #[test]
385 fn proto_instance_maps_all_fields_to_domain() {
386 let proto = InstanceInfo {
387 gear_name: "calc".to_owned(),
388 instance_id: "calc-1".to_owned(),
389 endpoint_uri: "http://calc:8080".to_owned(),
390 version: "1.2.3".to_owned(),
391 rest_endpoint_uri: Some("http://calc:8080".to_owned()),
392 openapi_spec: Some("{\"openapi\":\"3.1.0\"}".to_owned()),
393 openapi_spec_hash: None,
394 };
395 let domain = proto_instance_to_domain(proto);
396 assert_eq!(domain.gear, "calc");
397 assert_eq!(domain.instance_id, "calc-1");
398 assert_eq!(domain.endpoint.uri, "http://calc:8080");
399 assert_eq!(domain.version.as_deref(), Some("1.2.3"));
400 assert_eq!(
401 domain.rest_endpoint.map(|e| e.uri),
402 Some("http://calc:8080".to_owned())
403 );
404 assert!(domain.openapi_spec.is_some());
405 }
406
407 #[test]
408 fn proto_instance_maps_empty_version_to_none() {
409 let proto = InstanceInfo {
410 gear_name: "worker".to_owned(),
411 instance_id: "worker-1".to_owned(),
412 endpoint_uri: "http://worker:7000".to_owned(),
413 version: String::new(),
414 rest_endpoint_uri: None,
415 openapi_spec: None,
416 openapi_spec_hash: None,
417 };
418 let domain = proto_instance_to_domain(proto);
419 assert!(domain.version.is_none());
421 assert!(domain.rest_endpoint.is_none());
422 assert!(domain.openapi_spec.is_none());
423 }
424}