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:
jpoehnelt-bot
2026-03-04 00:30:51 -07:00
committed by GitHub
parent f281797d07
commit de2787e90f
9 changed files with 274 additions and 3 deletions
@@ -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
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+1
View File
@@ -248,6 +248,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
code: 0,
message: err,
reason: "calendarList_failed".to_string(),
enable_url: None,
});
}
+3
View File
@@ -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,
});
}
+1
View File
@@ -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,
});
}
+4
View File
@@ -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,
});
}
+3
View File
@@ -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,
});
}