diff --git a/.changeset/api-endpoint-base-url.md b/.changeset/api-endpoint-base-url.md new file mode 100644 index 00000000..b7ba5d26 --- /dev/null +++ b/.changeset/api-endpoint-base-url.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add an opt-in API endpoint base URL override via `GOOGLE_WORKSPACE_CLI_API_ENDPOINT_BASE_URL`. diff --git a/.env.example b/.env.example index f503815d..0cd3e623 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ # Override the config directory (default: ~/.config/gws) # GOOGLE_WORKSPACE_CLI_CONFIG_DIR= +# Override the HTTP(S) base URL for Google API requests. Discovery documents are still fetched from Google. +# GOOGLE_WORKSPACE_CLI_API_ENDPOINT_BASE_URL=https://proxy.example.com/ + # ── Model Armor (response sanitization) ────────────────────────── # Default Model Armor template (overridden by --sanitize flag) # GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE= diff --git a/AGENTS.md b/AGENTS.md index 72211226..e0b737a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,6 +208,7 @@ See [`src/helpers/README.md`](crates/google-workspace-cli/src/helpers/README.md) | Variable | Description | |---|---| | `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override the config directory (default: `~/.config/gws`) | +| `GOOGLE_WORKSPACE_CLI_API_ENDPOINT_BASE_URL` | Optional HTTP(S) base URL override for Google API requests. Discovery documents are still fetched directly from Google. | ### OAuth Client diff --git a/README.md b/README.md index 04c532d0..9edb4843 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,7 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste | `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) | | `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) | | `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) | +| `GOOGLE_WORKSPACE_CLI_API_ENDPOINT_BASE_URL` | Optional HTTP(S) base URL override for Google API requests | | `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template | | `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` | | `GOOGLE_WORKSPACE_CLI_LOG` | Log level for stderr (e.g., `gws=debug`). Off by default. | @@ -390,6 +391,20 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste Environment variables can also be set in a `.env` file (loaded via [dotenvy](https://crates.io/crates/dotenvy)). +### API Endpoint Override + +Set `GOOGLE_WORKSPACE_CLI_API_ENDPOINT_BASE_URL` to override the base URL used for Discovery-driven Google API requests, for example when routing calls through an internal reverse proxy or API gateway: + +```bash +export GOOGLE_WORKSPACE_CLI_API_ENDPOINT_BASE_URL=https://proxy.example.com/ +``` + +Only API request endpoints from Discovery documents are rewritten. Discovery documents themselves are still fetched directly from Google, and cached Discovery JSON remains the original Google document so the endpoint override can be enabled or disabled without clearing the cache. The rewrite replaces the scheme, host, and port while preserving the Google API path, for example `https://www.googleapis.com/drive/v3/files` becomes `https://proxy.example.com/drive/v3/files`. Any path in the configured base URL itself is not prepended. + +This is separate from standard `http_proxy` / `https_proxy` forward proxy support and does not rewrite OAuth authorization or token endpoints. + +Some services share the same path prefix in Discovery, such as Analytics Admin and Analytics Data both using `/v1beta/`. In those cases, a proxy may need its own routing rules beyond path inspection. + ## Exit Codes `gws` uses structured exit codes so scripts can branch on the failure type without parsing error output. diff --git a/crates/google-workspace-cli/src/helpers/script.rs b/crates/google-workspace-cli/src/helpers/script.rs index 11bcdebe..6acd1618 100644 --- a/crates/google-workspace-cli/src/helpers/script.rs +++ b/crates/google-workspace-cli/src/helpers/script.rs @@ -169,13 +169,8 @@ fn process_file(path: &Path) -> Result, GwsError> { filename.trim_end_matches(".js").trim_end_matches(".gs"), ), "html" => ("HTML", filename.trim_end_matches(".html")), - "json" => { - if filename == "appsscript.json" { - ("JSON", "appsscript") - } else { - return Ok(None); - } - } + "json" if filename == "appsscript.json" => ("JSON", "appsscript"), + "json" => return Ok(None), _ => return Ok(None), }; diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 41dcc1e1..470a5fc6 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -484,6 +484,9 @@ fn print_usage() { println!( " GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)" ); + println!( + " GOOGLE_WORKSPACE_CLI_API_ENDPOINT_BASE_URL HTTP(S) base URL override for API requests" + ); println!( " GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND Keyring backend: keyring (default) or file" ); diff --git a/crates/google-workspace/Cargo.toml b/crates/google-workspace/Cargo.toml index 3dc9f083..c5076322 100644 --- a/crates/google-workspace/Cargo.toml +++ b/crates/google-workspace/Cargo.toml @@ -38,3 +38,4 @@ tracing = "0.1" [dev-dependencies] serial_test = "3.4.0" tempfile = "3" +tokio = { version = "1", features = ["macros", "rt"] } diff --git a/crates/google-workspace/src/discovery.rs b/crates/google-workspace/src/discovery.rs index 39c3867d..865b484c 100644 --- a/crates/google-workspace/src/discovery.rs +++ b/crates/google-workspace/src/discovery.rs @@ -21,8 +21,12 @@ use std::collections::HashMap; +use anyhow::Context; use serde::Deserialize; +/// Environment variable for overriding Discovery-derived API request endpoints. +pub const API_ENDPOINT_BASE_URL_ENV: &str = "GOOGLE_WORKSPACE_CLI_API_ENDPOINT_BASE_URL"; + /// Top-level Discovery REST Description document. #[derive(Debug, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -183,6 +187,70 @@ pub struct JsonSchemaProperty { pub additional_properties: Option>, } +/// Returns the configured API endpoint base URL, if one was provided. +pub fn api_endpoint_base_url_from_env() -> Option { + std::env::var(API_ENDPOINT_BASE_URL_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// Rewrites Discovery API endpoints to use a configured endpoint origin. +/// +/// Only the scheme, host, and port are replaced. Existing paths from the +/// Discovery document are preserved so service-specific routing remains +/// Discovery-driven. Discovery fetch URLs themselves are not rewritten. +pub fn rewrite_api_urls_for_endpoint_base_url( + mut doc: RestDescription, + endpoint_base_url: Option<&str>, +) -> anyhow::Result { + let endpoint_base_url = match endpoint_base_url + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(value) => value, + None => return Ok(doc), + }; + + let endpoint_origin = parse_endpoint_base_url_origin(endpoint_base_url)?; + doc.root_url = rewrite_url_origin(&doc.root_url, &endpoint_origin)?; + if let Some(base_url) = doc.base_url.as_mut() { + let rewritten_base_url = rewrite_url_origin(base_url, &endpoint_origin)?; + *base_url = rewritten_base_url; + } + + Ok(doc) +} + +fn parse_endpoint_base_url_origin(endpoint_base_url: &str) -> anyhow::Result { + let url = reqwest::Url::parse(endpoint_base_url) + .with_context(|| format!("Invalid {API_ENDPOINT_BASE_URL_ENV}: {endpoint_base_url}"))?; + if !matches!(url.scheme(), "http" | "https") { + anyhow::bail!("{API_ENDPOINT_BASE_URL_ENV} must use http or https"); + } + Ok(url) +} + +fn rewrite_url_origin(url: &str, endpoint_origin: &reqwest::Url) -> anyhow::Result { + let mut rewritten = reqwest::Url::parse(url) + .with_context(|| format!("Discovery document contains an invalid API URL: {url}"))?; + rewritten + .set_scheme(endpoint_origin.scheme()) + .map_err(|_| { + anyhow::anyhow!( + "Invalid endpoint base URL scheme: {}", + endpoint_origin.scheme() + ) + })?; + rewritten + .set_host(endpoint_origin.host_str()) + .context("Failed to apply endpoint host to Discovery API URL")?; + rewritten + .set_port(endpoint_origin.port()) + .map_err(|_| anyhow::anyhow!("Failed to apply endpoint port to Discovery API URL"))?; + Ok(rewritten.to_string()) +} + /// Fetches and caches a Google Discovery Document. /// /// When `cache_dir` is `Some`, the document is cached on disk with a 24-hour @@ -210,7 +278,10 @@ pub async fn fetch_discovery_document( let data = tokio::fs::read_to_string(&cache_file).await?; let doc: RestDescription = serde_json::from_str(&data)?; tracing::debug!(service = %service, version = %version, "Discovery cache hit"); - return Ok(doc); + return rewrite_api_urls_for_endpoint_base_url( + doc, + api_endpoint_base_url_from_env().as_deref(), + ); } } } @@ -254,13 +325,53 @@ pub async fn fetch_discovery_document( } let doc: RestDescription = serde_json::from_str(&body)?; - Ok(doc) + rewrite_api_urls_for_endpoint_base_url(doc, api_endpoint_base_url_from_env().as_deref()) } #[cfg(test)] mod tests { use super::*; + struct EnvGuard { + key: &'static str, + original: Option, + } + + impl EnvGuard { + fn remove(key: &'static str) -> Self { + let original = std::env::var_os(key); + std::env::remove_var(key); + Self { key, original } + } + + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, original } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = &self.original { + std::env::set_var(self.key, value); + } else { + std::env::remove_var(self.key); + } + } + } + + fn minimal_doc(root_url: &str, service_path: &str, base_url: Option<&str>) -> RestDescription { + RestDescription { + name: "drive".to_string(), + version: "v3".to_string(), + root_url: root_url.to_string(), + service_path: service_path.to_string(), + base_url: base_url.map(str::to_string), + ..RestDescription::default() + } + } + #[test] fn test_deserialize_rest_description() { let json = r#"{ @@ -326,4 +437,196 @@ mod tests { assert!(doc.resources.is_empty()); assert!(doc.schemas.is_empty()); } + + #[test] + fn test_rewrite_api_urls_for_endpoint_base_url_none_noop() { + let doc = minimal_doc( + "https://www.googleapis.com/", + "drive/v3/", + Some("https://www.googleapis.com/drive/v3/"), + ); + + let doc = rewrite_api_urls_for_endpoint_base_url(doc, None).unwrap(); + + assert_eq!(doc.root_url, "https://www.googleapis.com/"); + assert_eq!(doc.service_path, "drive/v3/"); + assert_eq!( + doc.base_url.as_deref(), + Some("https://www.googleapis.com/drive/v3/") + ); + } + + #[test] + fn test_rewrite_api_urls_for_endpoint_base_url_empty_string_noop() { + let doc = minimal_doc( + "https://www.googleapis.com/", + "drive/v3/", + Some("https://www.googleapis.com/drive/v3/"), + ); + + let doc = rewrite_api_urls_for_endpoint_base_url(doc, Some(" ")).unwrap(); + + assert_eq!(doc.root_url, "https://www.googleapis.com/"); + assert_eq!( + doc.base_url.as_deref(), + Some("https://www.googleapis.com/drive/v3/") + ); + } + + #[test] + fn test_rewrite_api_urls_for_endpoint_base_url_replaces_root_url_without_base_url() { + let doc = minimal_doc("https://www.googleapis.com/", "drive/v3/", None); + + let doc = rewrite_api_urls_for_endpoint_base_url( + doc, + Some("https://proxy.example.com/api-gateway/"), + ) + .unwrap(); + + assert_eq!(doc.root_url, "https://proxy.example.com/"); + assert_eq!(doc.service_path, "drive/v3/"); + assert!(doc.base_url.is_none()); + } + + #[test] + fn test_rewrite_api_urls_for_endpoint_base_url_replaces_base_url_and_preserves_paths() { + let doc = minimal_doc( + "https://www.googleapis.com/", + "drive/v3/", + Some("https://sheets.googleapis.com/v4/"), + ); + + let doc = + rewrite_api_urls_for_endpoint_base_url(doc, Some("http://proxy.example.com:8080")) + .unwrap(); + + assert_eq!(doc.root_url, "http://proxy.example.com:8080/"); + assert_eq!( + doc.base_url.as_deref(), + Some("http://proxy.example.com:8080/v4/") + ); + assert_eq!(doc.service_path, "drive/v3/"); + } + + #[test] + fn test_rewrite_api_urls_for_endpoint_base_url_trailing_slash_is_normalized() { + let without_slash = rewrite_api_urls_for_endpoint_base_url( + minimal_doc( + "https://www.googleapis.com/", + "drive/v3/", + Some("https://www.googleapis.com/drive/v3/"), + ), + Some("https://proxy.example.com"), + ) + .unwrap(); + let with_slash = rewrite_api_urls_for_endpoint_base_url( + minimal_doc( + "https://www.googleapis.com/", + "drive/v3/", + Some("https://www.googleapis.com/drive/v3/"), + ), + Some("https://proxy.example.com/"), + ) + .unwrap(); + + assert_eq!(without_slash.root_url, with_slash.root_url); + assert_eq!(without_slash.base_url, with_slash.base_url); + assert_eq!(without_slash.root_url, "https://proxy.example.com/"); + assert_eq!( + without_slash.base_url.as_deref(), + Some("https://proxy.example.com/drive/v3/") + ); + } + + #[test] + fn test_rewrite_api_urls_for_endpoint_base_url_preserves_original_url_paths() { + let doc = minimal_doc( + "https://analyticsadmin.googleapis.com/v1beta/", + "", + Some("https://analyticsdata.googleapis.com/v1beta/"), + ); + + let doc = rewrite_api_urls_for_endpoint_base_url( + doc, + Some("https://proxy.example.com/proxy-prefix/"), + ) + .unwrap(); + + assert_eq!(doc.root_url, "https://proxy.example.com/v1beta/"); + assert_eq!( + doc.base_url.as_deref(), + Some("https://proxy.example.com/v1beta/") + ); + } + + #[test] + fn test_rewrite_api_urls_for_endpoint_base_url_rejects_invalid_endpoint_base_url() { + let doc = minimal_doc("https://www.googleapis.com/", "drive/v3/", None); + + let err = + rewrite_api_urls_for_endpoint_base_url(doc, Some("proxy.example.com")).unwrap_err(); + + assert!(err + .to_string() + .contains("Invalid GOOGLE_WORKSPACE_CLI_API_ENDPOINT_BASE_URL")); + } + + #[test] + fn test_rewrite_api_urls_for_endpoint_base_url_rejects_non_http_scheme() { + let doc = minimal_doc("https://www.googleapis.com/", "drive/v3/", None); + + let err = rewrite_api_urls_for_endpoint_base_url(doc, Some("ftp://proxy.example.com")) + .unwrap_err(); + + assert!(err + .to_string() + .contains("GOOGLE_WORKSPACE_CLI_API_ENDPOINT_BASE_URL must use http or https")); + } + + #[tokio::test(flavor = "current_thread")] + #[serial_test::serial] + async fn test_fetch_discovery_document_rewrites_cache_hit_without_mutating_cache() { + let _env_guard = EnvGuard::set(API_ENDPOINT_BASE_URL_ENV, "https://proxy.example.com/"); + let cache_dir = tempfile::tempdir().unwrap(); + let cache_file = cache_dir.path().join("drive_v3.json"); + let cached_json = r#"{ + "name": "drive", + "version": "v3", + "rootUrl": "https://www.googleapis.com/", + "servicePath": "drive/v3/", + "baseUrl": "https://www.googleapis.com/drive/v3/" + }"#; + std::fs::write(&cache_file, cached_json).unwrap(); + + let doc = fetch_discovery_document("drive", "v3", Some(cache_dir.path())) + .await + .unwrap(); + + assert_eq!(doc.root_url, "https://proxy.example.com/"); + assert_eq!(doc.service_path, "drive/v3/"); + assert_eq!( + doc.base_url.as_deref(), + Some("https://proxy.example.com/drive/v3/") + ); + assert_eq!(std::fs::read_to_string(&cache_file).unwrap(), cached_json); + } + + #[test] + #[serial_test::serial] + fn test_api_endpoint_base_url_from_env_handles_missing_empty_and_configured_values() { + let missing_guard = EnvGuard::remove(API_ENDPOINT_BASE_URL_ENV); + assert_eq!(api_endpoint_base_url_from_env(), None); + drop(missing_guard); + + let empty_guard = EnvGuard::set(API_ENDPOINT_BASE_URL_ENV, " "); + assert_eq!(api_endpoint_base_url_from_env(), None); + drop(empty_guard); + + let value_guard = EnvGuard::set(API_ENDPOINT_BASE_URL_ENV, " https://proxy.example.com/ "); + assert_eq!( + api_endpoint_base_url_from_env().as_deref(), + Some("https://proxy.example.com/") + ); + drop(value_guard); + } }