use std::sync::Arc;
use anyhow::{Context, Result};
use reqwest::{Client, Response};
use url::Url;
use crate::drive::auth::{DriveCredentials, DriveSession};
use crate::drive::error::DriveError;
use crate::request_log;
use crate::utils::http::{connect_timeout, read_timeout, retry_if};
pub(crate) struct GoogleApiClient {
client: Client,
base_url: String,
session: Arc<DriveSession>,
service: &'static str,
api_name: &'static str,
}
impl std::fmt::Debug for GoogleApiClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GoogleApiClient")
.field("base_url", &self.base_url)
.field("service", &self.service)
.field("api_name", &self.api_name)
.finish_non_exhaustive()
}
}
impl GoogleApiClient {
pub(crate) fn new(
base_url: &str,
credentials: &DriveCredentials,
service: &'static str,
api_name: &'static str,
) -> Result<Self> {
let client = Client::builder()
.connect_timeout(connect_timeout())
.read_timeout(read_timeout())
.build()
.context("Failed to build HTTP client")?;
let session = Arc::new(DriveSession::new(client.clone(), credentials));
Ok(Self::from_parts(
client, base_url, session, service, api_name,
))
}
#[allow(dead_code)]
pub(crate) fn derived(
&self,
base_url: &str,
service: &'static str,
api_name: &'static str,
) -> Self {
Self::from_parts(
self.client.clone(),
base_url,
Arc::clone(&self.session),
service,
api_name,
)
}
fn from_parts(
client: Client,
base_url: &str,
session: Arc<DriveSession>,
service: &'static str,
api_name: &'static str,
) -> Self {
Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
session,
service,
api_name,
}
}
pub(crate) fn base_url(&self) -> &str {
&self.base_url
}
pub(crate) fn api_url(base_url: &str, path: &str) -> Result<Url> {
Url::parse(&format!("{base_url}{path}")).context("Invalid Drive base URL")
}
pub(crate) fn push_path_segments(url: &mut Url, segments: &[&str]) -> Result<()> {
let mut path = url
.path_segments_mut()
.map_err(|()| anyhow::anyhow!("Invalid API base URL: cannot be a base"))?;
for segment in segments {
path.push(segment);
}
Ok(())
}
pub(crate) async fn parse_response<T: serde::de::DeserializeOwned>(
&self,
response: Response,
context: &'static str,
) -> Result<T> {
let response = self.error_unless_success(response).await?;
response.json().await.context(context)
}
async fn error_unless_success(&self, response: Response) -> Result<Response> {
if response.status().is_success() {
Ok(response)
} else {
Err(Self::response_to_error(self.api_name, response)
.await
.into())
}
}
pub(crate) async fn parse_success_response<T: serde::de::DeserializeOwned>(
&self,
response: Response,
context: &'static str,
) -> Result<std::result::Result<T, String>> {
let response = self.error_unless_success(response).await?;
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(err) => return Ok(Err(format!("{context}: {err}"))),
};
Ok(serde_json::from_slice(&bytes).map_err(|err| format!("{context}: {err}")))
}
pub(crate) async fn get_parsed<T: serde::de::DeserializeOwned>(
&self,
url: &str,
context: &'static str,
) -> Result<T> {
let response = self.get_json(url).await?;
self.parse_response(response, context).await
}
pub(crate) async fn get_json(&self, url: &str) -> Result<Response> {
self.send_authorized(url, "GET", |client, token| {
client
.get(url)
.bearer_auth(token)
.header("Accept", "application/json")
})
.await
}
pub(crate) async fn get_bytes(&self, url: &str) -> Result<Response> {
self.send_authorized(url, "GET", |client, token| {
client.get(url).bearer_auth(token)
})
.await
}
pub(crate) async fn post_json<T: serde::Serialize + Sync + ?Sized>(
&self,
url: &str,
body: &T,
) -> Result<Response> {
self.send_authorized(url, "POST", |client, token| {
client
.post(url)
.bearer_auth(token)
.header("Content-Type", "application/json")
.json(body)
})
.await
}
#[allow(dead_code)]
pub(crate) async fn put_json<T: serde::Serialize + Sync + ?Sized>(
&self,
url: &str,
body: &T,
) -> Result<Response> {
self.send_authorized(url, "PUT", |client, token| {
client
.put(url)
.bearer_auth(token)
.header("Content-Type", "application/json")
.json(body)
})
.await
}
pub(crate) async fn patch_json<T: serde::Serialize + Sync + ?Sized>(
&self,
url: &str,
body: &T,
) -> Result<Response> {
self.send_authorized(url, "PATCH", |client, token| {
client
.patch(url)
.bearer_auth(token)
.header("Content-Type", "application/json")
.json(body)
})
.await
}
pub(crate) async fn post_bytes(
&self,
url: &str,
body: &[u8],
content_type: &str,
) -> Result<Response> {
self.send_authorized(url, "POST", |client, token| {
client
.post(url)
.bearer_auth(token)
.header("Content-Type", content_type)
.body(body.to_vec())
})
.await
}
pub(crate) async fn patch_bytes(
&self,
url: &str,
body: &[u8],
content_type: &str,
) -> Result<Response> {
self.send_authorized(url, "PATCH", |client, token| {
client
.patch(url)
.bearer_auth(token)
.header("Content-Type", content_type)
.body(body.to_vec())
})
.await
}
async fn send_authorized<F>(
&self,
url: &str,
method: &'static str,
build: F,
) -> Result<Response>
where
F: Fn(&Client, &str) -> reqwest::RequestBuilder + Send + Sync,
{
let token = self
.session
.access_token()
.await
.context("Failed to obtain a Drive access token")?;
let response = self
.send_once(url, method, &build, token.expose_secret())
.await?;
if response.status().as_u16() != 401 {
return Ok(response);
}
let refreshed = self
.session
.force_refresh(&token)
.await
.context("Failed to refresh the Drive access token after a 401")?;
self.send_once(url, method, &build, refreshed.expose_secret())
.await
}
async fn send_once<F>(
&self,
url: &str,
method: &'static str,
build: &F,
token: &str,
) -> Result<Response>
where
F: Fn(&Client, &str) -> reqwest::RequestBuilder + Send + Sync,
{
retry_if(
|| build(&self.client, token),
|started, result| {
request_log::record_http_result(self.service, method, url, started, result);
},
|status, body| status == 429 || is_quota_exceeded(status, body),
None,
)
.await
.with_context(|| format!("Failed to send {method} request to Drive API"))
}
pub(crate) async fn response_to_error(api: &'static str, response: Response) -> DriveError {
let status = response.status().as_u16();
let raw = response.text().await.unwrap_or_default();
let value = serde_json::from_str::<serde_json::Value>(&raw).ok();
let reason = value.as_ref().and_then(error_reason);
let body = value
.as_ref()
.and_then(error_message)
.map(|message| match &reason {
Some(r) => format!("{message} (reason: {r})"),
None => message,
})
.unwrap_or(raw);
DriveError::ApiRequestFailed {
api,
status,
body,
reason,
}
}
}
fn error_reason(value: &serde_json::Value) -> Option<String> {
let error = value.get("error")?;
let legacy = error
.get("errors")
.and_then(|e| e.as_array())
.and_then(|a| a.first())
.and_then(|e| e.get("reason"))
.and_then(|r| r.as_str())
.map(str::to_string);
legacy.or_else(|| error.get("status")?.as_str().map(str::to_string))
}
fn error_message(value: &serde_json::Value) -> Option<String> {
value
.get("error")?
.get("message")?
.as_str()
.map(str::to_string)
}
fn is_quota_exceeded(status: u16, body: &[u8]) -> bool {
if status != 403 {
return false;
}
let Ok(text) = std::str::from_utf8(body) else {
return false;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
return false;
};
matches!(
error_reason(&value).as_deref(),
Some("userRateLimitExceeded" | "RESOURCE_EXHAUSTED")
)
}
#[cfg(test)]
impl GoogleApiClient {
pub(crate) fn new_with_token_endpoint(
base_url: &str,
credentials: &DriveCredentials,
service: &'static str,
api_name: &'static str,
token_endpoint: &str,
) -> Result<Self> {
let client = Client::builder()
.connect_timeout(connect_timeout())
.read_timeout(read_timeout())
.build()
.context("Failed to build HTTP client")?;
let session = Arc::new(DriveSession::new_with_token_endpoint(
client.clone(),
credentials,
token_endpoint,
));
Ok(Self::from_parts(
client, base_url, session, service, api_name,
))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::DriveGrantedScopes;
use crate::utils::secret::Secret;
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
#[test]
fn error_reason_reads_the_legacy_drive_envelope() {
let value = serde_json::json!({
"error": {"errors": [{"reason": "insufficientPermissions"}], "message": "nope"},
});
assert_eq!(
error_reason(&value).as_deref(),
Some("insufficientPermissions")
);
}
#[test]
fn error_reason_reads_the_google_rpc_status_when_there_is_no_errors_array() {
let value = serde_json::json!({
"error": {"code": 403, "message": "The caller does not have permission",
"status": "PERMISSION_DENIED"},
});
assert_eq!(error_reason(&value).as_deref(), Some("PERMISSION_DENIED"));
}
#[test]
fn error_reason_prefers_the_legacy_reason_when_both_are_present() {
let value = serde_json::json!({
"error": {"errors": [{"reason": "insufficientPermissions"}],
"status": "PERMISSION_DENIED"},
});
assert_eq!(
error_reason(&value).as_deref(),
Some("insufficientPermissions")
);
}
#[test]
fn error_reason_none_when_neither_shape_is_present() {
assert_eq!(error_reason(&serde_json::json!({"error": {}})), None);
assert_eq!(error_reason(&serde_json::json!({"nope": 1})), None);
}
#[test]
fn is_quota_exceeded_false_on_non_utf8_body() {
assert!(!is_quota_exceeded(403, &[0xff, 0xfe]));
}
#[test]
fn is_quota_exceeded_false_on_non_403_status() {
assert!(!is_quota_exceeded(
429,
br#"{"error":{"errors":[{"reason":"userRateLimitExceeded"}]}}"#
));
}
#[test]
fn is_quota_exceeded_true_on_matching_403_reason() {
assert!(is_quota_exceeded(
403,
br#"{"error":{"errors":[{"reason":"userRateLimitExceeded"}]}}"#
));
}
#[test]
fn is_quota_exceeded_true_on_google_rpc_resource_exhausted() {
assert!(is_quota_exceeded(
403,
br#"{"error":{"code":403,"status":"RESOURCE_EXHAUSTED"}}"#
));
}
#[test]
fn is_quota_exceeded_false_on_permission_denied() {
assert!(!is_quota_exceeded(
403,
br#"{"error":{"code":403,"status":"PERMISSION_DENIED"}}"#
));
assert!(!is_quota_exceeded(
403,
br#"{"error":{"errors":[{"reason":"insufficientPermissions"}]}}"#
));
}
#[test]
fn new_strips_trailing_slash() {
let client = GoogleApiClient::new(
"https://example.test/",
&test_credentials(),
"drive",
"Drive",
)
.unwrap();
assert_eq!(client.base_url(), "https://example.test");
}
#[test]
fn derived_shares_the_session_and_swaps_the_host() {
let drive = GoogleApiClient::new(
"https://www.googleapis.com",
&test_credentials(),
"drive",
"Drive",
)
.unwrap();
let sheets = drive.derived("https://sheets.googleapis.com", "drive", "Sheets");
assert_eq!(sheets.base_url(), "https://sheets.googleapis.com");
assert_eq!(drive.base_url(), "https://www.googleapis.com");
assert!(
Arc::ptr_eq(&drive.session, &sheets.session),
"a derived client must reuse the OAuth session, not open a second one"
);
}
#[test]
fn debug_never_mentions_the_session_field() {
let client = GoogleApiClient::new(
"https://example.test",
&test_credentials(),
"drive",
"Drive",
)
.unwrap();
let debug = format!("{client:?}");
assert!(!debug.contains("secret-1"));
assert!(!debug.contains("refresh-1"));
assert!(!debug.contains("session"));
}
#[tokio::test]
async fn parse_success_response_reports_a_truncated_2xx_body_as_an_inner_error() {
use tokio::io::AsyncWriteExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nabc")
.await
.unwrap();
socket.shutdown().await.unwrap();
});
let client = GoogleApiClient::new(
&format!("http://{addr}"),
&test_credentials(),
"drive",
"Drive",
)
.unwrap();
let response = reqwest::Client::builder()
.no_proxy()
.build()
.unwrap()
.get(format!("http://{addr}/x"))
.send()
.await
.unwrap();
let parsed = client
.parse_success_response::<serde_json::Value>(response, "reading the reply")
.await
.unwrap();
let detail = parsed.unwrap_err();
assert!(detail.starts_with("reading the reply: "), "{detail}");
}
}