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}