Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/access-not-configured-guidance.md
Original file line number Diff line number Diff line change
@@ -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 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 69 additions & 3 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")]
Expand All @@ -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,
Expand Down Expand Up @@ -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)]
Expand All @@ -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]
Expand Down Expand Up @@ -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());
}
}
124 changes: 124 additions & 0 deletions src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,24 @@ 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()
.filter(|s| s.starts_with("http"))?;
Some(url.to_string())
}

fn handle_error_response(
status: reqwest::StatusCode,
error_body: &str,
Expand All @@ -526,19 +544,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,
});
}
}
Expand All @@ -547,6 +576,7 @@ fn handle_error_response(
code: status.as_u16(),
message: error_body.to_string(),
reason: "httpError".to_string(),
enable_url: None,
})
}

Expand Down Expand Up @@ -1130,6 +1160,7 @@ mod tests {
code,
message,
reason,
..
} => {
assert_eq!(code, 400);
assert_eq!(message, "Bad Request");
Expand Down Expand Up @@ -1275,6 +1306,7 @@ fn test_handle_error_response_non_json() {
code,
message,
reason,
..
} => {
assert_eq!(code, 500);
assert_eq!(message, "Internal Server Error Text");
Expand All @@ -1284,6 +1316,98 @@ 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_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");
Expand Down
1 change: 1 addition & 0 deletions src/helpers/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
code: 0,
message: err,
reason: "calendarList_failed".to_string(),
enable_url: None,
});
}

Expand Down
3 changes: 3 additions & 0 deletions src/helpers/events/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand All @@ -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,
});
}

Expand Down Expand Up @@ -339,6 +341,7 @@ async fn pull_loop(
code: 400,
message: format!("Pub/Sub pull failed: {body}"),
reason: "pubsubError".to_string(),
enable_url: None,
});
}

Expand Down
Loading
Loading