Skip to content

Commit

Permalink
feat(core): add support to multipart/form-data requests, closes #2118 (
Browse files Browse the repository at this point in the history
  • Loading branch information
lucasfernog committed Apr 22, 2022
1 parent 8e00f09 commit 1397d91
Show file tree
Hide file tree
Showing 24 changed files with 667 additions and 140 deletions.
5 changes: 5 additions & 0 deletions .changes/form-multipart-support.md
@@ -0,0 +1,5 @@
---
"tauri": patch
---

The HTTP API now supports `multipart/form-data` requests. You need to set the `Content-Type` header and enable the `http-multipart` Cargo feature.
5 changes: 5 additions & 0 deletions .changes/refactor-form-part-bytes.md
@@ -0,0 +1,5 @@
---
"tauri": patch
---

**Breaking change:** The `tauri::api::http::FormPart::Bytes` enum variant has been renamed to `File` with a value object `{ file, mime, file_name }`.
2 changes: 1 addition & 1 deletion .github/workflows/lint-fmt-core.yml
Expand Up @@ -50,7 +50,7 @@ jobs:
clippy:
- { args: '', key: 'empty' }
- {
args: '--features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray',
args: '--features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray,http-multipart',
key: 'all'
}
- {
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-core.yml
Expand Up @@ -89,4 +89,4 @@ jobs:
run: |
cargo test
cargo test --features api-all
cargo test --features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray
cargo test --features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray,http-multipart
2 changes: 1 addition & 1 deletion .github/workflows/udeps.yml
Expand Up @@ -29,7 +29,7 @@ jobs:
clippy:
- {
path: './core/tauri/Cargo.toml',
args: '--features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray'
args: '--features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray,http-multipart'
}
- { path: './core/tauri-build/Cargo.toml', args: '--all-features' }
- { path: './core/tauri-codegen/Cargo.toml', args: '--all-features' }
Expand Down
6 changes: 4 additions & 2 deletions core/tauri/Cargo.toml
Expand Up @@ -28,6 +28,7 @@ features = [
"__updater-docs",
"system-tray",
"devtools",
"http-multipart",
"dox"
]
rustdoc-args = [ "--cfg", "doc_cfg" ]
Expand All @@ -39,7 +40,7 @@ targets = [
]

[package.metadata.cargo-udeps.ignore]
normal = [ "attohttpc" ]
normal = [ "attohttpc", "reqwest" ]

[dependencies]
serde_json = { version = "1.0", features = [ "raw_value" ] }
Expand Down Expand Up @@ -72,7 +73,7 @@ percent-encoding = "2.1"
base64 = { version = "0.13", optional = true }
clap = { version = "3", optional = true }
notify-rust = { version = "4.5", optional = true }
reqwest = { version = "0.11", features = [ "json", "multipart", "stream" ], optional = true }
reqwest = { version = "0.11", features = [ "json", "stream" ], optional = true }
bytes = { version = "1", features = [ "serde" ], optional = true }
attohttpc = { version = "0.19", features = [ "json", "form" ], optional = true }
open = { version = "2.0", optional = true }
Expand Down Expand Up @@ -134,6 +135,7 @@ updater = [
]
__updater-docs = [ "minisign-verify", "base64", "http-api", "dialog-ask" ]
http-api = [ "attohttpc" ]
http-multipart = [ "attohttpc/multipart-form", "reqwest/multipart" ]
shell-open-api = [ "open", "regex", "tauri-macros/shell-scope" ]
fs-extract-api = [ "zip" ]
reqwest-client = [ "reqwest", "bytes" ]
Expand Down
2 changes: 1 addition & 1 deletion core/tauri/scripts/bundle.js

Large diffs are not rendered by default.

170 changes: 152 additions & 18 deletions core/tauri/src/api/http.rs
Expand Up @@ -11,7 +11,7 @@ use serde_json::Value;
use serde_repr::{Deserialize_repr, Serialize_repr};
use url::Url;

use std::{collections::HashMap, time::Duration};
use std::{collections::HashMap, path::PathBuf, time::Duration};

#[cfg(feature = "reqwest-client")]
pub use reqwest::header;
Expand Down Expand Up @@ -114,7 +114,7 @@ impl Client {
request_builder = request_builder.params(&query);
}

if let Some(headers) = request.headers {
if let Some(headers) = &request.headers {
for (name, value) in headers.0.iter() {
request_builder = request_builder.header(name, value);
}
Expand All @@ -130,14 +130,69 @@ impl Client {
Body::Text(text) => request_builder.body(attohttpc::body::Bytes(text)).send()?,
Body::Json(json) => request_builder.json(&json)?.send()?,
Body::Form(form_body) => {
let mut form = Vec::new();
for (name, part) in form_body.0 {
match part {
FormPart::Bytes(bytes) => form.push((name, serde_json::to_string(&bytes)?)),
FormPart::Text(text) => form.push((name, text)),
#[allow(unused_variables)]
fn send_form(
request_builder: attohttpc::RequestBuilder,
headers: &Option<HeaderMap>,
form_body: FormBody,
) -> crate::api::Result<attohttpc::Response> {
#[cfg(feature = "http-multipart")]
if matches!(
headers
.as_ref()
.and_then(|h| h.0.get("content-type"))
.map(|v| v.as_bytes()),
Some(b"multipart/form-data")
) {
let mut multipart = attohttpc::MultipartBuilder::new();
let mut byte_cache: HashMap<String, Vec<u8>> = Default::default();

for (name, part) in &form_body.0 {
if let FormPart::File { file, .. } = part {
byte_cache.insert(name.to_string(), file.clone().try_into()?);
}
}
for (name, part) in &form_body.0 {
multipart = match part {
FormPart::File {
file,
mime,
file_name,
} => {
// safe to unwrap: always set by previous loop
let mut file =
attohttpc::MultipartFile::new(name, byte_cache.get(name).unwrap());
if let Some(mime) = mime {
file = file.with_type(mime)?;
}
if let Some(file_name) = file_name {
file = file.with_filename(file_name);
}
multipart.with_file(file)
}
FormPart::Text(value) => multipart.with_text(name, value),
};
}
return request_builder
.body(multipart.build()?)
.send()
.map_err(Into::into);
}

let mut form = Vec::new();
for (name, part) in form_body.0 {
match part {
FormPart::File { file, .. } => {
let bytes: Vec<u8> = file.try_into()?;
form.push((name, serde_json::to_string(&bytes)?))
}
FormPart::Text(value) => form.push((name, value)),
}
}
request_builder.form(&form)?.send().map_err(Into::into)
}
request_builder.form(&form)?.send()?

send_form(request_builder, &request.headers, form_body)?
}
}
} else {
Expand Down Expand Up @@ -176,14 +231,61 @@ impl Client {
Body::Text(text) => request_builder.body(bytes::Bytes::from(text)),
Body::Json(json) => request_builder.json(&json),
Body::Form(form_body) => {
let mut form = Vec::new();
for (name, part) in form_body.0 {
match part {
FormPart::Bytes(bytes) => form.push((name, serde_json::to_string(&bytes)?)),
FormPart::Text(text) => form.push((name, text)),
#[allow(unused_variables)]
fn send_form(
request_builder: reqwest::RequestBuilder,
headers: &Option<HeaderMap>,
form_body: FormBody,
) -> crate::api::Result<reqwest::RequestBuilder> {
#[cfg(feature = "http-multipart")]
if matches!(
headers
.as_ref()
.and_then(|h| h.0.get("content-type"))
.map(|v| v.as_bytes()),
Some(b"multipart/form-data")
) {
let mut multipart = reqwest::multipart::Form::new();

for (name, part) in form_body.0 {
let part = match part {
FormPart::File {
file,
mime,
file_name,
} => {
let bytes: Vec<u8> = file.try_into()?;
let mut part = reqwest::multipart::Part::bytes(bytes);
if let Some(mime) = mime {
part = part.mime_str(&mime)?;
}
if let Some(file_name) = file_name {
part = part.file_name(file_name);
}
part
}
FormPart::Text(value) => reqwest::multipart::Part::text(value),
};

multipart = multipart.part(name, part);
}

return Ok(request_builder.multipart(multipart));
}

let mut form = Vec::new();
for (name, part) in form_body.0 {
match part {
FormPart::File { file, .. } => {
let bytes: Vec<u8> = file.try_into()?;
form.push((name, serde_json::to_string(&bytes)?))
}
FormPart::Text(value) => form.push((name, value)),
}
}
Ok(request_builder.form(&form))
}
request_builder.form(&form)
send_form(request_builder, &request.headers, form_body)?
}
};
}
Expand Down Expand Up @@ -216,20 +318,52 @@ pub enum ResponseType {
Binary,
}

/// A file path or contents.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum FilePart {
/// File path.
Path(PathBuf),
/// File contents.
Contents(Vec<u8>),
}

impl TryFrom<FilePart> for Vec<u8> {
type Error = crate::api::Error;
fn try_from(file: FilePart) -> crate::api::Result<Self> {
let bytes = match file {
FilePart::Path(path) => std::fs::read(&path)?,
FilePart::Contents(bytes) => bytes,
};
Ok(bytes)
}
}

/// [`FormBody`] data types.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum FormPart {
/// A string value.
Text(String),
/// A byte array value.
Bytes(Vec<u8>),
/// A file value.
#[serde(rename_all = "camelCase")]
File {
/// File path or content.
file: FilePart,
/// Mime type of this part.
/// Only used when the `Content-Type` header is set to `multipart/form-data`.
mime: Option<String>,
/// File name.
/// Only used when the `Content-Type` header is set to `multipart/form-data`.
file_name: Option<String>,
},
}

/// Form body definition.
#[derive(Debug, Deserialize)]
pub struct FormBody(HashMap<String, FormPart>);
pub struct FormBody(pub(crate) HashMap<String, FormPart>);

impl FormBody {
/// Creates a new form body.
Expand All @@ -243,7 +377,7 @@ impl FormBody {
#[serde(tag = "type", content = "payload")]
#[non_exhaustive]
pub enum Body {
/// A multipart formdata body.
/// A form body.
Form(FormBody),
/// A JSON body.
Json(Value),
Expand Down
26 changes: 19 additions & 7 deletions core/tauri/src/endpoints/http.rs
Expand Up @@ -79,19 +79,31 @@ impl Cmd {
options: Box<HttpRequestBuilder>,
) -> super::Result<ResponseData> {
use crate::Manager;
if context
.window
.state::<crate::Scopes>()
.http
.is_allowed(&options.url)
{
let scopes = context.window.state::<crate::Scopes>();
if scopes.http.is_allowed(&options.url) {
let client = clients()
.lock()
.unwrap()
.get(&client_id)
.ok_or_else(|| crate::Error::HttpClientNotInitialized.into_anyhow())?
.clone();
let response = client.send(*options).await?;
let options = *options;
if let Some(crate::api::http::Body::Form(form)) = &options.body {
for value in form.0.values() {
if let crate::api::http::FormPart::File {
file: crate::api::http::FilePart::Path(path),
..
} = value
{
if crate::api::file::SafePathBuf::new(path.clone()).is_err()
|| scopes.fs.is_allowed(&path)
{
return Err(crate::Error::PathNotAllowed(path.clone()).into_anyhow());
}
}
}
}
let response = client.send(options).await?;
Ok(response.read().await?)
} else {
Err(crate::Error::UrlNotAllowed(options.url).into_anyhow())
Expand Down
51 changes: 26 additions & 25 deletions examples/api/dist/assets/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions examples/api/dist/assets/vendor.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 1397d91

Please sign in to comment.