From 30e0f859e4652a7931a57813a0cbd623928e20b1 Mon Sep 17 00:00:00 2001 From: Xiangyi Li Date: Wed, 4 Mar 2026 23:22:15 -0800 Subject: [PATCH 1/4] feat: add GOOGLE_WORKSPACE_CLI_API_BASE_URL for custom/mock endpoint support Add an environment variable that redirects all API requests to a custom endpoint (e.g., a mock server). When set, OAuth authentication is automatically skipped while the real Discovery Document is still fetched so the CLI command tree remains fully functional. Changes: - src/discovery.rs: add custom_api_base_url() with LazyLock caching; rewrite Discovery Document URLs when env var is set - src/executor.rs: add resolve_auth() that skips OAuth for custom endpoints - src/main.rs, src/mcp_server.rs: use resolve_auth() for consistent behavior - AGENTS.md: document env var with security note - Add changeset and unit tests for URL rewriting --- .changeset/custom-api-endpoint.md | 5 ++ AGENTS.md | 1 + src/discovery.rs | 83 ++++++++++++++++++++++++++++++- src/executor.rs | 15 ++++++ src/main.rs | 11 ++-- src/mcp_server.rs | 10 +--- 6 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 .changeset/custom-api-endpoint.md diff --git a/.changeset/custom-api-endpoint.md b/.changeset/custom-api-endpoint.md new file mode 100644 index 0000000..1e2118b --- /dev/null +++ b/.changeset/custom-api-endpoint.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `GOOGLE_WORKSPACE_CLI_API_BASE_URL` env var to redirect API requests to a custom endpoint (e.g., mock server). Authentication is automatically skipped when set. diff --git a/AGENTS.md b/AGENTS.md index 12fa1ca..0ead360 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,4 +155,5 @@ When adding a new helper or CLI command: - `GOOGLE_WORKSPACE_CLI_TOKEN` — Pre-obtained OAuth2 access token (highest priority; bypasses all credential file loading) - `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` — Path to OAuth credentials JSON (no default; if unset, falls back to credentials secured by the OS Keyring and encrypted in `~/.config/gws/`) - `GOOGLE_WORKSPACE_CLI_ACCOUNT` — Default account email for multi-account usage (overridden by `--account` flag) +- `GOOGLE_WORKSPACE_CLI_API_BASE_URL` — Redirects all API requests to a custom endpoint (e.g., `http://localhost:8099`). Authentication is automatically disabled. **Security: never set this in production** — it silently disables OAuth and sends all requests to an arbitrary endpoint. - Supports `.env` files via `dotenvy` diff --git a/src/discovery.rs b/src/discovery.rs index a4bb350..38d3b76 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -183,7 +183,35 @@ pub struct JsonSchemaProperty { pub additional_properties: Option>, } +/// Cached custom API base URL, read once from the environment. +/// Prints a warning on first access so the redirect is never silent. +static CUSTOM_API_BASE_URL: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + let url = std::env::var("GOOGLE_WORKSPACE_CLI_API_BASE_URL").ok().filter(|s| !s.is_empty()); + if let Some(ref u) = url { + eprintln!("[gws] Custom API endpoint active: {u}"); + eprintln!("[gws] Authentication is disabled. Requests will NOT go to Google APIs."); + } + url + }); + +/// Returns the custom API base URL override, if set. +/// +/// When `GOOGLE_WORKSPACE_CLI_API_BASE_URL` is set (e.g., `http://localhost:8099`), all API +/// requests are directed to this endpoint instead of the real Google APIs. +/// Authentication is skipped automatically. This is useful for testing against +/// mock API servers. +pub fn custom_api_base_url() -> Option<&'static str> { + CUSTOM_API_BASE_URL.as_deref() +} + /// Fetches and caches a Google Discovery Document. +/// +/// The Discovery Document is always fetched from the real Google APIs so that +/// gws knows the full command structure (resources, methods, parameters). When +/// `GOOGLE_WORKSPACE_CLI_API_BASE_URL` is set, the document's `root_url` and `base_url` are +/// rewritten to point at the custom endpoint — actual API requests then go to +/// the mock server while the CLI command tree remains fully functional. pub async fn fetch_discovery_document( service: &str, version: &str, @@ -209,7 +237,8 @@ pub async fn fetch_discovery_document( 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_base_url_override(&mut doc); return Ok(doc); } } @@ -250,10 +279,29 @@ pub async fn fetch_discovery_document( let _ = e; } - let doc: RestDescription = serde_json::from_str(&body)?; + let mut doc: RestDescription = serde_json::from_str(&body)?; + apply_base_url_override(&mut doc); Ok(doc) } +/// Rewrite Discovery Document URLs when `GOOGLE_WORKSPACE_CLI_API_BASE_URL` is set. +/// Uses the same base_url structure as the original document — just +/// swaps the host so request paths stay correct for the mock server. +fn apply_base_url_override(doc: &mut RestDescription) { + if let Some(base) = custom_api_base_url() { + rewrite_base_url(doc, base); + } +} + +/// Rewrites `root_url` and `base_url` in a Discovery Document to point at a +/// custom endpoint. Extracted for testability (the env-var path goes through +/// `LazyLock` which is hard to toggle in tests). +fn rewrite_base_url(doc: &mut RestDescription, base: &str) { + let base_trimmed = base.trim_end_matches('/'); + doc.root_url = format!("{base_trimmed}/"); + doc.base_url = Some(format!("{base_trimmed}/")); +} + #[cfg(test)] mod tests { use super::*; @@ -323,4 +371,35 @@ mod tests { assert!(doc.resources.is_empty()); assert!(doc.schemas.is_empty()); } + + #[test] + fn test_rewrite_base_url() { + let mut doc = RestDescription { + name: "gmail".to_string(), + version: "v1".to_string(), + root_url: "https://gmail.googleapis.com/".to_string(), + base_url: Some("https://gmail.googleapis.com/gmail/v1/".to_string()), + service_path: "gmail/v1/".to_string(), + ..Default::default() + }; + + rewrite_base_url(&mut doc, "http://localhost:8099"); + assert_eq!(doc.root_url, "http://localhost:8099/"); + assert_eq!(doc.base_url.as_deref(), Some("http://localhost:8099/")); + } + + #[test] + fn test_rewrite_base_url_trailing_slash() { + let mut doc = RestDescription { + name: "drive".to_string(), + version: "v3".to_string(), + root_url: "https://www.googleapis.com/".to_string(), + base_url: Some("https://www.googleapis.com/drive/v3/".to_string()), + ..Default::default() + }; + + rewrite_base_url(&mut doc, "http://localhost:8099/"); + assert_eq!(doc.root_url, "http://localhost:8099/"); + assert_eq!(doc.base_url.as_deref(), Some("http://localhost:8099/")); + } } diff --git a/src/executor.rs b/src/executor.rs index 8bc4f02..67220f1 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -38,6 +38,21 @@ pub enum AuthMethod { None, } +/// Resolve authentication, skipping OAuth when a custom API endpoint is set. +/// +/// When `GOOGLE_WORKSPACE_CLI_API_BASE_URL` is set, all requests go to a mock +/// server that doesn't need (or support) Google OAuth. This helper centralises +/// that check so every call-site doesn't need to know about the env var. +pub async fn resolve_auth(scopes: &[&str], account: Option<&str>) -> (Option, AuthMethod) { + if crate::discovery::custom_api_base_url().is_some() { + return (None, AuthMethod::None); + } + match crate::auth::get_token(scopes, account).await { + Ok(t) => (Some(t), AuthMethod::OAuth), + Err(_) => (None, AuthMethod::None), + } +} + /// Configuration for auto-pagination. #[derive(Debug, Clone)] pub struct PaginationConfig { diff --git a/src/main.rs b/src/main.rs index 4497731..a381920 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,11 +235,9 @@ async fn run() -> Result<(), GwsError> { // Get scopes from the method let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect(); - // Authenticate: try OAuth, otherwise proceed unauthenticated - let (token, auth_method) = match auth::get_token(&scopes, account.as_deref()).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + // Authenticate: skips OAuth automatically when GOOGLE_WORKSPACE_CLI_API_BASE_URL is set. + let (token, auth_method) = + executor::resolve_auth(&scopes, account.as_deref()).await; // Execute executor::execute_method( @@ -430,6 +428,9 @@ fn print_usage() { println!( " GOOGLE_WORKSPACE_CLI_ACCOUNT Default account email for multi-account" ); + println!( + " GOOGLE_WORKSPACE_CLI_API_BASE_URL Custom API endpoint (e.g., mock server); disables auth" + ); println!(); println!("COMMUNITY:"); println!(" Star the repo: https://github.com/googleworkspace/cli"); diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 2b0752b..7a18dc6 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -425,15 +425,7 @@ async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result = method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match crate::auth::get_token(&scopes, None).await { - Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth), - Err(e) => { - eprintln!( - "[gws mcp] Warning: Authentication failed, proceeding without credentials: {e}" - ); - (None, crate::executor::AuthMethod::None) - } - }; + let (token, auth_method) = crate::executor::resolve_auth(&scopes, None).await; let result = crate::executor::execute_method( &doc, From f3c07af0f57f26bfa2f4671051defa3a2aefc5da Mon Sep 17 00:00:00 2001 From: Xiangyi Li Date: Thu, 5 Mar 2026 01:37:21 -0800 Subject: [PATCH 2/4] address bugs --- src/discovery.rs | 68 ++++++++++++++++++++++++++++++++++------------- src/executor.rs | 14 +++++++--- src/main.rs | 6 +++-- src/mcp_server.rs | 10 ++++++- 4 files changed, 73 insertions(+), 25 deletions(-) diff --git a/src/discovery.rs b/src/discovery.rs index 38d3b76..d102ed7 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -185,15 +185,16 @@ pub struct JsonSchemaProperty { /// Cached custom API base URL, read once from the environment. /// Prints a warning on first access so the redirect is never silent. -static CUSTOM_API_BASE_URL: std::sync::LazyLock> = - std::sync::LazyLock::new(|| { - let url = std::env::var("GOOGLE_WORKSPACE_CLI_API_BASE_URL").ok().filter(|s| !s.is_empty()); - if let Some(ref u) = url { - eprintln!("[gws] Custom API endpoint active: {u}"); - eprintln!("[gws] Authentication is disabled. Requests will NOT go to Google APIs."); - } - url - }); +static CUSTOM_API_BASE_URL: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + let url = std::env::var("GOOGLE_WORKSPACE_CLI_API_BASE_URL") + .ok() + .filter(|s| !s.is_empty()); + if let Some(ref u) = url { + eprintln!("[gws] Custom API endpoint active: {u}"); + eprintln!("[gws] Authentication is disabled. Requests will NOT go to Google APIs."); + } + url +}); /// Returns the custom API base URL override, if set. /// @@ -294,12 +295,17 @@ fn apply_base_url_override(doc: &mut RestDescription) { } /// Rewrites `root_url` and `base_url` in a Discovery Document to point at a -/// custom endpoint. Extracted for testability (the env-var path goes through -/// `LazyLock` which is hard to toggle in tests). +/// custom endpoint while preserving the service path (e.g., `drive/v3/`). +/// Extracted for testability (the env-var path goes through `LazyLock` which +/// is hard to toggle in tests). fn rewrite_base_url(doc: &mut RestDescription, base: &str) { let base_trimmed = base.trim_end_matches('/'); - doc.root_url = format!("{base_trimmed}/"); - doc.base_url = Some(format!("{base_trimmed}/")); + let new_root_url = format!("{base_trimmed}/"); + let original_root_url = std::mem::replace(&mut doc.root_url, new_root_url); + + if let Some(base_url) = &mut doc.base_url { + *base_url = base_url.replace(&original_root_url, &doc.root_url); + } } #[cfg(test)] @@ -373,13 +379,15 @@ mod tests { } #[test] - fn test_rewrite_base_url() { + fn test_rewrite_base_url_empty_service_path() { + // Gmail-style: rootUrl includes the host, servicePath is empty, + // method paths include the full path (e.g., "gmail/v1/users/{userId}/profile") let mut doc = RestDescription { name: "gmail".to_string(), version: "v1".to_string(), root_url: "https://gmail.googleapis.com/".to_string(), - base_url: Some("https://gmail.googleapis.com/gmail/v1/".to_string()), - service_path: "gmail/v1/".to_string(), + base_url: Some("https://gmail.googleapis.com/".to_string()), + service_path: "".to_string(), ..Default::default() }; @@ -389,17 +397,41 @@ mod tests { } #[test] - fn test_rewrite_base_url_trailing_slash() { + fn test_rewrite_base_url_preserves_service_path() { + // Drive-style: rootUrl is the shared host, servicePath is "drive/v3/", + // method paths are short (e.g., "files") let mut doc = RestDescription { name: "drive".to_string(), version: "v3".to_string(), root_url: "https://www.googleapis.com/".to_string(), base_url: Some("https://www.googleapis.com/drive/v3/".to_string()), + service_path: "drive/v3/".to_string(), ..Default::default() }; rewrite_base_url(&mut doc, "http://localhost:8099/"); assert_eq!(doc.root_url, "http://localhost:8099/"); - assert_eq!(doc.base_url.as_deref(), Some("http://localhost:8099/")); + assert_eq!( + doc.base_url.as_deref(), + Some("http://localhost:8099/drive/v3/") + ); + } + + #[test] + fn test_rewrite_base_url_none() { + // Some Discovery Documents omit base_url; build_url() falls back to + // root_url + service_path in that case. Verify we don't panic. + let mut doc = RestDescription { + name: "customsearch".to_string(), + version: "v1".to_string(), + root_url: "https://www.googleapis.com/".to_string(), + base_url: None, + service_path: "customsearch/v1/".to_string(), + ..Default::default() + }; + + rewrite_base_url(&mut doc, "http://localhost:8099"); + assert_eq!(doc.root_url, "http://localhost:8099/"); + assert!(doc.base_url.is_none()); } } diff --git a/src/executor.rs b/src/executor.rs index 67220f1..91d6bee 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -43,13 +43,19 @@ pub enum AuthMethod { /// When `GOOGLE_WORKSPACE_CLI_API_BASE_URL` is set, all requests go to a mock /// server that doesn't need (or support) Google OAuth. This helper centralises /// that check so every call-site doesn't need to know about the env var. -pub async fn resolve_auth(scopes: &[&str], account: Option<&str>) -> (Option, AuthMethod) { +/// +/// Returns `Err` when OAuth fails so call-sites can log context-appropriate +/// warnings (e.g. `[gws mcp]` prefix in MCP mode). +pub async fn resolve_auth( + scopes: &[&str], + account: Option<&str>, +) -> anyhow::Result<(Option, AuthMethod)> { if crate::discovery::custom_api_base_url().is_some() { - return (None, AuthMethod::None); + return Ok((None, AuthMethod::None)); } match crate::auth::get_token(scopes, account).await { - Ok(t) => (Some(t), AuthMethod::OAuth), - Err(_) => (None, AuthMethod::None), + Ok(t) => Ok((Some(t), AuthMethod::OAuth)), + Err(e) => Err(e), } } diff --git a/src/main.rs b/src/main.rs index a381920..dde1ff0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -236,8 +236,10 @@ async fn run() -> Result<(), GwsError> { let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect(); // Authenticate: skips OAuth automatically when GOOGLE_WORKSPACE_CLI_API_BASE_URL is set. - let (token, auth_method) = - executor::resolve_auth(&scopes, account.as_deref()).await; + let (token, auth_method) = match executor::resolve_auth(&scopes, account.as_deref()).await { + Ok(auth) => auth, + Err(_) => (None, executor::AuthMethod::None), + }; // Execute executor::execute_method( diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 7a18dc6..4a29d8e 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -425,7 +425,15 @@ async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result = method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = crate::executor::resolve_auth(&scopes, None).await; + let (token, auth_method) = match crate::executor::resolve_auth(&scopes, None).await { + Ok(auth) => auth, + Err(e) => { + eprintln!( + "[gws mcp] Warning: Authentication failed, proceeding without credentials: {e}" + ); + (None, crate::executor::AuthMethod::None) + } + }; let result = crate::executor::execute_method( &doc, From 76c370ee2276f4a6b05e379dd48d33d85cb3af08 Mon Sep 17 00:00:00 2001 From: Xiangyi Li Date: Thu, 5 Mar 2026 01:48:23 -0800 Subject: [PATCH 3/4] remove comments --- src/discovery.rs | 27 --------------------------- src/executor.rs | 7 ------- src/main.rs | 1 - 3 files changed, 35 deletions(-) diff --git a/src/discovery.rs b/src/discovery.rs index d102ed7..d4a9ded 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -183,8 +183,6 @@ pub struct JsonSchemaProperty { pub additional_properties: Option>, } -/// Cached custom API base URL, read once from the environment. -/// Prints a warning on first access so the redirect is never silent. static CUSTOM_API_BASE_URL: std::sync::LazyLock> = std::sync::LazyLock::new(|| { let url = std::env::var("GOOGLE_WORKSPACE_CLI_API_BASE_URL") .ok() @@ -196,23 +194,11 @@ static CUSTOM_API_BASE_URL: std::sync::LazyLock> = std::sync::Laz url }); -/// Returns the custom API base URL override, if set. -/// -/// When `GOOGLE_WORKSPACE_CLI_API_BASE_URL` is set (e.g., `http://localhost:8099`), all API -/// requests are directed to this endpoint instead of the real Google APIs. -/// Authentication is skipped automatically. This is useful for testing against -/// mock API servers. pub fn custom_api_base_url() -> Option<&'static str> { CUSTOM_API_BASE_URL.as_deref() } /// Fetches and caches a Google Discovery Document. -/// -/// The Discovery Document is always fetched from the real Google APIs so that -/// gws knows the full command structure (resources, methods, parameters). When -/// `GOOGLE_WORKSPACE_CLI_API_BASE_URL` is set, the document's `root_url` and `base_url` are -/// rewritten to point at the custom endpoint — actual API requests then go to -/// the mock server while the CLI command tree remains fully functional. pub async fn fetch_discovery_document( service: &str, version: &str, @@ -285,19 +271,12 @@ pub async fn fetch_discovery_document( Ok(doc) } -/// Rewrite Discovery Document URLs when `GOOGLE_WORKSPACE_CLI_API_BASE_URL` is set. -/// Uses the same base_url structure as the original document — just -/// swaps the host so request paths stay correct for the mock server. fn apply_base_url_override(doc: &mut RestDescription) { if let Some(base) = custom_api_base_url() { rewrite_base_url(doc, base); } } -/// Rewrites `root_url` and `base_url` in a Discovery Document to point at a -/// custom endpoint while preserving the service path (e.g., `drive/v3/`). -/// Extracted for testability (the env-var path goes through `LazyLock` which -/// is hard to toggle in tests). fn rewrite_base_url(doc: &mut RestDescription, base: &str) { let base_trimmed = base.trim_end_matches('/'); let new_root_url = format!("{base_trimmed}/"); @@ -380,8 +359,6 @@ mod tests { #[test] fn test_rewrite_base_url_empty_service_path() { - // Gmail-style: rootUrl includes the host, servicePath is empty, - // method paths include the full path (e.g., "gmail/v1/users/{userId}/profile") let mut doc = RestDescription { name: "gmail".to_string(), version: "v1".to_string(), @@ -398,8 +375,6 @@ mod tests { #[test] fn test_rewrite_base_url_preserves_service_path() { - // Drive-style: rootUrl is the shared host, servicePath is "drive/v3/", - // method paths are short (e.g., "files") let mut doc = RestDescription { name: "drive".to_string(), version: "v3".to_string(), @@ -419,8 +394,6 @@ mod tests { #[test] fn test_rewrite_base_url_none() { - // Some Discovery Documents omit base_url; build_url() falls back to - // root_url + service_path in that case. Verify we don't panic. let mut doc = RestDescription { name: "customsearch".to_string(), version: "v1".to_string(), diff --git a/src/executor.rs b/src/executor.rs index 91d6bee..3d5a97e 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -39,13 +39,6 @@ pub enum AuthMethod { } /// Resolve authentication, skipping OAuth when a custom API endpoint is set. -/// -/// When `GOOGLE_WORKSPACE_CLI_API_BASE_URL` is set, all requests go to a mock -/// server that doesn't need (or support) Google OAuth. This helper centralises -/// that check so every call-site doesn't need to know about the env var. -/// -/// Returns `Err` when OAuth fails so call-sites can log context-appropriate -/// warnings (e.g. `[gws mcp]` prefix in MCP mode). pub async fn resolve_auth( scopes: &[&str], account: Option<&str>, diff --git a/src/main.rs b/src/main.rs index dde1ff0..801959a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,7 +235,6 @@ async fn run() -> Result<(), GwsError> { // Get scopes from the method let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect(); - // Authenticate: skips OAuth automatically when GOOGLE_WORKSPACE_CLI_API_BASE_URL is set. let (token, auth_method) = match executor::resolve_auth(&scopes, account.as_deref()).await { Ok(auth) => auth, Err(_) => (None, executor::AuthMethod::None), From 7cc5875431c988759594819d6d4c93c9e6e2a2d2 Mon Sep 17 00:00:00 2001 From: Xiangyi Li Date: Thu, 5 Mar 2026 01:52:27 -0800 Subject: [PATCH 4/4] Add logging --- src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 801959a..c8956ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -237,7 +237,10 @@ async fn run() -> Result<(), GwsError> { let (token, auth_method) = match executor::resolve_auth(&scopes, account.as_deref()).await { Ok(auth) => auth, - Err(_) => (None, executor::AuthMethod::None), + Err(e) => { + eprintln!("[gws] Warning: Authentication failed, proceeding without credentials: {e}"); + (None, executor::AuthMethod::None) + } }; // Execute