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..d4a9ded 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -183,6 +183,21 @@ pub struct JsonSchemaProperty { pub additional_properties: Option>, } +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 +}); + +pub fn custom_api_base_url() -> Option<&'static str> { + CUSTOM_API_BASE_URL.as_deref() +} + /// Fetches and caches a Google Discovery Document. pub async fn fetch_discovery_document( service: &str, @@ -209,7 +224,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 +266,27 @@ 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) } +fn apply_base_url_override(doc: &mut RestDescription) { + if let Some(base) = custom_api_base_url() { + rewrite_base_url(doc, base); + } +} + +fn rewrite_base_url(doc: &mut RestDescription, base: &str) { + let base_trimmed = base.trim_end_matches('/'); + 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)] mod tests { use super::*; @@ -323,4 +356,55 @@ mod tests { assert!(doc.resources.is_empty()); assert!(doc.schemas.is_empty()); } + + #[test] + fn test_rewrite_base_url_empty_service_path() { + 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/".to_string()), + service_path: "".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_preserves_service_path() { + 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/drive/v3/") + ); + } + + #[test] + fn test_rewrite_base_url_none() { + 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 8bc4f02..3d5a97e 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -38,6 +38,20 @@ pub enum AuthMethod { None, } +/// Resolve authentication, skipping OAuth when a custom API endpoint is set. +pub async fn resolve_auth( + scopes: &[&str], + account: Option<&str>, +) -> anyhow::Result<(Option, AuthMethod)> { + if crate::discovery::custom_api_base_url().is_some() { + return Ok((None, AuthMethod::None)); + } + match crate::auth::get_token(scopes, account).await { + Ok(t) => Ok((Some(t), AuthMethod::OAuth)), + Err(e) => Err(e), + } +} + /// Configuration for auto-pagination. #[derive(Debug, Clone)] pub struct PaginationConfig { diff --git a/src/main.rs b/src/main.rs index 4497731..c8956ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,10 +235,12 @@ 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), + let (token, auth_method) = match executor::resolve_auth(&scopes, account.as_deref()).await { + Ok(auth) => auth, + Err(e) => { + eprintln!("[gws] Warning: Authentication failed, proceeding without credentials: {e}"); + (None, executor::AuthMethod::None) + } }; // Execute @@ -430,6 +432,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..4a29d8e 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -425,8 +425,8 @@ 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), + 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}"