Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

[package]
name = "gws"
version = "0.11.1"
version = "0.11.5"
edition = "2021"
description = "Google Workspace CLI — dynamic command surface from Discovery Service"
license = "Apache-2.0"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |
| `GOOGLE_WORKSPACE_PROJECT_ID` | GCP project ID override for quota/billing and fallback for helper commands |
| `GOOGLE_WORKSPACE_CLI_API_BASE` | Override base URL for all Google API calls (e.g. `https://my-proxy.example.com`). Routes discovery doc fetches and API requests through the specified URL instead of `*.googleapis.com` |

Environment variables can also be set in a `.env` file (loaded via [dotenvy](https://crates.io/crates/dotenvy)).

Expand Down
141 changes: 131 additions & 10 deletions src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,14 @@ pub struct JsonSchemaProperty {
pub additional_properties: Option<Box<JsonSchemaProperty>>,
}

fn apply_api_base_override(doc: &mut RestDescription, api_base_override: &Option<String>) {
if let Some(ref override_url) = api_base_override {
let base = override_url.trim_end_matches('/');
doc.root_url = format!("{base}/");
doc.base_url = Some(format!("{base}/{}", doc.service_path));
}
}

/// Fetches and caches a Google Discovery Document.
pub async fn fetch_discovery_document(
service: &str,
Expand All @@ -200,21 +208,29 @@ pub async fn fetch_discovery_document(

let cache_file = cache_dir.join(format!("{service}_{version}.json"));

let api_base_override = std::env::var("GOOGLE_WORKSPACE_CLI_API_BASE").ok();

// Check cache (24hr TTL)
if cache_file.exists() {
if let Ok(metadata) = std::fs::metadata(&cache_file) {
if let Ok(modified) = metadata.modified() {
if modified.elapsed().unwrap_or_default() < std::time::Duration::from_secs(86400) {
let data = std::fs::read_to_string(&cache_file)?;
let doc: RestDescription = serde_json::from_str(&data)?;
let mut doc: RestDescription = serde_json::from_str(&data)?;
apply_api_base_override(&mut doc, &api_base_override);
return Ok(doc);
}
}
}
}

let discovery_base = api_base_override
.as_deref()
.unwrap_or("https://www.googleapis.com");

let url = format!(
"https://www.googleapis.com/discovery/v1/apis/{}/{}/rest",
"{}/discovery/v1/apis/{}/{}/rest",
discovery_base,
crate::validate::encode_path_segment(service),
crate::validate::encode_path_segment(version),
);
Expand All @@ -226,12 +242,18 @@ pub async fn fetch_discovery_document(
resp.text().await?
} else {
// Try the $discovery/rest URL pattern used by newer APIs (Forms, Keep, Meet, etc.)
let alt_url = format!("https://{service}.googleapis.com/$discovery/rest");
let alt_resp = client
.get(&alt_url)
.query(&[("version", version)])
.send()
.await?;
// When an override is set, preserve the service name in the path so the proxy
// can route to the correct upstream.
let alt_url = if let Some(ref base) = api_base_override {
format!("{}/$discovery/rest", base.trim_end_matches('/'))
} else {
format!("https://{service}.googleapis.com/$discovery/rest")
};
let mut alt_req = client.get(&alt_url).query(&[("version", version)]);
if api_base_override.is_some() {
alt_req = alt_req.query(&[("service", service)]);
}
let alt_resp = alt_req.send().await?;
if !alt_resp.status().is_success() {
anyhow::bail!(
"Failed to fetch Discovery Document for {service}/{version}: HTTP {} (tried both standard and $discovery URLs)",
Expand All @@ -243,11 +265,12 @@ pub async fn fetch_discovery_document(

// Write to cache
if let Err(e) = std::fs::write(&cache_file, &body) {
// Non-fatal: just warn via stderr-safe approach
let _ = e;
}

let doc: RestDescription = serde_json::from_str(&body)?;
let mut doc: RestDescription = serde_json::from_str(&body)?;
apply_api_base_override(&mut doc, &api_base_override);

Ok(doc)
}

Expand Down Expand Up @@ -320,4 +343,102 @@ mod tests {
assert!(doc.resources.is_empty());
assert!(doc.schemas.is_empty());
}

struct EnvVarGuard {
name: String,
original: Option<std::ffi::OsString>,
}

impl EnvVarGuard {
fn set(name: &str, value: &str) -> Self {
let original = std::env::var_os(name);
std::env::set_var(name, value);
Self {
name: name.to_string(),
original,
}
}

fn remove(name: &str) -> Self {
let original = std::env::var_os(name);
std::env::remove_var(name);
Self {
name: name.to_string(),
original,
}
}
}

impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original {
Some(v) => std::env::set_var(&self.name, v),
None => std::env::remove_var(&self.name),
}
}
}

#[tokio::test]
#[serial_test::serial]
async fn test_api_base_override_rewrites_doc() {
let _guard = EnvVarGuard::set(
"GOOGLE_WORKSPACE_CLI_API_BASE",
"https://my-proxy.example.com",
);

let doc_json = r#"{
"name": "test",
"version": "v1",
"rootUrl": "https://www.googleapis.com/",
"servicePath": "test/v1/"
}"#;
let mut doc: RestDescription = serde_json::from_str(doc_json).unwrap();

let api_base_override = std::env::var("GOOGLE_WORKSPACE_CLI_API_BASE").ok();
apply_api_base_override(&mut doc, &api_base_override);

assert_eq!(doc.root_url, "https://my-proxy.example.com/");
assert_eq!(
doc.base_url.as_deref(),
Some("https://my-proxy.example.com/test/v1/")
);
}

#[tokio::test]
#[serial_test::serial]
async fn test_api_base_override_unset_preserves_original() {
let _guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_API_BASE");

let doc_json = r#"{
"name": "drive",
"version": "v3",
"rootUrl": "https://www.googleapis.com/",
"servicePath": "drive/v3/"
}"#;

let mut doc: RestDescription = serde_json::from_str(doc_json).unwrap();

let api_base_override = std::env::var("GOOGLE_WORKSPACE_CLI_API_BASE").ok();
apply_api_base_override(&mut doc, &api_base_override);

assert_eq!(doc.root_url, "https://www.googleapis.com/");
assert!(doc.base_url.is_none());
}

#[test]
fn test_api_base_override_strips_trailing_slash() {
let mut doc: RestDescription = serde_json::from_str(
r#"{"name": "test", "version": "v1", "rootUrl": "https://x.com/", "servicePath": "t/v1/"}"#,
)
.unwrap();

let override_url = Some("https://my-proxy.example.com/".to_string());
apply_api_base_override(&mut doc, &override_url);

assert_eq!(doc.root_url, "https://my-proxy.example.com/");
assert_eq!(
doc.base_url.as_deref(),
Some("https://my-proxy.example.com/t/v1/")
);
}
}
14 changes: 13 additions & 1 deletion src/helpers/gmail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ pub(super) const GMAIL_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modi
pub(super) const GMAIL_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/gmail.readonly";
pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub";

static GMAIL_API_BASE: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
std::env::var("GOOGLE_WORKSPACE_CLI_API_BASE")
.unwrap_or_else(|_| "https://gmail.googleapis.com".to_string())
.trim_end_matches('/')
.to_string()
});

pub(super) fn gmail_api_base() -> &'static str {
&GMAIL_API_BASE
}

pub(super) struct OriginalMessage {
pub thread_id: String,
pub message_id_header: String,
Expand Down Expand Up @@ -170,7 +181,8 @@ pub(super) async fn fetch_message_metadata(
message_id: &str,
) -> Result<OriginalMessage, GwsError> {
let url = format!(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}",
"{}/gmail/v1/users/me/messages/{}",
gmail_api_base(),
crate::validate::encode_path_segment(message_id)
);

Expand Down
2 changes: 1 addition & 1 deletion src/helpers/gmail/reply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ pub(super) struct ReplyConfig {
async fn fetch_user_email(client: &reqwest::Client, token: &str) -> Result<String, GwsError> {
let resp = crate::client::send_with_retry(|| {
client
.get("https://gmail.googleapis.com/gmail/v1/users/me/profile")
.get(&format!("{}/gmail/v1/users/me/profile", gmail_api_base()))
.bearer_auth(token)
})
.await
Expand Down
6 changes: 4 additions & 2 deletions src/helpers/gmail/triage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> {
let client = crate::client::build_client()?;

// 1. List message IDs
let list_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages";
let base = gmail_api_base();
let list_url = format!("{base}/gmail/v1/users/me/messages");

let list_resp = client
.get(list_url)
Expand Down Expand Up @@ -97,7 +98,8 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> {
let token = &token;
async move {
let get_url = format!(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date",
"{}/gmail/v1/users/me/messages/{}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date",
gmail_api_base(),
msg_id
);

Expand Down
9 changes: 5 additions & 4 deletions src/helpers/gmail/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ pub(super) async fn handle_watch(
}

let resp = client
.post("https://gmail.googleapis.com/gmail/v1/users/me/watch")
.post(&format!("{}/gmail/v1/users/me/watch", gmail_api_base()))
.bearer_auth(&gmail_token)
.header("Content-Type", "application/json")
.json(&watch_body)
Expand Down Expand Up @@ -180,7 +180,7 @@ pub(super) async fn handle_watch(

// Get initial historyId for tracking
let profile_resp = client
.get("https://gmail.googleapis.com/gmail/v1/users/me/profile")
.get(&format!("{}/gmail/v1/users/me/profile", gmail_api_base()))
.bearer_auth(&gmail_token)
.send()
.await
Expand Down Expand Up @@ -386,7 +386,7 @@ async fn fetch_and_output_messages(
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
) -> Result<(), GwsError> {
let resp = client
.get("https://gmail.googleapis.com/gmail/v1/users/me/history")
.get(&format!("{}/gmail/v1/users/me/history", gmail_api_base()))
.query(&[
("startHistoryId", &start_history_id.to_string()),
("historyTypes", &"messageAdded".to_string()),
Expand All @@ -403,7 +403,8 @@ async fn fetch_and_output_messages(
for msg_id in msg_ids {
// Fetch full message
let msg_url = format!(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}",
"{}/gmail/v1/users/me/messages/{}",
gmail_api_base(),
crate::validate::encode_path_segment(&msg_id),
);
let msg_resp = client
Expand Down
8 changes: 6 additions & 2 deletions src/helpers/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,8 @@ async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> {

// 1. Fetch the email
let msg_url = format!(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}",
"{}/gmail/v1/users/me/messages/{}",
crate::helpers::gmail::gmail_api_base(),
crate::validate::encode_path_segment(message_id),
);
let msg_json = get_json(
Expand Down Expand Up @@ -588,7 +589,10 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> {
// Fetch unread email count
let gmail_json = get_json(
&client,
"https://gmail.googleapis.com/gmail/v1/users/me/messages",
&format!(
"{}/gmail/v1/users/me/messages",
crate::helpers::gmail::gmail_api_base()
),
&token,
&[("q", "is:unread"), ("maxResults", "1")],
)
Expand Down
Loading