Skip to main content

omni_dev/drive/
api_client.rs

1//! Shared transport core for Google REST APIs.
2//!
3//! Extracted from `crate::drive::client` (issue #1589) so a second Google
4//! host — the Sheets API at `sheets.googleapis.com`, which Drive's
5//! `/drive/v3/...`-on-`www.googleapis.com` base URL cannot reach — can reuse
6//! the OAuth session, the 401 refresh-and-retry, the quota/429 backoff, the
7//! request-log hook and the error-envelope parsing without duplicating any
8//! of it.
9//!
10//! This module owns **transport only**. It has no notion of files, ranges or
11//! any API's resource model; `DriveClient` and `SheetsClient` are thin typed
12//! wrappers that add a default host, an env override and their own façades.
13//! Keeping them as *distinct types* is deliberate: `FilesApi::new` takes a
14//! `&DriveClient`, so a Sheets-hosted client cannot be handed to it and
15//! silently issue `/drive/v3/files` against `sheets.googleapis.com`.
16//!
17//! Gmail (`crate::gmail::client::GmailClient`) is a near-verbatim twin of the
18//! pre-extraction `DriveClient` and is deliberately **not** migrated here:
19//! it carries its own `GmailSession`, so sharing would need a `TokenSource`
20//! abstraction that nothing in issue #1589 requires.
21
22use std::sync::Arc;
23
24use anyhow::{Context, Result};
25use reqwest::{Client, Response};
26use url::Url;
27
28use crate::drive::auth::{DriveCredentials, DriveSession};
29use crate::drive::error::DriveError;
30use crate::request_log;
31use crate::utils::http::{connect_timeout, read_timeout, retry_if};
32
33/// Authenticated HTTP transport for one Google API host.
34pub(crate) struct GoogleApiClient {
35    client: Client,
36    base_url: String,
37    /// Shared so a `SheetsClient` derived from a `DriveClient` reuses the
38    /// same OAuth session: a `sheets write` needs Drive (`files.get` plus the
39    /// ancestor-chain walk) *and* Sheets calls, and an unshared session would
40    /// refresh the same refresh token twice per invocation.
41    session: Arc<DriveSession>,
42    /// The `service` tag every HTTP record carries
43    /// ([`request_log::record_http_result`]). Deliberately the same for
44    /// Drive and Sheets — see `crate::drive::client::SERVICE_TAG`.
45    service: &'static str,
46    /// Human-readable API name for error messages (`"Drive"`/`"Sheets"`).
47    /// Distinct from [`Self::service`] precisely because that one is shared.
48    api_name: &'static str,
49}
50
51impl std::fmt::Debug for GoogleApiClient {
52    // Hand-written, not derived: omits `session` entirely rather than relying
53    // on every nested `Secret` staying wrapped — the safest possible
54    // redaction is "not mentioned at all."
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("GoogleApiClient")
57            .field("base_url", &self.base_url)
58            .field("service", &self.service)
59            .field("api_name", &self.api_name)
60            .finish_non_exhaustive()
61    }
62}
63
64impl GoogleApiClient {
65    /// Builds a transport against `base_url` with already-loaded credentials.
66    pub(crate) fn new(
67        base_url: &str,
68        credentials: &DriveCredentials,
69        service: &'static str,
70        api_name: &'static str,
71    ) -> Result<Self> {
72        let client = Client::builder()
73            .connect_timeout(connect_timeout())
74            .read_timeout(read_timeout())
75            .build()
76            .context("Failed to build HTTP client")?;
77        let session = Arc::new(DriveSession::new(client.clone(), credentials));
78        Ok(Self::from_parts(
79            client, base_url, session, service, api_name,
80        ))
81    }
82
83    /// Builds a transport reusing an existing client and OAuth session,
84    /// against a different host.
85    ///
86    /// This is how a `SheetsClient` is derived from a `DriveClient`: the
87    /// `reqwest::Client` (and its connection pool) and the `Arc<DriveSession>`
88    /// are shared; only the base URL differs.
89    ///
90    /// `dead_code`-allowed because its only consumer, `SheetsClient`, lands in
91    /// the following commit; the extraction is what makes that possible, so
92    /// the seam ships with the extraction rather than trailing it.
93    #[allow(dead_code)]
94    pub(crate) fn derived(
95        &self,
96        base_url: &str,
97        service: &'static str,
98        api_name: &'static str,
99    ) -> Self {
100        Self::from_parts(
101            self.client.clone(),
102            base_url,
103            Arc::clone(&self.session),
104            service,
105            api_name,
106        )
107    }
108
109    fn from_parts(
110        client: Client,
111        base_url: &str,
112        session: Arc<DriveSession>,
113        service: &'static str,
114        api_name: &'static str,
115    ) -> Self {
116        Self {
117            client,
118            base_url: base_url.trim_end_matches('/').to_string(),
119            session,
120            service,
121            api_name,
122        }
123    }
124
125    /// Returns the API base URL (without trailing slash).
126    pub(crate) fn base_url(&self) -> &str {
127        &self.base_url
128    }
129
130    /// Builds an absolute API URL by joining `path` onto `base_url`.
131    ///
132    /// Takes `base_url` (rather than `&self`) so the free `build_*_url`
133    /// functions in the API façade modules — and their unit tests, which pass
134    /// literal base URLs — can call it without an instance.
135    ///
136    /// `path` is interpolated verbatim, so a caller embedding anything but a
137    /// Drive-style opaque id (`[A-Za-z0-9_-]`) **must** percent-encode it
138    /// first: a Sheets A1 range can contain `#`, `?`, `/` and spaces, any of
139    /// which would silently reshape the URL.
140    pub(crate) fn api_url(base_url: &str, path: &str) -> Result<Url> {
141        Url::parse(&format!("{base_url}{path}")).context("Invalid Drive base URL")
142    }
143
144    /// Appends `segments` to `url`'s path, percent-encoding each one.
145    ///
146    /// **This is the safe counterpart to [`Self::api_url`]**, and lives
147    /// beside it because that method's doc comment describes exactly the
148    /// hazard this one exists to remove. A Sheets A1 range goes in the URL
149    /// *path* and is caller-influenced text that routinely contains
150    /// characters with URL meaning: a sheet titled with a `#` truncates the
151    /// path into a fragment, a `?` starts a query string, a `/` invents a
152    /// path segment, and a space is simply invalid. Every one of those
153    /// silently reads or writes the wrong cells rather than erroring.
154    ///
155    /// `files_api.rs`'s `format!("/drive/v3/files/{{file_id}}")` sites are
156    /// safe only because Drive ids are `[A-Za-z0-9_-]`; do not generalise
157    /// from them. New façades should reach for this even when their own ids
158    /// happen to be opaque, so no second precedent for `format!` exists.
159    pub(crate) fn push_path_segments(url: &mut Url, segments: &[&str]) -> Result<()> {
160        let mut path = url
161            .path_segments_mut()
162            .map_err(|()| anyhow::anyhow!("Invalid API base URL: cannot be a base"))?;
163        for segment in segments {
164            path.push(segment);
165        }
166        Ok(())
167    }
168
169    /// Checks `response` for success and deserialises its JSON body into `T`.
170    pub(crate) async fn parse_response<T: serde::de::DeserializeOwned>(
171        &self,
172        response: Response,
173        context: &'static str,
174    ) -> Result<T> {
175        if !response.status().is_success() {
176            return Err(Self::response_to_error(self.api_name, response)
177                .await
178                .into());
179        }
180        response.json().await.context(context)
181    }
182
183    /// Sends an authenticated GET and deserialises the JSON body into `T`.
184    pub(crate) async fn get_parsed<T: serde::de::DeserializeOwned>(
185        &self,
186        url: &str,
187        context: &'static str,
188    ) -> Result<T> {
189        let response = self.get_json(url).await?;
190        self.parse_response(response, context).await
191    }
192
193    /// Sends an authenticated GET request and returns the raw response.
194    ///
195    /// Retries exactly once on HTTP 401 by forcing a session refresh — see
196    /// [`Self::send_authorized`] for why both a proactive and a reactive
197    /// refresh path exist.
198    pub(crate) async fn get_json(&self, url: &str) -> Result<Response> {
199        self.send_authorized(url, "GET", |client, token| {
200            client
201                .get(url)
202                .bearer_auth(token)
203                .header("Accept", "application/json")
204        })
205        .await
206    }
207
208    /// Sends an authenticated GET request without forcing an `Accept:
209    /// application/json` header, for endpoints that return raw bytes rather
210    /// than JSON (`files.export`, `files.get?alt=media`).
211    pub(crate) async fn get_bytes(&self, url: &str) -> Result<Response> {
212        self.send_authorized(url, "GET", |client, token| {
213            client.get(url).bearer_auth(token)
214        })
215        .await
216    }
217
218    /// Sends an authenticated POST request with a JSON body.
219    pub(crate) async fn post_json<T: serde::Serialize + Sync + ?Sized>(
220        &self,
221        url: &str,
222        body: &T,
223    ) -> Result<Response> {
224        self.send_authorized(url, "POST", |client, token| {
225            client
226                .post(url)
227                .bearer_auth(token)
228                .header("Content-Type", "application/json")
229                .json(body)
230        })
231        .await
232    }
233
234    /// Sends an authenticated PUT request with a JSON body.
235    ///
236    /// Added for Sheets' `spreadsheets.values.update` (issue #1589), the
237    /// first PUT endpoint this transport calls — Drive v3 uses PATCH for
238    /// `files.update`. `dead_code`-allowed until that caller lands; exercised
239    /// by `client.rs`'s `put_json_sends_body_and_bearer_auth` meanwhile.
240    #[allow(dead_code)]
241    pub(crate) async fn put_json<T: serde::Serialize + Sync + ?Sized>(
242        &self,
243        url: &str,
244        body: &T,
245    ) -> Result<Response> {
246        self.send_authorized(url, "PUT", |client, token| {
247            client
248                .put(url)
249                .bearer_auth(token)
250                .header("Content-Type", "application/json")
251                .json(body)
252        })
253        .await
254    }
255
256    /// Sends an authenticated PATCH request with a JSON body.
257    pub(crate) async fn patch_json<T: serde::Serialize + Sync + ?Sized>(
258        &self,
259        url: &str,
260        body: &T,
261    ) -> Result<Response> {
262        self.send_authorized(url, "PATCH", |client, token| {
263            client
264                .patch(url)
265                .bearer_auth(token)
266                .header("Content-Type", "application/json")
267                .json(body)
268        })
269        .await
270    }
271
272    /// Sends an authenticated POST request with a raw byte body — for
273    /// Drive's multipart-upload endpoint, whose `multipart/related` body
274    /// [`crate::drive::files_api::FilesApi::upload`] hand-assembles.
275    ///
276    /// `body` is cloned per send attempt ([`Self::send_authorized`]'s `build`
277    /// closure is `Fn`, not `FnOnce` — it may run twice, once on a 401 retry
278    /// — and [`reqwest::RequestBuilder::body`] takes ownership).
279    pub(crate) async fn post_bytes(
280        &self,
281        url: &str,
282        body: &[u8],
283        content_type: &str,
284    ) -> Result<Response> {
285        self.send_authorized(url, "POST", |client, token| {
286            client
287                .post(url)
288                .bearer_auth(token)
289                .header("Content-Type", content_type)
290                .body(body.to_vec())
291        })
292        .await
293    }
294
295    /// Sends an authenticated PATCH request with a raw byte body —
296    /// [`crate::drive::files_api::FilesApi::edit_content`]'s simple
297    /// media-only content replacement.
298    pub(crate) async fn patch_bytes(
299        &self,
300        url: &str,
301        body: &[u8],
302        content_type: &str,
303    ) -> Result<Response> {
304        self.send_authorized(url, "PATCH", |client, token| {
305            client
306                .patch(url)
307                .bearer_auth(token)
308                .header("Content-Type", content_type)
309                .body(body.to_vec())
310        })
311        .await
312    }
313
314    /// Sends a request built by `build`, retrying exactly once on HTTP 401.
315    ///
316    /// [`DriveSession::access_token`] already refreshes proactively when the
317    /// tracked expiry is near — this reactive path exists for what proactive
318    /// tracking can't see: clock skew against Google's clock, or the token
319    /// being invalidated server-side mid-run (revoked access). A second 401
320    /// after the retry is authoritative: either the refresh produced a token
321    /// that was also rejected, or another caller's already-current token was
322    /// reused and still rejected — either way the problem isn't staleness, so
323    /// it surfaces as an ordinary `ApiRequestFailed` rather than retrying
324    /// again.
325    async fn send_authorized<F>(
326        &self,
327        url: &str,
328        method: &'static str,
329        build: F,
330    ) -> Result<Response>
331    where
332        F: Fn(&Client, &str) -> reqwest::RequestBuilder + Send + Sync,
333    {
334        let token = self
335            .session
336            .access_token()
337            .await
338            .context("Failed to obtain a Drive access token")?;
339        let response = self
340            .send_once(url, method, &build, token.expose_secret())
341            .await?;
342        if response.status().as_u16() != 401 {
343            return Ok(response);
344        }
345        let refreshed = self
346            .session
347            .force_refresh(&token)
348            .await
349            .context("Failed to refresh the Drive access token after a 401")?;
350        self.send_once(url, method, &build, refreshed.expose_secret())
351            .await
352    }
353
354    async fn send_once<F>(
355        &self,
356        url: &str,
357        method: &'static str,
358        build: &F,
359        token: &str,
360    ) -> Result<Response>
361    where
362        F: Fn(&Client, &str) -> reqwest::RequestBuilder + Send + Sync,
363    {
364        retry_if(
365            || build(&self.client, token),
366            |started, result| {
367                request_log::record_http_result(self.service, method, url, started, result);
368            },
369            |status, body| status == 429 || is_quota_exceeded(status, body),
370            None,
371        )
372        .await
373        .with_context(|| format!("Failed to send {method} request to Drive API"))
374    }
375
376    /// Consumes a non-success response into a [`DriveError`].
377    ///
378    /// Parses Google's error envelope into a human message when present
379    /// (falls back to the raw body otherwise). See [`error_reason`] for why
380    /// two envelope shapes have to be understood.
381    pub(crate) async fn response_to_error(api: &'static str, response: Response) -> DriveError {
382        let status = response.status().as_u16();
383        let raw = response.text().await.unwrap_or_default();
384        let value = serde_json::from_str::<serde_json::Value>(&raw).ok();
385        let reason = value.as_ref().and_then(error_reason);
386        let body = value
387            .as_ref()
388            .and_then(error_message)
389            .map(|message| match &reason {
390                Some(r) => format!("{message} (reason: {r})"),
391                None => message,
392            })
393            .unwrap_or(raw);
394        DriveError::ApiRequestFailed {
395            api,
396            status,
397            body,
398            reason,
399        }
400    }
401}
402
403/// Extracts a machine-readable reason code from an already-parsed Google
404/// JSON error envelope, understanding **both** shapes Google ships.
405///
406/// Drive v3 and the other older APIs return the legacy envelope, whose code
407/// lives at `error.errors[0].reason` (`"insufficientPermissions"`,
408/// `"userRateLimitExceeded"`, ...). Newer services — Sheets v4 among them —
409/// return the `google.rpc` envelope instead:
410///
411/// ```json
412/// {"error":{"code":403,"message":"...","status":"PERMISSION_DENIED"}}
413/// ```
414///
415/// which has **no `errors[]` array at all**. Reading only the legacy shape
416/// yields `reason: None` for every Sheets failure, which silently disables
417/// both [`crate::drive::files_api::append_write_scope_hint`] (it matches on
418/// the reason string) and [`is_quota_exceeded`] — neither fails loudly, they
419/// just never fire. Hence: legacy first, then `error.status`.
420fn error_reason(value: &serde_json::Value) -> Option<String> {
421    let error = value.get("error")?;
422    let legacy = error
423        .get("errors")
424        .and_then(|e| e.as_array())
425        .and_then(|a| a.first())
426        .and_then(|e| e.get("reason"))
427        .and_then(|r| r.as_str())
428        .map(str::to_string);
429    legacy.or_else(|| error.get("status")?.as_str().map(str::to_string))
430}
431
432/// Extracts the `error.message` field from an already-parsed Google JSON
433/// error envelope, if present. Both envelope shapes carry it identically.
434fn error_message(value: &serde_json::Value) -> Option<String> {
435    value
436        .get("error")?
437        .get("message")?
438        .as_str()
439        .map(str::to_string)
440}
441
442/// Whether a response is Google's quota-exhaustion signal — a **403**
443/// carrying the legacy `userRateLimitExceeded` reason or the `google.rpc`
444/// `RESOURCE_EXHAUSTED` status, not any 403 with a reason field:
445/// `insufficientPermissions` / `PERMISSION_DENIED` are also 403s and must
446/// never be retried (retrying a scope error just wastes the backoff window
447/// before failing anyway).
448///
449/// Unlike Gmail's `is_gmail_quota_exceeded`, this does **not** also match the
450/// bare `rateLimitExceeded` reason — that string is confirmed for Gmail but
451/// not for Drive against
452/// [Drive's error-handling guide](https://developers.google.com/workspace/drive/api/guides/handle-errors);
453/// widen this match only once testing surfaces it. Plain `429` responses are
454/// already covered by the literal `status == 429` branch in
455/// [`GoogleApiClient::send_once`], independent of this function.
456fn is_quota_exceeded(status: u16, body: &[u8]) -> bool {
457    if status != 403 {
458        return false;
459    }
460    let Ok(text) = std::str::from_utf8(body) else {
461        return false;
462    };
463    let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
464        return false;
465    };
466    matches!(
467        error_reason(&value).as_deref(),
468        Some("userRateLimitExceeded" | "RESOURCE_EXHAUSTED")
469    )
470}
471
472/// Test-only seam letting sibling modules bootstrap a deterministic access
473/// token against a wiremock `/token` endpoint.
474///
475/// A **constructor**, not a post-hoc field assignment: with the session held
476/// behind an `Arc` and shared into derived clients, replacing it after a
477/// derivation would leave the derived client pointed at the real
478/// `oauth2.googleapis.com` — a live network call from a unit test.
479#[cfg(test)]
480impl GoogleApiClient {
481    pub(crate) fn new_with_token_endpoint(
482        base_url: &str,
483        credentials: &DriveCredentials,
484        service: &'static str,
485        api_name: &'static str,
486        token_endpoint: &str,
487    ) -> Result<Self> {
488        let client = Client::builder()
489            .connect_timeout(connect_timeout())
490            .read_timeout(read_timeout())
491            .build()
492            .context("Failed to build HTTP client")?;
493        let session = Arc::new(DriveSession::new_with_token_endpoint(
494            client.clone(),
495            credentials,
496            token_endpoint,
497        ));
498        Ok(Self::from_parts(
499            client, base_url, session, service, api_name,
500        ))
501    }
502}
503
504#[cfg(test)]
505#[allow(clippy::unwrap_used, clippy::expect_used)]
506mod tests {
507    use super::*;
508    use crate::drive::auth::DriveGrantedScopes;
509    use crate::utils::secret::Secret;
510
511    fn test_credentials() -> DriveCredentials {
512        DriveCredentials {
513            client_id: "client-1".to_string(),
514            client_secret: Secret::new("secret-1"),
515            refresh_token: Secret::new("refresh-1"),
516            scope: DriveGrantedScopes::READONLY,
517        }
518    }
519
520    // ── error_reason: both Google envelope shapes ──────────────────────
521
522    #[test]
523    fn error_reason_reads_the_legacy_drive_envelope() {
524        let value = serde_json::json!({
525            "error": {"errors": [{"reason": "insufficientPermissions"}], "message": "nope"},
526        });
527        assert_eq!(
528            error_reason(&value).as_deref(),
529            Some("insufficientPermissions")
530        );
531    }
532
533    #[test]
534    fn error_reason_reads_the_google_rpc_status_when_there_is_no_errors_array() {
535        // The shape Sheets v4 actually returns. Before issue #1589 this
536        // yielded None, silently disabling the write-scope hint and the
537        // quota retry for every Sheets call.
538        let value = serde_json::json!({
539            "error": {"code": 403, "message": "The caller does not have permission",
540                      "status": "PERMISSION_DENIED"},
541        });
542        assert_eq!(error_reason(&value).as_deref(), Some("PERMISSION_DENIED"));
543    }
544
545    #[test]
546    fn error_reason_prefers_the_legacy_reason_when_both_are_present() {
547        let value = serde_json::json!({
548            "error": {"errors": [{"reason": "insufficientPermissions"}],
549                      "status": "PERMISSION_DENIED"},
550        });
551        assert_eq!(
552            error_reason(&value).as_deref(),
553            Some("insufficientPermissions")
554        );
555    }
556
557    #[test]
558    fn error_reason_none_when_neither_shape_is_present() {
559        assert_eq!(error_reason(&serde_json::json!({"error": {}})), None);
560        assert_eq!(error_reason(&serde_json::json!({"nope": 1})), None);
561    }
562
563    // ── is_quota_exceeded ──────────────────────────────────────────────
564
565    #[test]
566    fn is_quota_exceeded_false_on_non_utf8_body() {
567        assert!(!is_quota_exceeded(403, &[0xff, 0xfe]));
568    }
569
570    #[test]
571    fn is_quota_exceeded_false_on_non_403_status() {
572        assert!(!is_quota_exceeded(
573            429,
574            br#"{"error":{"errors":[{"reason":"userRateLimitExceeded"}]}}"#
575        ));
576    }
577
578    #[test]
579    fn is_quota_exceeded_true_on_matching_403_reason() {
580        assert!(is_quota_exceeded(
581            403,
582            br#"{"error":{"errors":[{"reason":"userRateLimitExceeded"}]}}"#
583        ));
584    }
585
586    #[test]
587    fn is_quota_exceeded_true_on_google_rpc_resource_exhausted() {
588        assert!(is_quota_exceeded(
589            403,
590            br#"{"error":{"code":403,"status":"RESOURCE_EXHAUSTED"}}"#
591        ));
592    }
593
594    #[test]
595    fn is_quota_exceeded_false_on_permission_denied() {
596        // The whole point of not matching every 403 with a reason: a scope
597        // error must fail immediately, not burn the backoff window first.
598        assert!(!is_quota_exceeded(
599            403,
600            br#"{"error":{"code":403,"status":"PERMISSION_DENIED"}}"#
601        ));
602        assert!(!is_quota_exceeded(
603            403,
604            br#"{"error":{"errors":[{"reason":"insufficientPermissions"}]}}"#
605        ));
606    }
607
608    // ── construction ───────────────────────────────────────────────────
609
610    #[test]
611    fn new_strips_trailing_slash() {
612        let client = GoogleApiClient::new(
613            "https://example.test/",
614            &test_credentials(),
615            "drive",
616            "Drive",
617        )
618        .unwrap();
619        assert_eq!(client.base_url(), "https://example.test");
620    }
621
622    #[test]
623    fn derived_shares_the_session_and_swaps_the_host() {
624        let drive = GoogleApiClient::new(
625            "https://www.googleapis.com",
626            &test_credentials(),
627            "drive",
628            "Drive",
629        )
630        .unwrap();
631        let sheets = drive.derived("https://sheets.googleapis.com", "drive", "Sheets");
632        assert_eq!(sheets.base_url(), "https://sheets.googleapis.com");
633        assert_eq!(drive.base_url(), "https://www.googleapis.com");
634        assert!(
635            Arc::ptr_eq(&drive.session, &sheets.session),
636            "a derived client must reuse the OAuth session, not open a second one"
637        );
638    }
639
640    #[test]
641    fn debug_never_mentions_the_session_field() {
642        let client = GoogleApiClient::new(
643            "https://example.test",
644            &test_credentials(),
645            "drive",
646            "Drive",
647        )
648        .unwrap();
649        let debug = format!("{client:?}");
650        assert!(!debug.contains("secret-1"));
651        assert!(!debug.contains("refresh-1"));
652        assert!(!debug.contains("session"));
653    }
654}