From a4bfb5876d65008d48314068fc6d1c7bfb5a3f5f Mon Sep 17 00:00:00 2001 From: Hugo Smadja Date: Thu, 12 Mar 2026 13:15:07 +0000 Subject: [PATCH] feat: add --upload-content-type flag and smart MIME inference for uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/upload-content-type.md | 30 ++++++ src/commands.rs | 19 ++-- src/executor.rs | 157 +++++++++++++++++++++++++++--- src/helpers/calendar.rs | 1 + src/helpers/chat.rs | 1 + src/helpers/docs.rs | 1 + src/helpers/drive.rs | 1 + src/helpers/gmail/mod.rs | 1 + src/helpers/script.rs | 1 + src/helpers/sheets.rs | 2 + src/main.rs | 7 ++ 11 files changed, 204 insertions(+), 17 deletions(-) create mode 100644 .changeset/upload-content-type.md diff --git a/.changeset/upload-content-type.md b/.changeset/upload-content-type.md new file mode 100644 index 00000000..c4ac7e0b --- /dev/null +++ b/.changeset/upload-content-type.md @@ -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 +``` diff --git a/src/commands.rs b/src/commands.rs index ecfe991d..27324e42 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -122,12 +122,19 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option, pages_fetched: u32, upload_path: Option<&str>, + upload_content_type: Option<&str>, ) -> Result { 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?; @@ -728,22 +733,89 @@ fn handle_error_response( }) } +/// 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, +) -> 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, file_bytes: &[u8], + media_mime: &str, ) -> Result<(Vec, String), GwsError> { let boundary = format!("gws_boundary_{:016x}", rand::random::()); - // 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() @@ -1188,7 +1260,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=")); @@ -1209,15 +1282,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 { @@ -1668,6 +1798,7 @@ async fn test_execute_method_dry_run() { AuthMethod::None, None, None, + None, true, // dry_run &pagination, None, @@ -1711,6 +1842,7 @@ async fn test_execute_method_missing_path_param() { AuthMethod::None, None, None, + None, true, &PaginationConfig::default(), None, @@ -1888,6 +2020,7 @@ async fn test_post_without_body_sets_content_length_zero() { None, 0, None, + None, ) .await .unwrap(); @@ -1928,6 +2061,7 @@ async fn test_post_with_body_does_not_add_content_length_zero() { None, 0, None, + None, ) .await .unwrap(); @@ -1966,6 +2100,7 @@ async fn test_get_does_not_set_content_length_zero() { None, 0, None, + None, ) .await .unwrap(); diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs index e300592f..f2324ad7 100644 --- a/src/helpers/calendar.rs +++ b/src/helpers/calendar.rs @@ -170,6 +170,7 @@ TIPS: auth_method, None, None, + None, matches.get_flag("dry-run"), &executor::PaginationConfig::default(), None, diff --git a/src/helpers/chat.rs b/src/helpers/chat.rs index fce51ed5..3495c131 100644 --- a/src/helpers/chat.rs +++ b/src/helpers/chat.rs @@ -109,6 +109,7 @@ TIPS: auth_method, None, None, + None, matches.get_flag("dry-run"), &pagination, None, diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs index e847dfbf..3f6b3896 100644 --- a/src/helpers/docs.rs +++ b/src/helpers/docs.rs @@ -99,6 +99,7 @@ TIPS: auth_method, None, None, + None, matches.get_flag("dry-run"), &pagination, None, diff --git a/src/helpers/drive.rs b/src/helpers/drive.rs index f23aa555..393e0fde 100644 --- a/src/helpers/drive.rs +++ b/src/helpers/drive.rs @@ -110,6 +110,7 @@ TIPS: auth_method, None, Some(file_path), + None, matches.get_flag("dry-run"), &executor::PaginationConfig::default(), None, diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index bada6160..93d5b950 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -426,6 +426,7 @@ pub(super) async fn send_raw_email( auth_method, None, None, + None, matches.get_flag("dry-run"), &pagination, None, diff --git a/src/helpers/script.rs b/src/helpers/script.rs index 8685afb2..b0ad3497 100644 --- a/src/helpers/script.rs +++ b/src/helpers/script.rs @@ -122,6 +122,7 @@ TIPS: auth_method, None, None, + None, matches.get_flag("dry-run"), &executor::PaginationConfig::default(), None, diff --git a/src/helpers/sheets.rs b/src/helpers/sheets.rs index 5c2a9e90..6ca1c2be 100644 --- a/src/helpers/sheets.rs +++ b/src/helpers/sheets.rs @@ -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, diff --git a/src/main.rs b/src/main.rs index 22259a44..f8d984ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::("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 URL/Query parameters as JSON"); println!(" --json Request body as JSON (POST/PATCH/PUT)"); println!(" --upload Local file to upload as media content (multipart)"); + println!(" --upload-content-type MIME type of the uploaded file (auto-detected from extension if omitted)"); println!(" --output Output file path for binary responses"); println!(" --format Output format: json (default), table, yaml, csv"); println!(" --api-version Override the API version (e.g., v2, v3)");