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 let response = self.error_unless_success(response).await?;
176 response.json().await.context(context)
177 }
178
179 /// Passes a 2xx `response` through and turns any other status into the
180 /// error [`Self::response_to_error`] builds — the one place both
181 /// `parse_response` and `parse_success_response` decide "the server
182 /// refused".
183 async fn error_unless_success(&self, response: Response) -> Result<Response> {
184 if response.status().is_success() {
185 Ok(response)
186 } else {
187 Err(Self::response_to_error(self.api_name, response)
188 .await
189 .into())
190 }
191 }
192
193 /// Like [`Self::parse_response`], but tells "the server refused" apart from
194 /// "the server said 2xx, yet the body could not be read".
195 ///
196 /// The outer `Err` is a non-2xx (`response_to_error`'s message). On a 2xx the
197 /// inner `Result` is the body: `Err(detail)` means the request *succeeded*
198 /// but its reply failed to read or deserialise. For a mutating call that
199 /// distinction is load-bearing, because reporting an applied mutation as a
200 /// failure invites a retry that duplicates it (issue #2021).
201 pub(crate) async fn parse_success_response<T: serde::de::DeserializeOwned>(
202 &self,
203 response: Response,
204 context: &'static str,
205 ) -> Result<std::result::Result<T, String>> {
206 let response = self.error_unless_success(response).await?;
207 let bytes = match response.bytes().await {
208 Ok(bytes) => bytes,
209 Err(err) => return Ok(Err(format!("{context}: {err}"))),
210 };
211 Ok(serde_json::from_slice(&bytes).map_err(|err| format!("{context}: {err}")))
212 }
213
214 /// Sends an authenticated GET and deserialises the JSON body into `T`.
215 pub(crate) async fn get_parsed<T: serde::de::DeserializeOwned>(
216 &self,
217 url: &str,
218 context: &'static str,
219 ) -> Result<T> {
220 let response = self.get_json(url).await?;
221 self.parse_response(response, context).await
222 }
223
224 /// Sends an authenticated GET request and returns the raw response.
225 ///
226 /// Retries exactly once on HTTP 401 by forcing a session refresh — see
227 /// [`Self::send_authorized`] for why both a proactive and a reactive
228 /// refresh path exist.
229 pub(crate) async fn get_json(&self, url: &str) -> Result<Response> {
230 self.send_authorized(url, "GET", |client, token| {
231 client
232 .get(url)
233 .bearer_auth(token)
234 .header("Accept", "application/json")
235 })
236 .await
237 }
238
239 /// Sends an authenticated GET request without forcing an `Accept:
240 /// application/json` header, for endpoints that return raw bytes rather
241 /// than JSON (`files.export`, `files.get?alt=media`).
242 pub(crate) async fn get_bytes(&self, url: &str) -> Result<Response> {
243 self.send_authorized(url, "GET", |client, token| {
244 client.get(url).bearer_auth(token)
245 })
246 .await
247 }
248
249 /// Sends an authenticated POST request with a JSON body.
250 pub(crate) async fn post_json<T: serde::Serialize + Sync + ?Sized>(
251 &self,
252 url: &str,
253 body: &T,
254 ) -> Result<Response> {
255 self.send_authorized(url, "POST", |client, token| {
256 client
257 .post(url)
258 .bearer_auth(token)
259 .header("Content-Type", "application/json")
260 .json(body)
261 })
262 .await
263 }
264
265 /// Sends an authenticated PUT request with a JSON body.
266 ///
267 /// Added for Sheets' `spreadsheets.values.update` (issue #1589), the
268 /// first PUT endpoint this transport calls — Drive v3 uses PATCH for
269 /// `files.update`. `dead_code`-allowed until that caller lands; exercised
270 /// by `client.rs`'s `put_json_sends_body_and_bearer_auth` meanwhile.
271 #[allow(dead_code)]
272 pub(crate) async fn put_json<T: serde::Serialize + Sync + ?Sized>(
273 &self,
274 url: &str,
275 body: &T,
276 ) -> Result<Response> {
277 self.send_authorized(url, "PUT", |client, token| {
278 client
279 .put(url)
280 .bearer_auth(token)
281 .header("Content-Type", "application/json")
282 .json(body)
283 })
284 .await
285 }
286
287 /// Sends an authenticated PATCH request with a JSON body.
288 pub(crate) async fn patch_json<T: serde::Serialize + Sync + ?Sized>(
289 &self,
290 url: &str,
291 body: &T,
292 ) -> Result<Response> {
293 self.send_authorized(url, "PATCH", |client, token| {
294 client
295 .patch(url)
296 .bearer_auth(token)
297 .header("Content-Type", "application/json")
298 .json(body)
299 })
300 .await
301 }
302
303 /// Sends an authenticated POST request with a raw byte body — for
304 /// Drive's multipart-upload endpoint, whose `multipart/related` body
305 /// [`crate::drive::files_api::FilesApi::upload`] hand-assembles.
306 ///
307 /// `body` is cloned per send attempt ([`Self::send_authorized`]'s `build`
308 /// closure is `Fn`, not `FnOnce` — it may run twice, once on a 401 retry
309 /// — and [`reqwest::RequestBuilder::body`] takes ownership).
310 pub(crate) async fn post_bytes(
311 &self,
312 url: &str,
313 body: &[u8],
314 content_type: &str,
315 ) -> Result<Response> {
316 self.send_authorized(url, "POST", |client, token| {
317 client
318 .post(url)
319 .bearer_auth(token)
320 .header("Content-Type", content_type)
321 .body(body.to_vec())
322 })
323 .await
324 }
325
326 /// Sends an authenticated PATCH request with a raw byte body —
327 /// [`crate::drive::files_api::FilesApi::edit_content`]'s simple
328 /// media-only content replacement.
329 pub(crate) async fn patch_bytes(
330 &self,
331 url: &str,
332 body: &[u8],
333 content_type: &str,
334 ) -> Result<Response> {
335 self.send_authorized(url, "PATCH", |client, token| {
336 client
337 .patch(url)
338 .bearer_auth(token)
339 .header("Content-Type", content_type)
340 .body(body.to_vec())
341 })
342 .await
343 }
344
345 /// Sends a request built by `build`, retrying exactly once on HTTP 401.
346 ///
347 /// [`DriveSession::access_token`] already refreshes proactively when the
348 /// tracked expiry is near — this reactive path exists for what proactive
349 /// tracking can't see: clock skew against Google's clock, or the token
350 /// being invalidated server-side mid-run (revoked access). A second 401
351 /// after the retry is authoritative: either the refresh produced a token
352 /// that was also rejected, or another caller's already-current token was
353 /// reused and still rejected — either way the problem isn't staleness, so
354 /// it surfaces as an ordinary `ApiRequestFailed` rather than retrying
355 /// again.
356 async fn send_authorized<F>(
357 &self,
358 url: &str,
359 method: &'static str,
360 build: F,
361 ) -> Result<Response>
362 where
363 F: Fn(&Client, &str) -> reqwest::RequestBuilder + Send + Sync,
364 {
365 let token = self
366 .session
367 .access_token()
368 .await
369 .context("Failed to obtain a Drive access token")?;
370 let response = self
371 .send_once(url, method, &build, token.expose_secret())
372 .await?;
373 if response.status().as_u16() != 401 {
374 return Ok(response);
375 }
376 let refreshed = self
377 .session
378 .force_refresh(&token)
379 .await
380 .context("Failed to refresh the Drive access token after a 401")?;
381 self.send_once(url, method, &build, refreshed.expose_secret())
382 .await
383 }
384
385 async fn send_once<F>(
386 &self,
387 url: &str,
388 method: &'static str,
389 build: &F,
390 token: &str,
391 ) -> Result<Response>
392 where
393 F: Fn(&Client, &str) -> reqwest::RequestBuilder + Send + Sync,
394 {
395 retry_if(
396 || build(&self.client, token),
397 |started, result| {
398 request_log::record_http_result(self.service, method, url, started, result);
399 },
400 |status, body| status == 429 || is_quota_exceeded(status, body),
401 None,
402 )
403 .await
404 .with_context(|| format!("Failed to send {method} request to Drive API"))
405 }
406
407 /// Consumes a non-success response into a [`DriveError`].
408 ///
409 /// Parses Google's error envelope into a human message when present
410 /// (falls back to the raw body otherwise). See [`error_reason`] for why
411 /// two envelope shapes have to be understood.
412 pub(crate) async fn response_to_error(api: &'static str, response: Response) -> DriveError {
413 let status = response.status().as_u16();
414 let raw = response.text().await.unwrap_or_default();
415 let value = serde_json::from_str::<serde_json::Value>(&raw).ok();
416 let reason = value.as_ref().and_then(error_reason);
417 let body = value
418 .as_ref()
419 .and_then(error_message)
420 .map(|message| match &reason {
421 Some(r) => format!("{message} (reason: {r})"),
422 None => message,
423 })
424 .unwrap_or(raw);
425 DriveError::ApiRequestFailed {
426 api,
427 status,
428 body,
429 reason,
430 }
431 }
432}
433
434/// Extracts a machine-readable reason code from an already-parsed Google
435/// JSON error envelope, understanding **both** shapes Google ships.
436///
437/// Drive v3 and the other older APIs return the legacy envelope, whose code
438/// lives at `error.errors[0].reason` (`"insufficientPermissions"`,
439/// `"userRateLimitExceeded"`, ...). Newer services — Sheets v4 among them —
440/// return the `google.rpc` envelope instead:
441///
442/// ```json
443/// {"error":{"code":403,"message":"...","status":"PERMISSION_DENIED"}}
444/// ```
445///
446/// which has **no `errors[]` array at all**. Reading only the legacy shape
447/// yields `reason: None` for every Sheets failure, which silently disables
448/// both [`crate::drive::files_api::append_write_scope_hint`] (it matches on
449/// the reason string) and [`is_quota_exceeded`] — neither fails loudly, they
450/// just never fire. Hence: legacy first, then `error.status`.
451fn error_reason(value: &serde_json::Value) -> Option<String> {
452 let error = value.get("error")?;
453 let legacy = error
454 .get("errors")
455 .and_then(|e| e.as_array())
456 .and_then(|a| a.first())
457 .and_then(|e| e.get("reason"))
458 .and_then(|r| r.as_str())
459 .map(str::to_string);
460 legacy.or_else(|| error.get("status")?.as_str().map(str::to_string))
461}
462
463/// Extracts the `error.message` field from an already-parsed Google JSON
464/// error envelope, if present. Both envelope shapes carry it identically.
465fn error_message(value: &serde_json::Value) -> Option<String> {
466 value
467 .get("error")?
468 .get("message")?
469 .as_str()
470 .map(str::to_string)
471}
472
473/// Whether a response is Google's quota-exhaustion signal — a **403**
474/// carrying the legacy `userRateLimitExceeded` reason or the `google.rpc`
475/// `RESOURCE_EXHAUSTED` status, not any 403 with a reason field:
476/// `insufficientPermissions` / `PERMISSION_DENIED` are also 403s and must
477/// never be retried (retrying a scope error just wastes the backoff window
478/// before failing anyway).
479///
480/// Unlike Gmail's `is_gmail_quota_exceeded`, this does **not** also match the
481/// bare `rateLimitExceeded` reason — that string is confirmed for Gmail but
482/// not for Drive against
483/// [Drive's error-handling guide](https://developers.google.com/workspace/drive/api/guides/handle-errors);
484/// widen this match only once testing surfaces it. Plain `429` responses are
485/// already covered by the literal `status == 429` branch in
486/// [`GoogleApiClient::send_once`], independent of this function.
487fn is_quota_exceeded(status: u16, body: &[u8]) -> bool {
488 if status != 403 {
489 return false;
490 }
491 let Ok(text) = std::str::from_utf8(body) else {
492 return false;
493 };
494 let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
495 return false;
496 };
497 matches!(
498 error_reason(&value).as_deref(),
499 Some("userRateLimitExceeded" | "RESOURCE_EXHAUSTED")
500 )
501}
502
503/// Test-only seam letting sibling modules bootstrap a deterministic access
504/// token against a wiremock `/token` endpoint.
505///
506/// A **constructor**, not a post-hoc field assignment: with the session held
507/// behind an `Arc` and shared into derived clients, replacing it after a
508/// derivation would leave the derived client pointed at the real
509/// `oauth2.googleapis.com` — a live network call from a unit test.
510#[cfg(test)]
511impl GoogleApiClient {
512 pub(crate) fn new_with_token_endpoint(
513 base_url: &str,
514 credentials: &DriveCredentials,
515 service: &'static str,
516 api_name: &'static str,
517 token_endpoint: &str,
518 ) -> Result<Self> {
519 let client = Client::builder()
520 .connect_timeout(connect_timeout())
521 .read_timeout(read_timeout())
522 .build()
523 .context("Failed to build HTTP client")?;
524 let session = Arc::new(DriveSession::new_with_token_endpoint(
525 client.clone(),
526 credentials,
527 token_endpoint,
528 ));
529 Ok(Self::from_parts(
530 client, base_url, session, service, api_name,
531 ))
532 }
533}
534
535#[cfg(test)]
536#[allow(clippy::unwrap_used, clippy::expect_used)]
537mod tests {
538 use super::*;
539 use crate::drive::auth::DriveGrantedScopes;
540 use crate::utils::secret::Secret;
541
542 fn test_credentials() -> DriveCredentials {
543 DriveCredentials {
544 client_id: "client-1".to_string(),
545 client_secret: Secret::new("secret-1"),
546 refresh_token: Secret::new("refresh-1"),
547 scope: DriveGrantedScopes::READONLY,
548 }
549 }
550
551 // ── error_reason: both Google envelope shapes ──────────────────────
552
553 #[test]
554 fn error_reason_reads_the_legacy_drive_envelope() {
555 let value = serde_json::json!({
556 "error": {"errors": [{"reason": "insufficientPermissions"}], "message": "nope"},
557 });
558 assert_eq!(
559 error_reason(&value).as_deref(),
560 Some("insufficientPermissions")
561 );
562 }
563
564 #[test]
565 fn error_reason_reads_the_google_rpc_status_when_there_is_no_errors_array() {
566 // The shape Sheets v4 actually returns. Before issue #1589 this
567 // yielded None, silently disabling the write-scope hint and the
568 // quota retry for every Sheets call.
569 let value = serde_json::json!({
570 "error": {"code": 403, "message": "The caller does not have permission",
571 "status": "PERMISSION_DENIED"},
572 });
573 assert_eq!(error_reason(&value).as_deref(), Some("PERMISSION_DENIED"));
574 }
575
576 #[test]
577 fn error_reason_prefers_the_legacy_reason_when_both_are_present() {
578 let value = serde_json::json!({
579 "error": {"errors": [{"reason": "insufficientPermissions"}],
580 "status": "PERMISSION_DENIED"},
581 });
582 assert_eq!(
583 error_reason(&value).as_deref(),
584 Some("insufficientPermissions")
585 );
586 }
587
588 #[test]
589 fn error_reason_none_when_neither_shape_is_present() {
590 assert_eq!(error_reason(&serde_json::json!({"error": {}})), None);
591 assert_eq!(error_reason(&serde_json::json!({"nope": 1})), None);
592 }
593
594 // ── is_quota_exceeded ──────────────────────────────────────────────
595
596 #[test]
597 fn is_quota_exceeded_false_on_non_utf8_body() {
598 assert!(!is_quota_exceeded(403, &[0xff, 0xfe]));
599 }
600
601 #[test]
602 fn is_quota_exceeded_false_on_non_403_status() {
603 assert!(!is_quota_exceeded(
604 429,
605 br#"{"error":{"errors":[{"reason":"userRateLimitExceeded"}]}}"#
606 ));
607 }
608
609 #[test]
610 fn is_quota_exceeded_true_on_matching_403_reason() {
611 assert!(is_quota_exceeded(
612 403,
613 br#"{"error":{"errors":[{"reason":"userRateLimitExceeded"}]}}"#
614 ));
615 }
616
617 #[test]
618 fn is_quota_exceeded_true_on_google_rpc_resource_exhausted() {
619 assert!(is_quota_exceeded(
620 403,
621 br#"{"error":{"code":403,"status":"RESOURCE_EXHAUSTED"}}"#
622 ));
623 }
624
625 #[test]
626 fn is_quota_exceeded_false_on_permission_denied() {
627 // The whole point of not matching every 403 with a reason: a scope
628 // error must fail immediately, not burn the backoff window first.
629 assert!(!is_quota_exceeded(
630 403,
631 br#"{"error":{"code":403,"status":"PERMISSION_DENIED"}}"#
632 ));
633 assert!(!is_quota_exceeded(
634 403,
635 br#"{"error":{"errors":[{"reason":"insufficientPermissions"}]}}"#
636 ));
637 }
638
639 // ── construction ───────────────────────────────────────────────────
640
641 #[test]
642 fn new_strips_trailing_slash() {
643 let client = GoogleApiClient::new(
644 "https://example.test/",
645 &test_credentials(),
646 "drive",
647 "Drive",
648 )
649 .unwrap();
650 assert_eq!(client.base_url(), "https://example.test");
651 }
652
653 #[test]
654 fn derived_shares_the_session_and_swaps_the_host() {
655 let drive = GoogleApiClient::new(
656 "https://www.googleapis.com",
657 &test_credentials(),
658 "drive",
659 "Drive",
660 )
661 .unwrap();
662 let sheets = drive.derived("https://sheets.googleapis.com", "drive", "Sheets");
663 assert_eq!(sheets.base_url(), "https://sheets.googleapis.com");
664 assert_eq!(drive.base_url(), "https://www.googleapis.com");
665 assert!(
666 Arc::ptr_eq(&drive.session, &sheets.session),
667 "a derived client must reuse the OAuth session, not open a second one"
668 );
669 }
670
671 #[test]
672 fn debug_never_mentions_the_session_field() {
673 let client = GoogleApiClient::new(
674 "https://example.test",
675 &test_credentials(),
676 "drive",
677 "Drive",
678 )
679 .unwrap();
680 let debug = format!("{client:?}");
681 assert!(!debug.contains("secret-1"));
682 assert!(!debug.contains("refresh-1"));
683 assert!(!debug.contains("session"));
684 }
685
686 /// A 2xx whose body dies mid-stream is still a *successful* request: the
687 /// mutation may have been applied, so it is an inner `Err`, never the
688 /// outer one that a caller would treat as a failure to retry.
689 #[tokio::test]
690 async fn parse_success_response_reports_a_truncated_2xx_body_as_an_inner_error() {
691 use tokio::io::AsyncWriteExt;
692 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
693 let addr = listener.local_addr().unwrap();
694 tokio::spawn(async move {
695 let (mut socket, _) = listener.accept().await.unwrap();
696 // Promises 100 bytes, delivers 3, then hangs up.
697 socket
698 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nabc")
699 .await
700 .unwrap();
701 socket.shutdown().await.unwrap();
702 });
703 let client = GoogleApiClient::new(
704 &format!("http://{addr}"),
705 &test_credentials(),
706 "drive",
707 "Drive",
708 )
709 .unwrap();
710 let response = reqwest::Client::builder()
711 .no_proxy()
712 .build()
713 .unwrap()
714 .get(format!("http://{addr}/x"))
715 .send()
716 .await
717 .unwrap();
718 let parsed = client
719 .parse_success_response::<serde_json::Value>(response, "reading the reply")
720 .await
721 .unwrap();
722 let detail = parsed.unwrap_err();
723 assert!(detail.starts_with("reading the reply: "), "{detail}");
724 }
725}