Skip to content

Commit de2787e

Browse files
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
1 parent f281797 commit de2787e

File tree

9 files changed

+274
-3
lines changed

9 files changed

+274
-3
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
feat(error): detect disabled APIs and guide users to enable them
6+
7+
When the Google API returns a 403 `accessNotConfigured` error (i.e., the
8+
required API has not been enabled for the GCP project), `gws` now:
9+
10+
- Extracts the GCP Console enable URL from the error message body.
11+
- Prints the original error JSON to stdout (machine-readable, unchanged shape
12+
except for an optional new `enable_url` field added to the error object).
13+
- Prints a human-readable hint with the direct enable URL to stderr, along
14+
with instructions to retry after enabling.
15+
16+
This prevents a dead-end experience where users see a raw 403 JSON blob
17+
with no guidance. The JSON output is backward-compatible; only an optional
18+
`enable_url` field is added when the URL is parseable from the message.
19+
20+
Fixes #31

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,42 @@ gws gmail users messages get --params '...' \
264264
All output — success, errors, download metadata — is structured JSON.
265265

266266

267+
## Troubleshooting
268+
269+
### API not enabled — `accessNotConfigured`
270+
271+
If a required Google API is not enabled for your GCP project, you will see a
272+
403 error with reason `accessNotConfigured`:
273+
274+
```json
275+
{
276+
"error": {
277+
"code": 403,
278+
"message": "Gmail API has not been used in project 549352339482 ...",
279+
"reason": "accessNotConfigured",
280+
"enable_url": "https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482"
281+
}
282+
}
283+
```
284+
285+
`gws` also prints an actionable hint to **stderr**:
286+
287+
```
288+
💡 API not enabled for your GCP project.
289+
Enable it at: https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482
290+
After enabling, wait a few seconds and retry your command.
291+
```
292+
293+
**Steps to fix:**
294+
1. Click the `enable_url` link (or copy it from the `enable_url` JSON field).
295+
2. In the GCP Console, click **Enable**.
296+
3. Wait ~10 seconds, then retry your `gws` command.
297+
298+
> [!TIP]
299+
> You can also run `gws setup` which walks you through enabling all required
300+
> APIs for your project automatically.
301+
302+
267303
## Development
268304

269305
```bash

src/error.rs

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ pub enum GwsError {
2222
code: u16,
2323
message: String,
2424
reason: String,
25+
/// For `accessNotConfigured` errors: the GCP console URL to enable the API.
26+
enable_url: Option<String>,
2527
},
2628

2729
#[error("{0}")]
@@ -44,13 +46,20 @@ impl GwsError {
4446
code,
4547
message,
4648
reason,
47-
} => json!({
48-
"error": {
49+
enable_url,
50+
} => {
51+
let mut error_obj = json!({
4952
"code": code,
5053
"message": message,
5154
"reason": reason,
55+
});
56+
// Include enable_url in JSON output when present (accessNotConfigured errors).
57+
// This preserves machine-readable compatibility while adding new optional field.
58+
if let Some(url) = enable_url {
59+
error_obj["enable_url"] = json!(url);
5260
}
53-
}),
61+
json!({ "error": error_obj })
62+
}
5463
GwsError::Validation(msg) => json!({
5564
"error": {
5665
"code": 400,
@@ -84,12 +93,33 @@ impl GwsError {
8493
}
8594

8695
/// Formats any error as a JSON object and prints to stdout.
96+
///
97+
/// For `accessNotConfigured` errors (HTTP 403, reason `accessNotConfigured`),
98+
/// additional human-readable guidance is printed to stderr explaining how to
99+
/// enable the API in GCP. The JSON output on stdout is unchanged (machine-readable).
87100
pub fn print_error_json(err: &GwsError) {
88101
let json = err.to_json();
89102
println!(
90103
"{}",
91104
serde_json::to_string_pretty(&json).unwrap_or_default()
92105
);
106+
107+
// Print actionable guidance to stderr for accessNotConfigured errors
108+
if let GwsError::Api {
109+
reason, enable_url, ..
110+
} = err
111+
{
112+
if reason == "accessNotConfigured" {
113+
eprintln!();
114+
eprintln!("💡 API not enabled for your GCP project.");
115+
if let Some(url) = enable_url {
116+
eprintln!(" Enable it at: {url}");
117+
} else {
118+
eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API.");
119+
}
120+
eprintln!(" After enabling, wait a few seconds and retry your command.");
121+
}
122+
}
93123
}
94124

95125
#[cfg(test)]
@@ -102,11 +132,13 @@ mod tests {
102132
code: 404,
103133
message: "Not Found".to_string(),
104134
reason: "notFound".to_string(),
135+
enable_url: None,
105136
};
106137
let json = err.to_json();
107138
assert_eq!(json["error"]["code"], 404);
108139
assert_eq!(json["error"]["message"], "Not Found");
109140
assert_eq!(json["error"]["reason"], "notFound");
141+
assert!(json["error"]["enable_url"].is_null());
110142
}
111143

112144
#[test]
@@ -144,4 +176,38 @@ mod tests {
144176
assert_eq!(json["error"]["message"], "Something went wrong");
145177
assert_eq!(json["error"]["reason"], "internalError");
146178
}
179+
180+
// --- accessNotConfigured tests ---
181+
182+
#[test]
183+
fn test_error_to_json_access_not_configured_with_url() {
184+
let err = GwsError::Api {
185+
code: 403,
186+
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(),
187+
reason: "accessNotConfigured".to_string(),
188+
enable_url: Some("https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482".to_string()),
189+
};
190+
let json = err.to_json();
191+
assert_eq!(json["error"]["code"], 403);
192+
assert_eq!(json["error"]["reason"], "accessNotConfigured");
193+
assert_eq!(
194+
json["error"]["enable_url"],
195+
"https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482"
196+
);
197+
}
198+
199+
#[test]
200+
fn test_error_to_json_access_not_configured_without_url() {
201+
let err = GwsError::Api {
202+
code: 403,
203+
message: "API not enabled.".to_string(),
204+
reason: "accessNotConfigured".to_string(),
205+
enable_url: None,
206+
};
207+
let json = err.to_json();
208+
assert_eq!(json["error"]["code"], 403);
209+
assert_eq!(json["error"]["reason"], "accessNotConfigured");
210+
// enable_url key should not appear in JSON when None
211+
assert!(json["error"]["enable_url"].is_null());
212+
}
147213
}

src/executor.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,27 @@ fn build_url(
500500
Ok((full_url, query_params))
501501
}
502502

503+
/// Attempts to extract a GCP console enable URL from a Google API `accessNotConfigured`
504+
/// error message.
505+
///
506+
/// The message format is typically:
507+
/// `"<API> has not been used in project <N> before or it is disabled. Enable it by visiting <URL> then retry."`
508+
///
509+
/// Returns the URL string if found, otherwise `None`.
510+
pub fn extract_enable_url(message: &str) -> Option<String> {
511+
// Look for "visiting <URL>" pattern
512+
let after_visiting = message.split("visiting ").nth(1)?;
513+
// URL ends at the next whitespace character
514+
let url = after_visiting
515+
.split_whitespace()
516+
.next()
517+
.map(|s| {
518+
s.trim_end_matches(|c: char| ['.', ',', ';', ':', ')', ']', '"', '\''].contains(&c))
519+
})
520+
.filter(|s| s.starts_with("http"))?;
521+
Some(url.to_string())
522+
}
523+
503524
fn handle_error_response(
504525
status: reqwest::StatusCode,
505526
error_body: &str,
@@ -526,19 +547,30 @@ fn handle_error_response(
526547
.and_then(|m| m.as_str())
527548
.unwrap_or("Unknown error")
528549
.to_string();
550+
551+
// Reason can appear in "errors[0].reason" or at the top-level "reason" field.
529552
let reason = err_obj
530553
.get("errors")
531554
.and_then(|e| e.as_array())
532555
.and_then(|arr| arr.first())
533556
.and_then(|e| e.get("reason"))
534557
.and_then(|r| r.as_str())
558+
.or_else(|| err_obj.get("reason").and_then(|r| r.as_str()))
535559
.unwrap_or("unknown")
536560
.to_string();
537561

562+
// For accessNotConfigured, extract the GCP enable URL from the message.
563+
let enable_url = if reason == "accessNotConfigured" {
564+
extract_enable_url(&message)
565+
} else {
566+
None
567+
};
568+
538569
return Err(GwsError::Api {
539570
code,
540571
message,
541572
reason,
573+
enable_url,
542574
});
543575
}
544576
}
@@ -547,6 +579,7 @@ fn handle_error_response(
547579
code: status.as_u16(),
548580
message: error_body.to_string(),
549581
reason: "httpError".to_string(),
582+
enable_url: None,
550583
})
551584
}
552585

@@ -1130,6 +1163,7 @@ mod tests {
11301163
code,
11311164
message,
11321165
reason,
1166+
..
11331167
} => {
11341168
assert_eq!(code, 400);
11351169
assert_eq!(message, "Bad Request");
@@ -1275,6 +1309,7 @@ fn test_handle_error_response_non_json() {
12751309
code,
12761310
message,
12771311
reason,
1312+
..
12781313
} => {
12791314
assert_eq!(code, 500);
12801315
assert_eq!(message, "Internal Server Error Text");
@@ -1284,6 +1319,108 @@ fn test_handle_error_response_non_json() {
12841319
}
12851320
}
12861321

1322+
#[test]
1323+
fn test_extract_enable_url_typical_message() {
1324+
let msg = "Gmail API has not been used in project 549352339482 before or it is disabled. \
1325+
Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482 then retry.";
1326+
let url = extract_enable_url(msg);
1327+
assert_eq!(
1328+
url.as_deref(),
1329+
Some("https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482")
1330+
);
1331+
}
1332+
1333+
#[test]
1334+
fn test_extract_enable_url_no_url() {
1335+
let msg = "API not enabled.";
1336+
assert_eq!(extract_enable_url(msg), None);
1337+
}
1338+
1339+
#[test]
1340+
fn test_extract_enable_url_non_http() {
1341+
let msg = "Enable it by visiting ftp://example.com then retry.";
1342+
assert_eq!(extract_enable_url(msg), None);
1343+
}
1344+
1345+
#[test]
1346+
fn test_extract_enable_url_trims_trailing_punctuation() {
1347+
let msg = "Enable it by visiting https://console.cloud.google.com/apis/library?project=test123. Then retry.";
1348+
let url = extract_enable_url(msg);
1349+
assert_eq!(
1350+
url.as_deref(),
1351+
Some("https://console.cloud.google.com/apis/library?project=test123")
1352+
);
1353+
}
1354+
1355+
#[test]
1356+
fn test_handle_error_response_access_not_configured_with_url() {
1357+
// Matches the top-level "reason" field format Google actually returns for this error
1358+
let json_err = serde_json::json!({
1359+
"error": {
1360+
"code": 403,
1361+
"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.",
1362+
"status": "PERMISSION_DENIED",
1363+
"reason": "accessNotConfigured"
1364+
}
1365+
})
1366+
.to_string();
1367+
1368+
let err = handle_error_response(
1369+
reqwest::StatusCode::FORBIDDEN,
1370+
&json_err,
1371+
&AuthMethod::OAuth,
1372+
)
1373+
.unwrap_err();
1374+
1375+
match err {
1376+
GwsError::Api {
1377+
code,
1378+
reason,
1379+
enable_url,
1380+
..
1381+
} => {
1382+
assert_eq!(code, 403);
1383+
assert_eq!(reason, "accessNotConfigured");
1384+
assert_eq!(
1385+
enable_url.as_deref(),
1386+
Some("https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482")
1387+
);
1388+
}
1389+
_ => panic!("Expected Api error"),
1390+
}
1391+
}
1392+
1393+
#[test]
1394+
fn test_handle_error_response_access_not_configured_errors_array() {
1395+
// Some Google APIs put reason in errors[0].reason
1396+
let json_err = serde_json::json!({
1397+
"error": {
1398+
"code": 403,
1399+
"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.",
1400+
"errors": [{ "reason": "accessNotConfigured" }]
1401+
}
1402+
})
1403+
.to_string();
1404+
1405+
let err = handle_error_response(
1406+
reqwest::StatusCode::FORBIDDEN,
1407+
&json_err,
1408+
&AuthMethod::OAuth,
1409+
)
1410+
.unwrap_err();
1411+
1412+
match err {
1413+
GwsError::Api {
1414+
reason, enable_url, ..
1415+
} => {
1416+
assert_eq!(reason, "accessNotConfigured");
1417+
assert!(enable_url.is_some());
1418+
assert!(enable_url.unwrap().contains("drive.googleapis.com"));
1419+
}
1420+
_ => panic!("Expected Api error"),
1421+
}
1422+
}
1423+
12871424
#[test]
12881425
fn test_get_value_type_helper() {
12891426
assert_eq!(get_value_type(&json!(null)), "null");

src/helpers/calendar.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
248248
code: 0,
249249
message: err,
250250
reason: "calendarList_failed".to_string(),
251+
enable_url: None,
251252
});
252253
}
253254

0 commit comments

Comments
 (0)