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, RequestTimeouts};
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    /// Applies `request_timeouts` to every call of this client, and of the clients made from
145    /// it with [`get_with`](Self::get_with) and
146    /// [`connect_with_endpoint`](Self::connect_with_endpoint), unless the call carries
147    /// [`RequestTimeouts`] of its own.
148    pub fn with_request_timeouts(mut self, request_timeouts: RequestTimeouts) -> Self {
149        self.service.set_request_timeouts(request_timeouts);
150        self
151    }
152}
153
154#[derive(Clone)]
155pub struct GoogleApiClientBuilderFunction<C>
156where
157    C: Clone + Send,
158{
159    f: fn(GoogleAuthMiddlewareService<Channel>) -> C,
160}
161
162impl<C> GoogleApiClientBuilder<C> for GoogleApiClientBuilderFunction<C>
163where
164    C: Clone + Send,
165{
166    fn create_client(&self, channel: GoogleAuthMiddlewareService<Channel>) -> C {
167        (self.f)(channel)
168    }
169}
170
171impl<C> GoogleApiClient<GoogleApiClientBuilderFunction<C>, C>
172where
173    C: Clone + Send,
174{
175    /// A client that authenticates with the Application Default Credentials, for the
176    /// `cloud-platform` scope.
177    pub async fn from_function<S: AsRef<str>>(
178        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
179        google_api_url: S,
180        cloud_resource_prefix_meta: Option<String>,
181    ) -> crate::error::Result<Self> {
182        Self::from_function_with_scopes(
183            builder_fn,
184            google_api_url,
185            cloud_resource_prefix_meta,
186            GCP_DEFAULT_SCOPES.clone(),
187        )
188        .await
189    }
190
191    pub async fn from_function_with_headers<S: AsRef<str>>(
192        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
193        google_api_url: S,
194        cloud_resource_prefix_meta: Option<String>,
195        headers: hyper::HeaderMap,
196    ) -> crate::error::Result<Self> {
197        Self::from_function_with_scopes_and_headers(
198            builder_fn,
199            google_api_url,
200            cloud_resource_prefix_meta,
201            GCP_DEFAULT_SCOPES.clone(),
202            headers,
203        )
204        .await
205    }
206
207    /// A client that authenticates with the Application Default Credentials, for
208    /// `token_scopes`.
209    ///
210    /// Fails with [`ErrorKind::CryptoProviderMissing`](crate::error::ErrorKind::CryptoProviderMissing)
211    /// for a service account key, used directly or as the source of an impersonation,
212    /// when the `auth-default-crypto` feature is off and no rustls crypto provider is
213    /// installed.
214    pub async fn from_function_with_scopes<S: AsRef<str>>(
215        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
216        google_api_url: S,
217        cloud_resource_prefix_meta: Option<String>,
218        token_scopes: Vec<String>,
219    ) -> crate::error::Result<Self> {
220        Self::from_function_with_scopes_and_headers(
221            builder_fn,
222            google_api_url,
223            cloud_resource_prefix_meta,
224            token_scopes,
225            hyper::HeaderMap::new(),
226        )
227        .await
228    }
229
230    pub async fn from_function_with_scopes_and_headers<S: AsRef<str>>(
231        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
232        google_api_url: S,
233        cloud_resource_prefix_meta: Option<String>,
234        token_scopes: Vec<String>,
235        headers: hyper::HeaderMap,
236    ) -> crate::error::Result<Self> {
237        // The channel comes first: with a single rustls provider compiled in, its TLS
238        // setup installs that provider, which the credentials may need to sign with.
239        let channel = GoogleEnvironment::init_google_services_channel(google_api_url).await?;
240        let mut middleware = GoogleAuthMiddlewareLayer::new(
241            GoogleAuthHeaders::from_adc_with_scopes(token_scopes).await?,
242            cloud_resource_prefix_meta,
243        )?;
244        middleware.set_additional_headers(headers);
245        Ok(Self::on_channel(
246            GoogleApiClientBuilderFunction { f: builder_fn },
247            channel,
248            middleware,
249        ))
250    }
251
252    /// A client that authenticates with `credentials`, built with
253    /// [`google_cloud_auth`](crate::google_cloud_auth): a service account key, impersonation,
254    /// workload identity federation, or a
255    /// [`CredentialsProvider`](google_cloud_auth::credentials::CredentialsProvider) of
256    /// your own.
257    ///
258    /// `credentials` built from a service account key need a rustls crypto provider when
259    /// the `auth-default-crypto` feature is off. They are opaque here, so this
260    /// constructor cannot check for one.
261    pub async fn from_function_with_credentials<S: AsRef<str>>(
262        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
263        google_api_url: S,
264        cloud_resource_prefix_meta: Option<String>,
265        credentials: Credentials,
266    ) -> crate::error::Result<Self> {
267        Self::from_function_with_credentials_and_headers(
268            builder_fn,
269            google_api_url,
270            cloud_resource_prefix_meta,
271            credentials,
272            hyper::HeaderMap::new(),
273        )
274        .await
275    }
276
277    pub async fn from_function_with_credentials_and_headers<S: AsRef<str>>(
278        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
279        google_api_url: S,
280        cloud_resource_prefix_meta: Option<String>,
281        credentials: Credentials,
282        headers: hyper::HeaderMap,
283    ) -> crate::error::Result<Self> {
284        Self::with_credentials_and_headers(
285            GoogleApiClientBuilderFunction { f: builder_fn },
286            google_api_url,
287            cloud_resource_prefix_meta,
288            credentials,
289            headers,
290        )
291        .await
292    }
293
294    pub async fn from_function_with_middleware<S: AsRef<str>>(
295        builder_fn: fn(GoogleAuthMiddlewareService<Channel>) -> C,
296        google_api_url: S,
297        middleware: GoogleAuthMiddlewareLayer,
298    ) -> crate::error::Result<Self> {
299        Self::with_middleware(
300            GoogleApiClientBuilderFunction { f: builder_fn },
301            google_api_url,
302            middleware,
303        )
304        .await
305    }
306}
307
308pub type GoogleAuthMiddleware = GoogleAuthMiddlewareService<Channel>;
309pub type GoogleApi<C> = GoogleApiClient<GoogleApiClientBuilderFunction<C>, C>;
310
311pub struct GoogleEnvironment;
312
313impl GoogleEnvironment {
314    /// The Google Cloud project of this environment: the first of the `GCP_PROJECT`,
315    /// `PROJECT_ID`, `GCP_PROJECT_ID` and `GOOGLE_CLOUD_PROJECT` environment variables
316    /// that is set, else the project the Application Default Credentials file names, else
317    /// the project of the metadata server.
318    pub async fn detect_google_project_id() -> Option<String> {
319        Self::detect_project_id_in(
320            |name| std::env::var(name).ok(),
321            AdcFile::load(),
322            MetadataServer::from_env(),
323        )
324        .await
325    }
326
327    async fn detect_project_id_in(
328        env: impl Fn(&str) -> Option<String>,
329        adc: Option<AdcFile>,
330        metadata: MetadataServer,
331    ) -> Option<String> {
332        if let Some(project_id) = PROJECT_ID_ENV_VARS.into_iter().find_map(env) {
333            debug!("Detected GCP Project ID using environment variables");
334            return Some(project_id);
335        }
336        if let Some(project_id) = adc.and_then(|adc| adc.project_id().map(ToString::to_string)) {
337            debug!("Detected GCP Project ID in the Application Default Credentials file");
338            return Some(project_id);
339        }
340        let project_id = metadata.project_id().await;
341        if project_id.is_some() {
342            debug!("Detected GCP Project ID using the metadata server");
343        } else {
344            debug!(
345                env = ?PROJECT_ID_ENV_VARS,
346                "No GCP Project ID detected in this environment; set one of the environment variables"
347            );
348        }
349        project_id
350    }
351
352    pub async fn init_google_services_channel<S: AsRef<str>>(
353        api_url: S,
354    ) -> Result<Channel, crate::error::Error> {
355        let api_url_string = api_url.as_ref().to_string();
356        let base_config = Channel::from_shared(api_url_string.clone())?
357            .connect_timeout(Duration::from_secs(30))
358            .tcp_keepalive(Some(Duration::from_secs(60)))
359            .keep_alive_timeout(Duration::from_secs(60))
360            .http2_keep_alive_interval(Duration::from_secs(60))
361            .keep_alive_while_idle(true);
362
363        let config = if let Some(domain_name) = tls_server_name(&api_url_string)? {
364            let tls_config = Self::init_tls_config(domain_name);
365            base_config.tls_config(tls_config)?
366        } else {
367            base_config
368        };
369
370        Ok(config.connect().await?)
371    }
372
373    #[cfg(not(any(feature = "tls-roots", feature = "tls-webpki-roots")))]
374    fn init_tls_config(domain_name: String) -> tonic::transport::ClientTlsConfig {
375        tonic::transport::ClientTlsConfig::new()
376            .ca_certificate(tonic::transport::Certificate::from_pem(
377                crate::apis::CERTIFICATES,
378            ))
379            .domain_name(domain_name)
380    }
381
382    #[cfg(feature = "tls-roots")]
383    fn init_tls_config(domain_name: String) -> tonic::transport::ClientTlsConfig {
384        tonic::transport::ClientTlsConfig::new()
385            .with_native_roots()
386            .domain_name(domain_name)
387    }
388
389    #[cfg(all(feature = "tls-webpki-roots", not(feature = "tls-roots")))]
390    fn init_tls_config(domain_name: String) -> tonic::transport::ClientTlsConfig {
391        tonic::transport::ClientTlsConfig::new()
392            .with_webpki_roots()
393            .domain_name(domain_name)
394    }
395}
396
397/// The TLS server name to verify for a gRPC API URL.
398///
399/// Returns the URL's host, without port or path, for an `https` URL, and `None`
400/// for a plain `http` URL, which connects without TLS. Any other scheme, a URL
401/// without a scheme or host, or an unparseable URL is an error.
402fn tls_server_name(api_url: &str) -> Result<Option<String>, crate::error::Error> {
403    let uri: hyper::http::Uri = api_url.parse()?;
404    let invalid = |reason: &str| {
405        crate::error::Error::from(crate::error::ErrorKind::InvalidApiUrl(format!(
406            "{reason}: {api_url}"
407        )))
408    };
409    match uri.scheme_str() {
410        Some("https") => {
411            let host = uri
412                .host()
413                .filter(|host| !host.is_empty())
414                .ok_or_else(|| invalid("missing host"))?;
415            let host = host
416                .strip_prefix('[')
417                .and_then(|h| h.strip_suffix(']'))
418                .unwrap_or(host);
419            Ok(Some(host.to_string()))
420        }
421        Some("http") => Ok(None),
422        Some(_) => Err(invalid("unsupported scheme")),
423        None => Err(invalid("missing scheme")),
424    }
425}
426
427/// The environment variables that name the project, in the order they are read.
428const PROJECT_ID_ENV_VARS: [&str; 4] = [
429    "GCP_PROJECT",
430    "PROJECT_ID",
431    "GCP_PROJECT_ID",
432    "GOOGLE_CLOUD_PROJECT",
433];
434
435pub static GCP_DEFAULT_SCOPES: Lazy<Vec<String>> =
436    Lazy::new(|| vec!["https://www.googleapis.com/auth/cloud-platform".into()]);
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::test_support::{silent_endpoint, StubCredentials, StubResponse, StubServer};
442    use serde_json::json;
443    use std::sync::atomic::Ordering;
444
445    #[test]
446    fn tls_server_name_is_the_host_of_a_plain_https_url() {
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_a_trailing_slash() {
455        assert_eq!(
456            tls_server_name("https://bigquery.googleapis.com/").unwrap(),
457            Some("bigquery.googleapis.com".to_string())
458        );
459    }
460
461    #[test]
462    fn tls_server_name_ignores_the_path() {
463        assert_eq!(
464            tls_server_name("https://bigquery.googleapis.com/bigquery/v2").unwrap(),
465            Some("bigquery.googleapis.com".to_string())
466        );
467    }
468
469    #[test]
470    fn tls_server_name_ignores_an_explicit_port() {
471        assert_eq!(
472            tls_server_name("https://bigquery.googleapis.com:443").unwrap(),
473            Some("bigquery.googleapis.com".to_string())
474        );
475    }
476
477    #[test]
478    fn plain_http_url_uses_no_tls() {
479        assert_eq!(tls_server_name("http://localhost:8080").unwrap(), None);
480        assert_eq!(tls_server_name("http://localhost:8080/").unwrap(), None);
481    }
482
483    #[test]
484    fn url_without_scheme_or_host_is_an_error() {
485        assert!(tls_server_name("bigquery.googleapis.com").is_err());
486        assert!(tls_server_name("/bigquery/v2").is_err());
487        assert!(tls_server_name("https://").is_err());
488        assert!(tls_server_name("not a url").is_err());
489        assert!(tls_server_name("").is_err());
490    }
491    // Neither address is dialed: the middleware fetches the token before handing the
492    // request to the channel, and a closed loopback port fails the connection quickly
493    // without ever reaching the network.
494    async fn probe(service: &mut GoogleAuthMiddlewareService<Channel>) {
495        let req = hyper::Request::builder()
496            .uri("http://127.0.0.1:1/")
497            .body(tonic::body::Body::empty())
498            .unwrap();
499        let ready = tower::ServiceExt::ready(service).await.unwrap();
500        let _ = tower::Service::call(ready, req).await;
501    }
502
503    #[tokio::test]
504    async fn get_with_shares_the_authenticated_channel() {
505        let credentials = StubCredentials::bearer("counted-token");
506        let middleware = GoogleAuthMiddlewareLayer::new(
507            GoogleAuthHeaders::from(Credentials::from(credentials.clone())),
508            None,
509        )
510        .unwrap();
511        let channel = Channel::from_static("http://127.0.0.1:1").connect_lazy();
512        let service: GoogleAuthMiddlewareService<Channel> =
513            ServiceBuilder::new().layer(middleware).service(channel);
514
515        let client: GoogleApiClient<
516            GoogleApiClientBuilderFunction<GoogleAuthMiddlewareService<Channel>>,
517            GoogleAuthMiddlewareService<Channel>,
518        > = GoogleApiClient {
519            builder: GoogleApiClientBuilderFunction { f: |svc| svc },
520            service,
521            _ph: PhantomData,
522        };
523
524        let mut from_get = client.get();
525        let mut from_get_with = client.get_with(|svc| svc);
526
527        probe(&mut from_get).await;
528        probe(&mut from_get_with).await;
529
530        // A client built with `get_with` reuses the same `Arc<GoogleAuthHeaders>`
531        // as one built with `get`, so its header cache is shared: the headers are served
532        // in full once, not once per client.
533        assert_eq!(credentials.served(), 1);
534    }
535
536    async fn send_stalled(mut service: GoogleAuthMiddlewareService<Channel>) {
537        let _ = tokio::time::timeout(Duration::from_millis(200), probe(&mut service)).await;
538    }
539
540    #[tokio::test]
541    async fn connect_with_endpoint_shares_auth_with_a_client_on_another_host() {
542        let credentials = StubCredentials::bearer("counted-token");
543        let (first_url, first_connections) = silent_endpoint().await;
544        let (second_url, second_connections) = silent_endpoint().await;
545
546        let first: GoogleApi<GoogleAuthMiddlewareService<Channel>> =
547            GoogleApi::from_function_with_credentials(
548                |svc| svc,
549                first_url,
550                None,
551                Credentials::from(credentials.clone()),
552            )
553            .await
554            .unwrap();
555        let second = first
556            .connect_with_endpoint(|svc| svc, second_url, None)
557            .await
558            .unwrap();
559
560        send_stalled(first.get()).await;
561        send_stalled(second.get()).await;
562
563        assert_eq!(first_connections.load(Ordering::SeqCst), 1);
564        assert_eq!(second_connections.load(Ordering::SeqCst), 1);
565        assert_eq!(credentials.served(), 1);
566    }
567
568    async fn detected_project_id(
569        env: &[(&str, &str)],
570        adc: Option<serde_json::Value>,
571        metadata: &StubServer,
572    ) -> Option<String> {
573        GoogleEnvironment::detect_project_id_in(
574            |name| {
575                env.iter()
576                    .find(|(set, _)| *set == name)
577                    .map(|(_, value)| value.to_string())
578            },
579            adc.map(AdcFile::from),
580            MetadataServer::at(metadata.url.clone()),
581        )
582        .await
583    }
584
585    #[tokio::test]
586    async fn project_id_sources_are_read_in_order() {
587        let metadata = StubServer::start(vec![StubResponse::text("200 OK", "metadata-project")
588            .with_header("Metadata-Flavor", "Google")])
589        .await;
590        let mut env = vec![
591            ("GCP_PROJECT", "gcp-project"),
592            ("PROJECT_ID", "project-id"),
593            ("GCP_PROJECT_ID", "gcp-project-id"),
594            ("GOOGLE_CLOUD_PROJECT", "google-cloud-project"),
595        ];
596        let mut adc = json!({
597            "type": "impersonated_service_account",
598            "project_id": "adc-project",
599            "quota_project_id": "adc-quota-project",
600            "source_credentials": {
601                "type": "service_account",
602                "project_id": "source-project",
603                "quota_project_id": "source-quota-project",
604            },
605        });
606
607        let mut detected = Vec::new();
608        while !env.is_empty() {
609            detected.push(detected_project_id(&env, Some(adc.clone()), &metadata).await);
610            env.remove(0);
611        }
612        for field in [
613            "/project_id",
614            "/quota_project_id",
615            "/source_credentials/project_id",
616        ] {
617            detected.push(detected_project_id(&env, Some(adc.clone()), &metadata).await);
618            let (parent, name) = field.rsplit_once('/').unwrap();
619            adc.pointer_mut(parent)
620                .and_then(serde_json::Value::as_object_mut)
621                .unwrap()
622                .remove(name);
623        }
624        detected.push(detected_project_id(&env, Some(adc), &metadata).await);
625        detected.push(detected_project_id(&env, None, &metadata).await);
626
627        assert_eq!(
628            detected,
629            [
630                "gcp-project",
631                "project-id",
632                "gcp-project-id",
633                "google-cloud-project",
634                "adc-project",
635                "adc-quota-project",
636                "source-project",
637                "source-quota-project",
638                "metadata-project",
639            ]
640            .map(|project| Some(project.to_string()))
641        );
642    }
643}