Skip to main content

gcloud_sdk/
api_client.rs

1use std::marker::PhantomData;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use google_cloud_auth::credentials::Credentials;
6use once_cell::sync::Lazy;
7use tonic::transport::Channel;
8use tower::ServiceBuilder;
9use tracing::*;
10
11use crate::adc::AdcFile;
12use crate::metadata::MetadataServer;
13use crate::middleware::{GoogleAuthMiddlewareLayer, GoogleAuthMiddlewareService};
14use crate::GoogleAuthHeaders;
15
16#[async_trait]
17pub trait GoogleApiClientBuilder<C>
18where
19    C: Clone + Send,
20{
21    fn create_client(&self, channel: GoogleAuthMiddlewareService<Channel>) -> C;
22}
23
24#[derive(Clone)]
25pub struct GoogleApiClient<B, C>
26where
27    B: GoogleApiClientBuilder<C>,
28    C: Clone + Send,
29{
30    builder: B,
31    service: GoogleAuthMiddlewareService<Channel>,
32    _ph: PhantomData<C>,
33}
34
35impl<B, C> GoogleApiClient<B, C>
36where
37    B: GoogleApiClientBuilder<C>,
38    C: Clone + Send,
39{
40    pub async fn with_credentials<S: AsRef<str>>(
41        builder: B,
42        google_api_url: S,
43        cloud_resource_prefix: Option<String>,
44        credentials: Credentials,
45    ) -> crate::error::Result<Self> {
46        Self::with_credentials_and_headers(
47            builder,
48            google_api_url,
49            cloud_resource_prefix,
50            credentials,
51            hyper::HeaderMap::new(),
52        )
53        .await
54    }
55
56    pub async fn with_credentials_and_headers<S: AsRef<str>>(
57        builder: B,
58        google_api_url: S,
59        cloud_resource_prefix: Option<String>,
60        credentials: Credentials,
61        additional_headers: hyper::HeaderMap,
62    ) -> crate::error::Result<Self> {
63        debug!(
64            "Creating a new Google API client for {}",
65            google_api_url.as_ref()
66        );
67
68        let mut middleware = GoogleAuthMiddlewareLayer::new(
69            GoogleAuthHeaders::from(credentials),
70            cloud_resource_prefix,
71        )?;
72        middleware.set_additional_headers(additional_headers);
73
74        Self::with_middleware(builder, google_api_url, middleware).await
75    }
76
77    pub async fn with_middleware<S: AsRef<str>>(
78        builder: B,
79        google_api_url: S,
80        middleware: GoogleAuthMiddlewareLayer,
81    ) -> crate::error::Result<Self> {
82        let channel = GoogleEnvironment::init_google_services_channel(google_api_url).await?;
83        Ok(Self::on_channel(builder, channel, middleware))
84    }
85
86    fn on_channel(builder: B, channel: Channel, middleware: GoogleAuthMiddlewareLayer) -> Self {
87        Self {
88            builder,
89            service: ServiceBuilder::new().layer(middleware).service(channel),
90            _ph: PhantomData,
91        }
92    }
93
94    pub fn get(&self) -> C {
95        self.builder.create_client(self.service.clone())
96    }
97
98    /// Builds a client of another API on this client's authenticated channel: same
99    /// connection, authentication and headers, for an API served from the same endpoint
100    /// (Firestore's admin and long-running operations services, for example).
101    pub fn get_with<C2>(&self, f: impl FnOnce(GoogleAuthMiddlewareService<Channel>) -> C2) -> C2 {
102        f(self.service.clone())
103    }
104
105    /// Builds a client of an API served from another endpoint that reuses this client's
106    /// authentication: one [`GoogleAuthHeaders`], so the token is fetched and refreshed once for
107    /// both clients (BigQuery and Cloud Storage, for example).
108    ///
109    /// Unlike [`get_with`](Self::get_with), which shares the channel and so reaches only
110    /// the same host, this opens a new channel to `google_api_url`. The user agent,
111    /// `x-goog-api-client` and additional headers are carried over;
112    /// `google-cloud-resource-prefix` is not, since another API usually needs another
113    /// prefix or none, and is set from `cloud_resource_prefix` instead.
114    pub async fn connect_with_endpoint<C2, S: AsRef<str>>(
115        &self,
116        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C2,
117        google_api_url: S,
118        cloud_resource_prefix: Option<String>,
119    ) -> crate::error::Result<GoogleApi<C2>>
120    where
121        C2: Clone + Send,
122    {
123        let channel = GoogleEnvironment::init_google_services_channel(google_api_url).await?;
124        Ok(GoogleApiClient {
125            builder: GoogleApiClientBuilderFunction { f: builder_fn },
126            service: self.service.with_inner(channel, cloud_resource_prefix)?,
127            _ph: PhantomData,
128        })
129    }
130
131    pub fn amend_user_agent(mut self, user_agent: String) -> crate::error::Result<Self> {
132        self.service.append_user_agent(user_agent)?;
133        Ok(self)
134    }
135
136    pub fn amend_x_goog_api_client(
137        mut self,
138        x_goog_api_client: String,
139    ) -> crate::error::Result<Self> {
140        self.service.append_x_goog_api_client(x_goog_api_client)?;
141        Ok(self)
142    }
143}
144
145#[derive(Clone)]
146pub struct GoogleApiClientBuilderFunction<C>
147where
148    C: Clone + Send,
149{
150    f: fn(GoogleAuthMiddlewareService<Channel>) -> C,
151}
152
153impl<C> GoogleApiClientBuilder<C> for GoogleApiClientBuilderFunction<C>
154where
155    C: Clone + Send,
156{
157    fn create_client(&self, channel: GoogleAuthMiddlewareService<Channel>) -> C {
158        (self.f)(channel)
159    }
160}
161
162impl<C> GoogleApiClient<GoogleApiClientBuilderFunction<C>, C>
163where
164    C: Clone + Send,
165{
166    /// A client that authenticates with the Application Default Credentials, for the
167    /// `cloud-platform` scope.
168    pub async fn from_function<S: AsRef<str>>(
169        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
170        google_api_url: S,
171        cloud_resource_prefix_meta: Option<String>,
172    ) -> crate::error::Result<Self> {
173        Self::from_function_with_scopes(
174            builder_fn,
175            google_api_url,
176            cloud_resource_prefix_meta,
177            GCP_DEFAULT_SCOPES.clone(),
178        )
179        .await
180    }
181
182    pub async fn from_function_with_headers<S: AsRef<str>>(
183        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
184        google_api_url: S,
185        cloud_resource_prefix_meta: Option<String>,
186        headers: hyper::HeaderMap,
187    ) -> crate::error::Result<Self> {
188        Self::from_function_with_scopes_and_headers(
189            builder_fn,
190            google_api_url,
191            cloud_resource_prefix_meta,
192            GCP_DEFAULT_SCOPES.clone(),
193            headers,
194        )
195        .await
196    }
197
198    /// A client that authenticates with the Application Default Credentials, for
199    /// `token_scopes`.
200    ///
201    /// Fails with [`ErrorKind::CryptoProviderMissing`](crate::error::ErrorKind::CryptoProviderMissing)
202    /// for a service account key, used directly or as the source of an impersonation,
203    /// when the `auth-default-crypto` feature is off and no rustls crypto provider is
204    /// installed.
205    pub async fn from_function_with_scopes<S: AsRef<str>>(
206        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
207        google_api_url: S,
208        cloud_resource_prefix_meta: Option<String>,
209        token_scopes: Vec<String>,
210    ) -> crate::error::Result<Self> {
211        Self::from_function_with_scopes_and_headers(
212            builder_fn,
213            google_api_url,
214            cloud_resource_prefix_meta,
215            token_scopes,
216            hyper::HeaderMap::new(),
217        )
218        .await
219    }
220
221    pub async fn from_function_with_scopes_and_headers<S: AsRef<str>>(
222        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
223        google_api_url: S,
224        cloud_resource_prefix_meta: Option<String>,
225        token_scopes: Vec<String>,
226        headers: hyper::HeaderMap,
227    ) -> crate::error::Result<Self> {
228        // The channel comes first: with a single rustls provider compiled in, its TLS
229        // setup installs that provider, which the credentials may need to sign with.
230        let channel = GoogleEnvironment::init_google_services_channel(google_api_url).await?;
231        let mut middleware = GoogleAuthMiddlewareLayer::new(
232            GoogleAuthHeaders::from_adc_with_scopes(token_scopes).await?,
233            cloud_resource_prefix_meta,
234        )?;
235        middleware.set_additional_headers(headers);
236        Ok(Self::on_channel(
237            GoogleApiClientBuilderFunction { f: builder_fn },
238            channel,
239            middleware,
240        ))
241    }
242
243    /// A client that authenticates with `credentials`, built with
244    /// [`google_cloud_auth`](crate::google_cloud_auth): a service account key, impersonation,
245    /// workload identity federation, or a
246    /// [`CredentialsProvider`](google_cloud_auth::credentials::CredentialsProvider) of
247    /// your own.
248    ///
249    /// `credentials` built from a service account key need a rustls crypto provider when
250    /// the `auth-default-crypto` feature is off. They are opaque here, so this
251    /// constructor cannot check for one.
252    pub async fn from_function_with_credentials<S: AsRef<str>>(
253        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
254        google_api_url: S,
255        cloud_resource_prefix_meta: Option<String>,
256        credentials: Credentials,
257    ) -> crate::error::Result<Self> {
258        Self::from_function_with_credentials_and_headers(
259            builder_fn,
260            google_api_url,
261            cloud_resource_prefix_meta,
262            credentials,
263            hyper::HeaderMap::new(),
264        )
265        .await
266    }
267
268    pub async fn from_function_with_credentials_and_headers<S: AsRef<str>>(
269        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
270        google_api_url: S,
271        cloud_resource_prefix_meta: Option<String>,
272        credentials: Credentials,
273        headers: hyper::HeaderMap,
274    ) -> crate::error::Result<Self> {
275        Self::with_credentials_and_headers(
276            GoogleApiClientBuilderFunction { f: builder_fn },
277            google_api_url,
278            cloud_resource_prefix_meta,
279            credentials,
280            headers,
281        )
282        .await
283    }
284
285    pub async fn from_function_with_middleware<S: AsRef<str>>(
286        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
287        google_api_url: S,
288        middleware: GoogleAuthMiddlewareLayer,
289    ) -> crate::error::Result<Self> {
290        Self::with_middleware(
291            GoogleApiClientBuilderFunction { f: builder_fn },
292            google_api_url,
293            middleware,
294        )
295        .await
296    }
297}
298
299pub type GoogleAuthMiddleware = GoogleAuthMiddlewareService<Channel>;
300pub type GoogleApi<C> = GoogleApiClient<GoogleApiClientBuilderFunction<C>, C>;
301
302pub struct GoogleEnvironment;
303
304impl GoogleEnvironment {
305    /// The Google Cloud project of this environment: the first of the `GCP_PROJECT`,
306    /// `PROJECT_ID`, `GCP_PROJECT_ID` and `GOOGLE_CLOUD_PROJECT` environment variables
307    /// that is set, else the project the Application Default Credentials file names, else
308    /// the project of the metadata server.
309    pub async fn detect_google_project_id() -> Option<String> {
310        Self::detect_project_id_in(
311            |name| std::env::var(name).ok(),
312            AdcFile::load(),
313            MetadataServer::from_env(),
314        )
315        .await
316    }
317
318    async fn detect_project_id_in(
319        env: impl Fn(&str) -> Option<String>,
320        adc: Option<AdcFile>,
321        metadata: MetadataServer,
322    ) -> Option<String> {
323        if let Some(project_id) = PROJECT_ID_ENV_VARS.into_iter().find_map(env) {
324            debug!("Detected GCP Project ID using environment variables");
325            return Some(project_id);
326        }
327        if let Some(project_id) = adc.and_then(|adc| adc.project_id().map(ToString::to_string)) {
328            debug!("Detected GCP Project ID in the Application Default Credentials file");
329            return Some(project_id);
330        }
331        let project_id = metadata.project_id().await;
332        if project_id.is_some() {
333            debug!("Detected GCP Project ID using the metadata server");
334        } else {
335            debug!(
336                env = ?PROJECT_ID_ENV_VARS,
337                "No GCP Project ID detected in this environment; set one of the environment variables"
338            );
339        }
340        project_id
341    }
342
343    pub async fn init_google_services_channel<S: AsRef<str>>(
344        api_url: S,
345    ) -> Result<Channel, crate::error::Error> {
346        let api_url_string = api_url.as_ref().to_string();
347        let base_config = Channel::from_shared(api_url_string.clone())?
348            .connect_timeout(Duration::from_secs(30))
349            .tcp_keepalive(Some(Duration::from_secs(60)))
350            .keep_alive_timeout(Duration::from_secs(60))
351            .http2_keep_alive_interval(Duration::from_secs(60))
352            .keep_alive_while_idle(true);
353
354        let config = if let Some(domain_name) = tls_server_name(&api_url_string)? {
355            let tls_config = Self::init_tls_config(domain_name);
356            base_config.tls_config(tls_config)?
357        } else {
358            base_config
359        };
360
361        Ok(config.connect().await?)
362    }
363
364    #[cfg(not(any(feature = "tls-roots", feature = "tls-webpki-roots")))]
365    fn init_tls_config(domain_name: String) -> tonic::transport::ClientTlsConfig {
366        tonic::transport::ClientTlsConfig::new()
367            .ca_certificate(tonic::transport::Certificate::from_pem(
368                crate::apis::CERTIFICATES,
369            ))
370            .domain_name(domain_name)
371    }
372
373    #[cfg(feature = "tls-roots")]
374    fn init_tls_config(domain_name: String) -> tonic::transport::ClientTlsConfig {
375        tonic::transport::ClientTlsConfig::new()
376            .with_native_roots()
377            .domain_name(domain_name)
378    }
379
380    #[cfg(all(feature = "tls-webpki-roots", not(feature = "tls-roots")))]
381    fn init_tls_config(domain_name: String) -> tonic::transport::ClientTlsConfig {
382        tonic::transport::ClientTlsConfig::new()
383            .with_webpki_roots()
384            .domain_name(domain_name)
385    }
386}
387
388/// The TLS server name to verify for a gRPC API URL.
389///
390/// Returns the URL's host, without port or path, for an `https` URL, and `None`
391/// for a plain `http` URL, which connects without TLS. Any other scheme, a URL
392/// without a scheme or host, or an unparseable URL is an error.
393fn tls_server_name(api_url: &str) -> Result<Option<String>, crate::error::Error> {
394    let uri: hyper::http::Uri = api_url.parse()?;
395    let invalid = |reason: &str| {
396        crate::error::Error::from(crate::error::ErrorKind::InvalidApiUrl(format!(
397            "{reason}: {api_url}"
398        )))
399    };
400    match uri.scheme_str() {
401        Some("https") => {
402            let host = uri
403                .host()
404                .filter(|host| !host.is_empty())
405                .ok_or_else(|| invalid("missing host"))?;
406            let host = host
407                .strip_prefix('[')
408                .and_then(|h| h.strip_suffix(']'))
409                .unwrap_or(host);
410            Ok(Some(host.to_string()))
411        }
412        Some("http") => Ok(None),
413        Some(_) => Err(invalid("unsupported scheme")),
414        None => Err(invalid("missing scheme")),
415    }
416}
417
418/// The environment variables that name the project, in the order they are read.
419const PROJECT_ID_ENV_VARS: [&str; 4] = [
420    "GCP_PROJECT",
421    "PROJECT_ID",
422    "GCP_PROJECT_ID",
423    "GOOGLE_CLOUD_PROJECT",
424];
425
426pub static GCP_DEFAULT_SCOPES: Lazy<Vec<String>> =
427    Lazy::new(|| vec!["https://www.googleapis.com/auth/cloud-platform".into()]);
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::test_support::{StubCredentials, StubResponse, StubServer};
433    use serde_json::json;
434    use std::sync::atomic::{AtomicUsize, Ordering};
435    use std::sync::Arc;
436
437    #[test]
438    fn tls_server_name_is_the_host_of_a_plain_https_url() {
439        assert_eq!(
440            tls_server_name("https://bigquery.googleapis.com").unwrap(),
441            Some("bigquery.googleapis.com".to_string())
442        );
443    }
444
445    #[test]
446    fn tls_server_name_ignores_a_trailing_slash() {
447        assert_eq!(
448            tls_server_name("https://bigquery.googleapis.com/").unwrap(),
449            Some("bigquery.googleapis.com".to_string())
450        );
451    }
452
453    #[test]
454    fn tls_server_name_ignores_the_path() {
455        assert_eq!(
456            tls_server_name("https://bigquery.googleapis.com/bigquery/v2").unwrap(),
457            Some("bigquery.googleapis.com".to_string())
458        );
459    }
460
461    #[test]
462    fn tls_server_name_ignores_an_explicit_port() {
463        assert_eq!(
464            tls_server_name("https://bigquery.googleapis.com:443").unwrap(),
465            Some("bigquery.googleapis.com".to_string())
466        );
467    }
468
469    #[test]
470    fn plain_http_url_uses_no_tls() {
471        assert_eq!(tls_server_name("http://localhost:8080").unwrap(), None);
472        assert_eq!(tls_server_name("http://localhost:8080/").unwrap(), None);
473    }
474
475    #[test]
476    fn url_without_scheme_or_host_is_an_error() {
477        assert!(tls_server_name("bigquery.googleapis.com").is_err());
478        assert!(tls_server_name("/bigquery/v2").is_err());
479        assert!(tls_server_name("https://").is_err());
480        assert!(tls_server_name("not a url").is_err());
481        assert!(tls_server_name("").is_err());
482    }
483    // Neither address is dialed: the middleware fetches the token before handing the
484    // request to the channel, and a closed loopback port fails the connection quickly
485    // without ever reaching the network.
486    async fn probe(service: &mut GoogleAuthMiddlewareService<Channel>) {
487        let req = hyper::Request::builder()
488            .uri("http://127.0.0.1:1/")
489            .body(tonic::body::Body::empty())
490            .unwrap();
491        let ready = tower::ServiceExt::ready(service).await.unwrap();
492        let _ = tower::Service::call(ready, req).await;
493    }
494
495    #[tokio::test]
496    async fn get_with_shares_the_authenticated_channel() {
497        let credentials = StubCredentials::bearer("counted-token");
498        let middleware = GoogleAuthMiddlewareLayer::new(
499            GoogleAuthHeaders::from(Credentials::from(credentials.clone())),
500            None,
501        )
502        .unwrap();
503        let channel = Channel::from_static("http://127.0.0.1:1").connect_lazy();
504        let service: GoogleAuthMiddlewareService<Channel> =
505            ServiceBuilder::new().layer(middleware).service(channel);
506
507        let client: GoogleApiClient<
508            GoogleApiClientBuilderFunction<GoogleAuthMiddlewareService<Channel>>,
509            GoogleAuthMiddlewareService<Channel>,
510        > = GoogleApiClient {
511            builder: GoogleApiClientBuilderFunction { f: |svc| svc },
512            service,
513            _ph: PhantomData,
514        };
515
516        let mut from_get = client.get();
517        let mut from_get_with = client.get_with(|svc| svc);
518
519        probe(&mut from_get).await;
520        probe(&mut from_get_with).await;
521
522        // A client built with `get_with` reuses the same `Arc<GoogleAuthHeaders>`
523        // as one built with `get`, so its header cache is shared: the headers are served
524        // in full once, not once per client.
525        assert_eq!(credentials.served(), 1);
526    }
527
528    // A gRPC endpoint that accepts connections and never answers, counting the
529    // connections it received: the channel's eager connect succeeds against it, and a
530    // request sent to it reaches the middleware's token fetch before it stalls.
531    async fn silent_endpoint() -> (String, Arc<AtomicUsize>) {
532        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
533        let url = format!("http://{}", listener.local_addr().unwrap());
534        let connections = Arc::new(AtomicUsize::new(0));
535        let counter = connections.clone();
536        tokio::spawn(async move {
537            let mut held = Vec::new();
538            while let Ok((socket, _)) = listener.accept().await {
539                counter.fetch_add(1, Ordering::SeqCst);
540                held.push(socket);
541            }
542        });
543        (url, connections)
544    }
545
546    async fn send_stalled(mut service: GoogleAuthMiddlewareService<Channel>) {
547        let _ = tokio::time::timeout(Duration::from_millis(200), probe(&mut service)).await;
548    }
549
550    #[tokio::test]
551    async fn connect_with_endpoint_shares_auth_with_a_client_on_another_host() {
552        let credentials = StubCredentials::bearer("counted-token");
553        let (first_url, first_connections) = silent_endpoint().await;
554        let (second_url, second_connections) = silent_endpoint().await;
555
556        let first: GoogleApi<GoogleAuthMiddlewareService<Channel>> =
557            GoogleApi::from_function_with_credentials(
558                |svc| svc,
559                first_url,
560                None,
561                Credentials::from(credentials.clone()),
562            )
563            .await
564            .unwrap();
565        let second = first
566            .connect_with_endpoint(|svc| svc, second_url, None)
567            .await
568            .unwrap();
569
570        send_stalled(first.get()).await;
571        send_stalled(second.get()).await;
572
573        assert_eq!(first_connections.load(Ordering::SeqCst), 1);
574        assert_eq!(second_connections.load(Ordering::SeqCst), 1);
575        assert_eq!(credentials.served(), 1);
576    }
577
578    async fn detected_project_id(
579        env: &[(&str, &str)],
580        adc: Option<serde_json::Value>,
581        metadata: &StubServer,
582    ) -> Option<String> {
583        GoogleEnvironment::detect_project_id_in(
584            |name| {
585                env.iter()
586                    .find(|(set, _)| *set == name)
587                    .map(|(_, value)| value.to_string())
588            },
589            adc.map(AdcFile::from),
590            MetadataServer::at(metadata.url.clone()),
591        )
592        .await
593    }
594
595    #[tokio::test]
596    async fn project_id_sources_are_read_in_order() {
597        let metadata = StubServer::start(vec![StubResponse::text("200 OK", "metadata-project")
598            .with_header("Metadata-Flavor", "Google")])
599        .await;
600        let mut env = vec![
601            ("GCP_PROJECT", "gcp-project"),
602            ("PROJECT_ID", "project-id"),
603            ("GCP_PROJECT_ID", "gcp-project-id"),
604            ("GOOGLE_CLOUD_PROJECT", "google-cloud-project"),
605        ];
606        let mut adc = json!({
607            "type": "impersonated_service_account",
608            "project_id": "adc-project",
609            "quota_project_id": "adc-quota-project",
610            "source_credentials": {
611                "type": "service_account",
612                "project_id": "source-project",
613                "quota_project_id": "source-quota-project",
614            },
615        });
616
617        let mut detected = Vec::new();
618        while !env.is_empty() {
619            detected.push(detected_project_id(&env, Some(adc.clone()), &metadata).await);
620            env.remove(0);
621        }
622        for field in [
623            "/project_id",
624            "/quota_project_id",
625            "/source_credentials/project_id",
626        ] {
627            detected.push(detected_project_id(&env, Some(adc.clone()), &metadata).await);
628            let (parent, name) = field.rsplit_once('/').unwrap();
629            adc.pointer_mut(parent)
630                .and_then(serde_json::Value::as_object_mut)
631                .unwrap()
632                .remove(name);
633        }
634        detected.push(detected_project_id(&env, Some(adc), &metadata).await);
635        detected.push(detected_project_id(&env, None, &metadata).await);
636
637        assert_eq!(
638            detected,
639            [
640                "gcp-project",
641                "project-id",
642                "gcp-project-id",
643                "google-cloud-project",
644                "adc-project",
645                "adc-quota-project",
646                "source-project",
647                "source-quota-project",
648                "metadata-project",
649            ]
650            .map(|project| Some(project.to_string()))
651        );
652    }
653}