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:
Huglo
2026-03-13 20:18:30 +00:00
committed by GitHub
parent 6e639604a8
commit dc561e0ffe
11 changed files with 204 additions and 17 deletions
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+1
View File
@@ -170,6 +170,7 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
+1
View File
@@ -109,6 +109,7 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
+1
View File
@@ -99,6 +99,7 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
+1
View File
@@ -110,6 +110,7 @@ TIPS:
auth_method,
None,
Some(file_path),
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
+1
View File
@@ -613,6 +613,7 @@ pub(super) async fn send_raw_email(
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
+1
View File
@@ -122,6 +122,7 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
+2
View File
@@ -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,
+7
View File
@@ -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)");