diff --git a/.changeset/access-not-configured-guidance.md b/.changeset/access-not-configured-guidance.md new file mode 100644 index 00000000..a1caf9f5 --- /dev/null +++ b/.changeset/access-not-configured-guidance.md @@ -0,0 +1,20 @@ +--- +"@googleworkspace/cli": patch +--- + +feat(error): detect disabled APIs and guide users to enable them + +When the Google API returns a 403 `accessNotConfigured` error (i.e., the +required API has not been enabled for the GCP project), `gws` now: + +- Extracts the GCP Console enable URL from the error message body. +- Prints the original error JSON to stdout (machine-readable, unchanged shape + except for an optional new `enable_url` field added to the error object). +- Prints a human-readable hint with the direct enable URL to stderr, along + with instructions to retry after enabling. + +This prevents a dead-end experience where users see a raw 403 JSON blob +with no guidance. The JSON output is backward-compatible; only an optional +`enable_url` field is added when the URL is parseable from the message. + +Fixes #31 diff --git a/README.md b/README.md index c68dea3a..e1ee4fc9 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,42 @@ gws gmail users messages get --params '...' \ All output — success, errors, download metadata — is structured JSON. +## Troubleshooting + +### API not enabled — `accessNotConfigured` + +If a required Google API is not enabled for your GCP project, you will see a +403 error with reason `accessNotConfigured`: + +```json +{ + "error": { + "code": 403, + "message": "Gmail API has not been used in project 549352339482 ...", + "reason": "accessNotConfigured", + "enable_url": "https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482" + } +} +``` + +`gws` also prints an actionable hint to **stderr**: + +``` +💡 API not enabled for your GCP project. + Enable it at: https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482 + After enabling, wait a few seconds and retry your command. +``` + +**Steps to fix:** +1. Click the `enable_url` link (or copy it from the `enable_url` JSON field). +2. In the GCP Console, click **Enable**. +3. Wait ~10 seconds, then retry your `gws` command. + +> [!TIP] +> You can also run `gws setup` which walks you through enabling all required +> APIs for your project automatically. + + ## Development ```bash diff --git a/src/error.rs b/src/error.rs index e8ec1d95..25cc9f59 100644 --- a/src/error.rs +++ b/src/error.rs @@ -22,6 +22,8 @@ pub enum GwsError { code: u16, message: String, reason: String, + /// For `accessNotConfigured` errors: the GCP console URL to enable the API. + enable_url: Option, }, #[error("{0}")] @@ -44,13 +46,20 @@ impl GwsError { code, message, reason, - } => json!({ - "error": { + enable_url, + } => { + let mut error_obj = json!({ "code": code, "message": message, "reason": reason, + }); + // Include enable_url in JSON output when present (accessNotConfigured errors). + // This preserves machine-readable compatibility while adding new optional field. + if let Some(url) = enable_url { + error_obj["enable_url"] = json!(url); } - }), + json!({ "error": error_obj }) + } GwsError::Validation(msg) => json!({ "error": { "code": 400, @@ -84,12 +93,33 @@ impl GwsError { } /// Formats any error as a JSON object and prints to stdout. +/// +/// For `accessNotConfigured` errors (HTTP 403, reason `accessNotConfigured`), +/// additional human-readable guidance is printed to stderr explaining how to +/// enable the API in GCP. The JSON output on stdout is unchanged (machine-readable). pub fn print_error_json(err: &GwsError) { let json = err.to_json(); println!( "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); + + // Print actionable guidance to stderr for accessNotConfigured errors + if let GwsError::Api { + reason, enable_url, .. + } = err + { + if reason == "accessNotConfigured" { + eprintln!(); + eprintln!("💡 API not enabled for your GCP project."); + if let Some(url) = enable_url { + eprintln!(" Enable it at: {url}"); + } else { + eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API."); + } + eprintln!(" After enabling, wait a few seconds and retry your command."); + } + } } #[cfg(test)] @@ -102,11 +132,13 @@ mod tests { code: 404, message: "Not Found".to_string(), reason: "notFound".to_string(), + enable_url: None, }; let json = err.to_json(); assert_eq!(json["error"]["code"], 404); assert_eq!(json["error"]["message"], "Not Found"); assert_eq!(json["error"]["reason"], "notFound"); + assert!(json["error"]["enable_url"].is_null()); } #[test] @@ -144,4 +176,38 @@ mod tests { assert_eq!(json["error"]["message"], "Something went wrong"); assert_eq!(json["error"]["reason"], "internalError"); } + + // --- accessNotConfigured tests --- + + #[test] + fn test_error_to_json_access_not_configured_with_url() { + let err = GwsError::Api { + code: 403, + message: "Gmail API has not been used in project 549352339482 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482 then retry.".to_string(), + reason: "accessNotConfigured".to_string(), + enable_url: Some("https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482".to_string()), + }; + let json = err.to_json(); + assert_eq!(json["error"]["code"], 403); + assert_eq!(json["error"]["reason"], "accessNotConfigured"); + assert_eq!( + json["error"]["enable_url"], + "https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482" + ); + } + + #[test] + fn test_error_to_json_access_not_configured_without_url() { + let err = GwsError::Api { + code: 403, + message: "API not enabled.".to_string(), + reason: "accessNotConfigured".to_string(), + enable_url: None, + }; + let json = err.to_json(); + assert_eq!(json["error"]["code"], 403); + assert_eq!(json["error"]["reason"], "accessNotConfigured"); + // enable_url key should not appear in JSON when None + assert!(json["error"]["enable_url"].is_null()); + } } diff --git a/src/executor.rs b/src/executor.rs index f985c1e7..1bc8532f 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -500,6 +500,27 @@ fn build_url( Ok((full_url, query_params)) } +/// Attempts to extract a GCP console enable URL from a Google API `accessNotConfigured` +/// error message. +/// +/// The message format is typically: +/// `" has not been used in project before or it is disabled. Enable it by visiting then retry."` +/// +/// Returns the URL string if found, otherwise `None`. +pub fn extract_enable_url(message: &str) -> Option { + // Look for "visiting " pattern + let after_visiting = message.split("visiting ").nth(1)?; + // URL ends at the next whitespace character + let url = after_visiting + .split_whitespace() + .next() + .map(|s| { + s.trim_end_matches(|c: char| ['.', ',', ';', ':', ')', ']', '"', '\''].contains(&c)) + }) + .filter(|s| s.starts_with("http"))?; + Some(url.to_string()) +} + fn handle_error_response( status: reqwest::StatusCode, error_body: &str, @@ -526,19 +547,30 @@ fn handle_error_response( .and_then(|m| m.as_str()) .unwrap_or("Unknown error") .to_string(); + + // Reason can appear in "errors[0].reason" or at the top-level "reason" field. let reason = err_obj .get("errors") .and_then(|e| e.as_array()) .and_then(|arr| arr.first()) .and_then(|e| e.get("reason")) .and_then(|r| r.as_str()) + .or_else(|| err_obj.get("reason").and_then(|r| r.as_str())) .unwrap_or("unknown") .to_string(); + // For accessNotConfigured, extract the GCP enable URL from the message. + let enable_url = if reason == "accessNotConfigured" { + extract_enable_url(&message) + } else { + None + }; + return Err(GwsError::Api { code, message, reason, + enable_url, }); } } @@ -547,6 +579,7 @@ fn handle_error_response( code: status.as_u16(), message: error_body.to_string(), reason: "httpError".to_string(), + enable_url: None, }) } @@ -1130,6 +1163,7 @@ mod tests { code, message, reason, + .. } => { assert_eq!(code, 400); assert_eq!(message, "Bad Request"); @@ -1275,6 +1309,7 @@ fn test_handle_error_response_non_json() { code, message, reason, + .. } => { assert_eq!(code, 500); assert_eq!(message, "Internal Server Error Text"); @@ -1284,6 +1319,108 @@ fn test_handle_error_response_non_json() { } } +#[test] +fn test_extract_enable_url_typical_message() { + let msg = "Gmail API has not been used in project 549352339482 before or it is disabled. \ + Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482 then retry."; + let url = extract_enable_url(msg); + assert_eq!( + url.as_deref(), + Some("https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482") + ); +} + +#[test] +fn test_extract_enable_url_no_url() { + let msg = "API not enabled."; + assert_eq!(extract_enable_url(msg), None); +} + +#[test] +fn test_extract_enable_url_non_http() { + let msg = "Enable it by visiting ftp://example.com then retry."; + assert_eq!(extract_enable_url(msg), None); +} + +#[test] +fn test_extract_enable_url_trims_trailing_punctuation() { + let msg = "Enable it by visiting https://console.cloud.google.com/apis/library?project=test123. Then retry."; + let url = extract_enable_url(msg); + assert_eq!( + url.as_deref(), + Some("https://console.cloud.google.com/apis/library?project=test123") + ); +} + +#[test] +fn test_handle_error_response_access_not_configured_with_url() { + // Matches the top-level "reason" field format Google actually returns for this error + let json_err = serde_json::json!({ + "error": { + "code": 403, + "message": "Gmail API has not been used in project 549352339482 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482 then retry.", + "status": "PERMISSION_DENIED", + "reason": "accessNotConfigured" + } + }) + .to_string(); + + let err = handle_error_response( + reqwest::StatusCode::FORBIDDEN, + &json_err, + &AuthMethod::OAuth, + ) + .unwrap_err(); + + match err { + GwsError::Api { + code, + reason, + enable_url, + .. + } => { + assert_eq!(code, 403); + assert_eq!(reason, "accessNotConfigured"); + assert_eq!( + enable_url.as_deref(), + Some("https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482") + ); + } + _ => panic!("Expected Api error"), + } +} + +#[test] +fn test_handle_error_response_access_not_configured_errors_array() { + // Some Google APIs put reason in errors[0].reason + let json_err = serde_json::json!({ + "error": { + "code": 403, + "message": "Drive API has not been used in project 12345 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/drive.googleapis.com/overview?project=12345 then retry.", + "errors": [{ "reason": "accessNotConfigured" }] + } + }) + .to_string(); + + let err = handle_error_response( + reqwest::StatusCode::FORBIDDEN, + &json_err, + &AuthMethod::OAuth, + ) + .unwrap_err(); + + match err { + GwsError::Api { + reason, enable_url, .. + } => { + assert_eq!(reason, "accessNotConfigured"); + assert!(enable_url.is_some()); + assert!(enable_url.unwrap().contains("drive.googleapis.com")); + } + _ => panic!("Expected Api error"), + } +} + #[test] fn test_get_value_type_helper() { assert_eq!(get_value_type(&json!(null)), "null"); diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs index 77bf83ea..1db64aa2 100644 --- a/src/helpers/calendar.rs +++ b/src/helpers/calendar.rs @@ -248,6 +248,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { code: 0, message: err, reason: "calendarList_failed".to_string(), + enable_url: None, }); } diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index d00f17d8..dbe6047c 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -153,6 +153,7 @@ pub(super) async fn handle_subscribe( code: 400, message: format!("Failed to create Pub/Sub topic: {body}"), reason: "pubsubError".to_string(), + enable_url: None, }); } @@ -177,6 +178,7 @@ pub(super) async fn handle_subscribe( code: 400, message: format!("Failed to create Pub/Sub subscription: {body}"), reason: "pubsubError".to_string(), + enable_url: None, }); } @@ -339,6 +341,7 @@ async fn pull_loop( code: 400, message: format!("Pub/Sub pull failed: {body}"), reason: "pubsubError".to_string(), + enable_url: None, }); } diff --git a/src/helpers/gmail/triage.rs b/src/helpers/gmail/triage.rs index bbfd0d5f..6899d4a8 100644 --- a/src/helpers/gmail/triage.rs +++ b/src/helpers/gmail/triage.rs @@ -56,6 +56,7 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> { code: 0, message: err, reason: "list_failed".to_string(), + enable_url: None, }); } diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 11791db3..3609248f 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -58,6 +58,7 @@ pub(super) async fn handle_watch( code: 400, message: format!("Failed to create Pub/Sub topic: {body}"), reason: "pubsubError".to_string(), + enable_url: None, }); } @@ -122,6 +123,7 @@ pub(super) async fn handle_watch( code: 400, message: format!("Failed to create Pub/Sub subscription: {body}"), reason: "pubsubError".to_string(), + enable_url: None, }); } @@ -157,6 +159,7 @@ pub(super) async fn handle_watch( serde_json::to_string(err).unwrap_or_default() ), reason: "gmailError".to_string(), + enable_url: None, }); } @@ -282,6 +285,7 @@ async fn watch_pull_loop( code: 400, message: format!("Pub/Sub pull failed: {body}"), reason: "pubsubError".to_string(), + enable_url: None, }); } diff --git a/src/helpers/workflows.rs b/src/helpers/workflows.rs index b5e262a6..05355d0d 100644 --- a/src/helpers/workflows.rs +++ b/src/helpers/workflows.rs @@ -249,6 +249,7 @@ async fn get_json( code: status.as_u16(), message: body, reason: "workflow_request_failed".to_string(), + enable_url: None, }); } @@ -516,6 +517,7 @@ async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> { code: status.as_u16(), message: body, reason: "task_create_failed".to_string(), + enable_url: None, }); } @@ -671,6 +673,7 @@ async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> { code: status.as_u16(), message: body, reason: "chat_send_failed".to_string(), + enable_url: None, }); }