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
30 changes: 30 additions & 0 deletions .changeset/upload-content-type.md
Original file line number Diff line number Diff line change
@@ -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
```
19 changes: 13 additions & 6 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
157 changes: 146 additions & 11 deletions src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -410,6 +414,7 @@ pub async fn execute_method(
page_token.as_deref(),
pages_fetched,
upload_path,
upload_content_type,
)
.await?;

Expand Down Expand Up @@ -728,22 +733,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())?;
Comment on lines +778 to +780
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation using std::path::Path::extension doesn't handle dotfiles (e.g., .bashrc, .gitignore) as having an extension, which can lead to incorrect MIME type inference for such files. Using rsplit_once on the filename is more robust and correctly identifies extensions for dotfiles. This ensures that MIME type inference works as expected for a wider range of common file naming conventions.

    let ext = std::path::Path::new(path)
        .file_name()
        .and_then(std::ffi::OsStr::to_str)
        .and_then(|name| name.rsplit_once('.'))
        .map(|(_, ext)| ext)?;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The divergence only happens for dotfiles with no stem (.md, .bashrc, .gitignore). The two questions are:

  1. Would rsplit_once actually help? For .bashrc and .gitignore, it would yield bashrc and gitignore — neither of which is in our MIME map, so the result is identical (None → fallback). The only real case is a bare .md or .json file with no name, which is extremely uncommon as an upload target.
  2. Would it hurt? It could arguably be less correct — .bashrc is conventionally not considered to have an extension; Rust's Path behavior here aligns with POSIX conventions where leading-dot files are hidden files, not extensionless files with a dotted extension.

I would suggest to leave the code as is, but happy to take another point of view.

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()
Expand Down Expand Up @@ -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="));
Expand All @@ -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 {
Expand Down Expand Up @@ -1668,6 +1798,7 @@ async fn test_execute_method_dry_run() {
AuthMethod::None,
None,
None,
None,
true, // dry_run
&pagination,
None,
Expand Down Expand Up @@ -1711,6 +1842,7 @@ async fn test_execute_method_missing_path_param() {
AuthMethod::None,
None,
None,
None,
true,
&PaginationConfig::default(),
None,
Expand Down Expand Up @@ -1888,6 +2020,7 @@ async fn test_post_without_body_sets_content_length_zero() {
None,
0,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -1928,6 +2061,7 @@ async fn test_post_with_body_does_not_add_content_length_zero() {
None,
0,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -1966,6 +2100,7 @@ async fn test_get_does_not_set_content_length_zero() {
None,
0,
None,
None,
)
.await
.unwrap();
Expand Down
1 change: 1 addition & 0 deletions src/helpers/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
Expand Down
1 change: 1 addition & 0 deletions src/helpers/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
Expand Down
1 change: 1 addition & 0 deletions src/helpers/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
Expand Down
1 change: 1 addition & 0 deletions src/helpers/drive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ TIPS:
auth_method,
None,
Some(file_path),
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
Expand Down
1 change: 1 addition & 0 deletions src/helpers/gmail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@ pub(super) async fn send_raw_email(
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
Expand Down
1 change: 1 addition & 0 deletions src/helpers/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
Expand Down
2 changes: 2 additions & 0 deletions src/helpers/sheets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
Expand Down Expand Up @@ -178,6 +179,7 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
Expand Down
Loading
Loading