feat(error): detect accessNotConfigured and guide users to enable APIs (#33)
* feat(error): detect accessNotConfigured and guide users to enable APIs When the Google API returns a 403 with reason accessNotConfigured, gws now: - Extracts the GCP Console enable URL from the error message. - Adds an optional enable_url field to the JSON error output. - Prints an actionable hint with the enable URL to stderr. Also adds extract_enable_url() helper with tests, and a Troubleshooting section to README. Fixes #31 * fix(error): trim trailing punctuation from accessNotConfigured enable URL
This commit is contained in:
@@ -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
|
||||
@@ -264,6 +264,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
|
||||
|
||||
+69
-3
@@ -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<String>,
|
||||
},
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -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:
|
||||
/// `"<API> has not been used in project <N> before or it is disabled. Enable it by visiting <URL> then retry."`
|
||||
///
|
||||
/// Returns the URL string if found, otherwise `None`.
|
||||
pub fn extract_enable_url(message: &str) -> Option<String> {
|
||||
// Look for "visiting <URL>" 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");
|
||||
|
||||
@@ -248,6 +248,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
code: 0,
|
||||
message: err,
|
||||
reason: "calendarList_failed".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user