Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
22 changes: 22 additions & 0 deletions .changeset/drive-download-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@googleworkspace/cli": minor
---

Add `drive +download` helper for downloading Drive files to a local path

The new `+download` command is a multi-step helper that:
1. Fetches file metadata (name, MIME type) to determine how to download
2. For Google Workspace native files (Docs, Sheets, Slides) uses `files.export`
with the caller-supplied `--mime-type` (e.g. `application/pdf`, `text/csv`)
3. For all other files uses `files.get?alt=media`
4. Writes the response bytes to a local path validated against path traversal

This complements the existing `+upload` helper and follows all helper
guidelines: it performs multi-step orchestration that the raw Discovery
API cannot express as a single call.

```
gws drive +download --file FILE_ID
gws drive +download --file FILE_ID --output report.pdf
gws drive +download --file FILE_ID --mime-type application/pdf
```
65 changes: 65 additions & 0 deletions crates/google-workspace-cli/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,71 @@ fn handle_error_response<T>(
})
}

/// Parse a Google API error response body into a [`GwsError`].
///
/// Extracts the real `reason`, `message`, and `enable_url` from the JSON
/// payload so that specialised error handling (e.g. `accessNotConfigured`
/// hints) works correctly in callers that perform their own HTTP requests
/// outside of [`execute_method`].
///
/// When `auth_method` is [`AuthMethod::None`] and the status is 401 or 403,
/// a helpful login hint is returned — mirroring the behaviour of the internal
/// `handle_error_response` helper.
pub fn api_error_from_response(
status: reqwest::StatusCode,
body: &str,
auth_method: &AuthMethod,
) -> GwsError {
// Mirror handle_error_response: give a helpful login hint when no auth was provided.
if (status.as_u16() == 401 || status.as_u16() == 403) && *auth_method == AuthMethod::None {
return GwsError::Auth(
"Access denied. No credentials provided. Run `gws auth login` or set \
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE to an OAuth credentials JSON file."
.to_string(),
);
}

if let Ok(error_json) = serde_json::from_str::<Value>(body) {
if let Some(err_obj) = error_json.get("error") {
let code = err_obj
.get("code")
.and_then(|c| c.as_u64())
.unwrap_or(status.as_u16() as u64) as u16;
let message = err_obj
.get("message")
.and_then(|m| m.as_str())
.map(sanitize_for_terminal)
.unwrap_or_else(|| "Unknown error".to_string());
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()))
.map(sanitize_for_terminal)
.unwrap_or_else(|| "unknown".to_string());
let enable_url = if reason == "accessNotConfigured" {
extract_enable_url(&message)
} else {
None
};
return GwsError::Api {
code,
message,
reason,
enable_url,
};
}
}
GwsError::Api {
code: status.as_u16(),
message: crate::output::sanitize_for_terminal(body),
reason: "httpError".to_string(),
enable_url: None,
}
}

/// Resolves the MIME type for the uploaded media content.
///
/// Priority:
Expand Down
Loading
Loading