feat: add --upload-content-type flag and smart MIME inference for uploads (#429)
The multipart upload media Content-Type is now resolved independently from the metadata mimeType, enabling Drive import conversions (e.g. Markdown → Google Docs) to work automatically. Priority order for the media MIME type: 1. --upload-content-type flag (explicit override) 2. File extension inference (best guess for what the bytes are) 3. Metadata mimeType (backward-compat fallback) 4. application/octet-stream Previously the metadata mimeType was reused for the media part, which meant uploading `notes.md` with mimeType set to `application/vnd.google-apps.document` would incorrectly label the bytes as a Google Doc instead of text/markdown. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
---
|
||||
"@googleworkspace/cli": minor
|
||||
---
|
||||
|
||||
Add `--upload-content-type` flag and smart MIME inference for multipart uploads
|
||||
|
||||
Previously, multipart uploads used the metadata `mimeType` field for both the Drive
|
||||
metadata and the media part's `Content-Type` header. This made it impossible to upload
|
||||
a file in one format (e.g. Markdown) and have Drive convert it to another (e.g. Google Docs),
|
||||
because the media `Content-Type` and the target `mimeType` must differ for import conversions.
|
||||
|
||||
The new `--upload-content-type` flag allows setting the media `Content-Type` explicitly.
|
||||
When omitted, the media type is now inferred from the file extension before falling back
|
||||
to the metadata `mimeType`. This matches Google Drive's model where metadata `mimeType`
|
||||
is the *target* type (what the file should become) while the media `Content-Type` is the
|
||||
*source* type (what the bytes are).
|
||||
|
||||
This means import conversions now work automatically:
|
||||
```bash
|
||||
# Extension inference detects text/markdown → conversion just works
|
||||
gws drive files create \
|
||||
--json '{"name":"My Doc","mimeType":"application/vnd.google-apps.document"}' \
|
||||
--upload notes.md
|
||||
|
||||
# Explicit flag still available as an override
|
||||
gws drive files create \
|
||||
--json '{"name":"My Doc","mimeType":"application/vnd.google-apps.document"}' \
|
||||
--upload notes.md \
|
||||
--upload-content-type text/markdown
|
||||
```
|
||||
+13
-6
@@ -122,12 +122,19 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option<Command
|
||||
|
||||
// Add --upload flag if the method supports media upload
|
||||
if method.supports_media_upload {
|
||||
method_cmd = method_cmd.arg(
|
||||
Arg::new("upload")
|
||||
.long("upload")
|
||||
.help("Local file path to upload as media content (multipart upload)")
|
||||
.value_name("PATH"),
|
||||
);
|
||||
method_cmd = method_cmd
|
||||
.arg(
|
||||
Arg::new("upload")
|
||||
.long("upload")
|
||||
.help("Local file path to upload as media content (multipart upload)")
|
||||
.value_name("PATH"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("upload-content-type")
|
||||
.long("upload-content-type")
|
||||
.help("MIME type of the uploaded file content (e.g. text/markdown). If omitted, detected from file extension or metadata mimeType")
|
||||
.value_name("MIME"),
|
||||
);
|
||||
}
|
||||
|
||||
// Pagination flags
|
||||
|
||||
+146
-11
@@ -145,6 +145,7 @@ async fn build_http_request(
|
||||
page_token: Option<&str>,
|
||||
pages_fetched: u32,
|
||||
upload_path: Option<&str>,
|
||||
upload_content_type: Option<&str>,
|
||||
) -> Result<reqwest::RequestBuilder, GwsError> {
|
||||
let mut request = match method.http_method.as_str() {
|
||||
"GET" => client.get(&input.full_url),
|
||||
@@ -190,7 +191,9 @@ async fn build_http_request(
|
||||
})?;
|
||||
|
||||
request = request.query(&[("uploadType", "multipart")]);
|
||||
let (multipart_body, content_type) = build_multipart_body(&input.body, &file_bytes)?;
|
||||
let media_mime = resolve_upload_mime(upload_content_type, Some(upload_path), &input.body);
|
||||
let (multipart_body, content_type) =
|
||||
build_multipart_body(&input.body, &file_bytes, &media_mime)?;
|
||||
request = request.header("Content-Type", content_type);
|
||||
request = request.body(multipart_body);
|
||||
} else if let Some(ref body_val) = input.body {
|
||||
@@ -367,6 +370,7 @@ pub async fn execute_method(
|
||||
auth_method: AuthMethod,
|
||||
output_path: Option<&str>,
|
||||
upload_path: Option<&str>,
|
||||
upload_content_type: Option<&str>,
|
||||
dry_run: bool,
|
||||
pagination: &PaginationConfig,
|
||||
sanitize_template: Option<&str>,
|
||||
@@ -410,6 +414,7 @@ pub async fn execute_method(
|
||||
page_token.as_deref(),
|
||||
pages_fetched,
|
||||
upload_path,
|
||||
upload_content_type,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -754,22 +759,89 @@ fn handle_error_response<T>(
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves the MIME type for the uploaded media content.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. `--upload-content-type` flag (explicit override)
|
||||
/// 2. File extension inference (best guess for what the bytes actually are)
|
||||
/// 3. Metadata `mimeType` (fallback for backward compatibility)
|
||||
/// 4. `application/octet-stream`
|
||||
///
|
||||
/// Extension inference ranks above metadata `mimeType` because in Google
|
||||
/// Drive's multipart model, metadata `mimeType` represents the *target* type
|
||||
/// (what the file should become in Drive), while the media `Content-Type`
|
||||
/// represents the *source* type (what the bytes are). When a user uploads
|
||||
/// `notes.md` with `"mimeType":"application/vnd.google-apps.document"`, the
|
||||
/// media part should be `text/markdown`, not a Google Workspace MIME type.
|
||||
fn resolve_upload_mime(
|
||||
explicit: Option<&str>,
|
||||
upload_path: Option<&str>,
|
||||
metadata: &Option<Value>,
|
||||
) -> String {
|
||||
if let Some(mime) = explicit {
|
||||
return mime.to_string();
|
||||
}
|
||||
|
||||
if let Some(path) = upload_path {
|
||||
if let Some(detected) = mime_from_extension(path) {
|
||||
return detected.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mime) = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("mimeType"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return mime.to_string();
|
||||
}
|
||||
|
||||
"application/octet-stream".to_string()
|
||||
}
|
||||
|
||||
/// Infers a MIME type from a file path's extension.
|
||||
fn mime_from_extension(path: &str) -> Option<&'static str> {
|
||||
let ext = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())?;
|
||||
match ext.to_lowercase().as_str() {
|
||||
"md" | "markdown" => Some("text/markdown"),
|
||||
"html" | "htm" => Some("text/html"),
|
||||
"txt" => Some("text/plain"),
|
||||
"json" => Some("application/json"),
|
||||
"csv" => Some("text/csv"),
|
||||
"xml" => Some("application/xml"),
|
||||
"pdf" => Some("application/pdf"),
|
||||
"png" => Some("image/png"),
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
"gif" => Some("image/gif"),
|
||||
"svg" => Some("image/svg+xml"),
|
||||
"doc" => Some("application/msword"),
|
||||
"docx" => Some(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
),
|
||||
"xls" => Some("application/vnd.ms-excel"),
|
||||
"xlsx" => Some(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
),
|
||||
"ppt" => Some("application/vnd.ms-powerpoint"),
|
||||
"pptx" => Some(
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a multipart/related body for media upload requests.
|
||||
///
|
||||
/// Returns the body bytes and the Content-Type header value (with boundary).
|
||||
fn build_multipart_body(
|
||||
metadata: &Option<Value>,
|
||||
file_bytes: &[u8],
|
||||
media_mime: &str,
|
||||
) -> Result<(Vec<u8>, String), GwsError> {
|
||||
let boundary = format!("gws_boundary_{:016x}", rand::random::<u64>());
|
||||
|
||||
// Determine the media MIME type from the metadata's mimeType field, or fall back
|
||||
let media_mime = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("mimeType"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("application/octet-stream");
|
||||
|
||||
// Build multipart/related body
|
||||
let metadata_json = metadata
|
||||
.as_ref()
|
||||
@@ -1216,7 +1288,8 @@ mod tests {
|
||||
let metadata = Some(json!({ "name": "test.txt", "mimeType": "text/plain" }));
|
||||
let content = b"Hello world";
|
||||
|
||||
let (body, content_type) = build_multipart_body(&metadata, content).unwrap();
|
||||
let (body, content_type) =
|
||||
build_multipart_body(&metadata, content, "text/plain").unwrap();
|
||||
|
||||
// Check content type has boundary
|
||||
assert!(content_type.starts_with("multipart/related; boundary="));
|
||||
@@ -1237,15 +1310,72 @@ mod tests {
|
||||
let metadata = None;
|
||||
let content = b"Binary data";
|
||||
|
||||
let (body, content_type) = build_multipart_body(&metadata, content).unwrap();
|
||||
let (body, content_type) =
|
||||
build_multipart_body(&metadata, content, "application/octet-stream").unwrap();
|
||||
let boundary = content_type.split("boundary=").nth(1).unwrap();
|
||||
let body_str = String::from_utf8(body).unwrap();
|
||||
|
||||
assert!(body_str.contains(boundary));
|
||||
assert!(body_str.contains("application/octet-stream")); // Fallback mime
|
||||
assert!(body_str.contains("application/octet-stream"));
|
||||
assert!(body_str.contains("Binary data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_upload_mime_explicit_flag() {
|
||||
let metadata = Some(json!({ "mimeType": "image/png" }));
|
||||
let mime = resolve_upload_mime(Some("text/markdown"), Some("file.txt"), &metadata);
|
||||
assert_eq!(mime, "text/markdown", "explicit flag takes top priority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_upload_mime_extension_beats_metadata() {
|
||||
let metadata = Some(json!({ "mimeType": "application/vnd.google-apps.document" }));
|
||||
let mime = resolve_upload_mime(None, Some("notes.md"), &metadata);
|
||||
assert_eq!(
|
||||
mime, "text/markdown",
|
||||
"extension inference ranks above metadata mimeType"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_upload_mime_metadata_fallback_for_unknown_extension() {
|
||||
let metadata = Some(json!({ "mimeType": "text/plain" }));
|
||||
let mime = resolve_upload_mime(None, Some("file.unknown"), &metadata);
|
||||
assert_eq!(
|
||||
mime, "text/plain",
|
||||
"metadata mimeType is used when extension is unrecognized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_upload_mime_extension_when_no_metadata() {
|
||||
let mime = resolve_upload_mime(None, Some("notes.md"), &None);
|
||||
assert_eq!(mime, "text/markdown");
|
||||
|
||||
let mime = resolve_upload_mime(None, Some("page.html"), &None);
|
||||
assert_eq!(mime, "text/html");
|
||||
|
||||
let mime = resolve_upload_mime(None, Some("data.csv"), &None);
|
||||
assert_eq!(mime, "text/csv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_upload_mime_fallback() {
|
||||
let mime = resolve_upload_mime(None, Some("file.unknown"), &None);
|
||||
assert_eq!(mime, "application/octet-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_upload_mime_explicit_enables_import_conversion() {
|
||||
let metadata = Some(json!({ "mimeType": "application/vnd.google-apps.document" }));
|
||||
let mime =
|
||||
resolve_upload_mime(Some("text/markdown"), Some("impact.md"), &metadata);
|
||||
assert_eq!(
|
||||
mime, "text/markdown",
|
||||
"--upload-content-type overrides metadata for media part"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_basic() {
|
||||
let doc = RestDescription {
|
||||
@@ -1735,6 +1865,7 @@ async fn test_execute_method_dry_run() {
|
||||
AuthMethod::None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true, // dry_run
|
||||
&pagination,
|
||||
None,
|
||||
@@ -1778,6 +1909,7 @@ async fn test_execute_method_missing_path_param() {
|
||||
AuthMethod::None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
&PaginationConfig::default(),
|
||||
None,
|
||||
@@ -1955,6 +2087,7 @@ async fn test_post_without_body_sets_content_length_zero() {
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1995,6 +2128,7 @@ async fn test_post_with_body_does_not_add_content_length_zero() {
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2033,6 +2167,7 @@ async fn test_get_does_not_set_content_length_zero() {
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -170,6 +170,7 @@ TIPS:
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&executor::PaginationConfig::default(),
|
||||
None,
|
||||
|
||||
@@ -109,6 +109,7 @@ TIPS:
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&pagination,
|
||||
None,
|
||||
|
||||
@@ -99,6 +99,7 @@ TIPS:
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&pagination,
|
||||
None,
|
||||
|
||||
@@ -110,6 +110,7 @@ TIPS:
|
||||
auth_method,
|
||||
None,
|
||||
Some(file_path),
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&executor::PaginationConfig::default(),
|
||||
None,
|
||||
|
||||
@@ -613,6 +613,7 @@ pub(super) async fn send_raw_email(
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&pagination,
|
||||
None,
|
||||
|
||||
@@ -122,6 +122,7 @@ TIPS:
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&executor::PaginationConfig::default(),
|
||||
None,
|
||||
|
||||
@@ -136,6 +136,7 @@ TIPS:
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&pagination,
|
||||
None,
|
||||
@@ -178,6 +179,7 @@ TIPS:
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&executor::PaginationConfig::default(),
|
||||
None,
|
||||
|
||||
@@ -216,6 +216,11 @@ async fn run() -> Result<(), GwsError> {
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| s.as_str());
|
||||
let upload_content_type = matched_args
|
||||
.try_get_one::<String>("upload-content-type")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| s.as_str());
|
||||
|
||||
let dry_run = matched_args.get_flag("dry-run");
|
||||
|
||||
@@ -254,6 +259,7 @@ async fn run() -> Result<(), GwsError> {
|
||||
auth_method,
|
||||
output_path,
|
||||
upload_path,
|
||||
upload_content_type,
|
||||
dry_run,
|
||||
&pagination,
|
||||
sanitize_config.template.as_deref(),
|
||||
@@ -421,6 +427,7 @@ fn print_usage() {
|
||||
println!(" --params <JSON> URL/Query parameters as JSON");
|
||||
println!(" --json <JSON> Request body as JSON (POST/PATCH/PUT)");
|
||||
println!(" --upload <PATH> Local file to upload as media content (multipart)");
|
||||
println!(" --upload-content-type <MIME> MIME type of the uploaded file (auto-detected from extension if omitted)");
|
||||
println!(" --output <PATH> Output file path for binary responses");
|
||||
println!(" --format <FMT> Output format: json (default), table, yaml, csv");
|
||||
println!(" --api-version <VER> Override the API version (e.g., v2, v3)");
|
||||
|
||||
Reference in New Issue
Block a user