Skip to content

Commit 1397d91

Browse files
authored
feat(core): add support to multipart/form-data requests, closes #2118 (#3929)
1 parent 8e00f09 commit 1397d91

24 files changed

Lines changed: 667 additions & 140 deletions

File tree

.changes/form-multipart-support.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tauri": patch
3+
---
4+
5+
The HTTP API now supports `multipart/form-data` requests. You need to set the `Content-Type` header and enable the `http-multipart` Cargo feature.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tauri": patch
3+
---
4+
5+
**Breaking change:** The `tauri::api::http::FormPart::Bytes` enum variant has been renamed to `File` with a value object `{ file, mime, file_name }`.

.github/workflows/lint-fmt-core.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ jobs:
5050
clippy:
5151
- { args: '', key: 'empty' }
5252
- {
53-
args: '--features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray',
53+
args: '--features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray,http-multipart',
5454
key: 'all'
5555
}
5656
- {

.github/workflows/test-core.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,4 @@ jobs:
8989
run: |
9090
cargo test
9191
cargo test --features api-all
92-
cargo test --features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray
92+
cargo test --features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray,http-multipart

.github/workflows/udeps.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
clippy:
3030
- {
3131
path: './core/tauri/Cargo.toml',
32-
args: '--features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray'
32+
args: '--features compression,wry,isolation,custom-protocol,api-all,cli,updater,system-tray,http-multipart'
3333
}
3434
- { path: './core/tauri-build/Cargo.toml', args: '--all-features' }
3535
- { path: './core/tauri-codegen/Cargo.toml', args: '--all-features' }

core/tauri/Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ features = [
2828
"__updater-docs",
2929
"system-tray",
3030
"devtools",
31+
"http-multipart",
3132
"dox"
3233
]
3334
rustdoc-args = [ "--cfg", "doc_cfg" ]
@@ -39,7 +40,7 @@ targets = [
3940
]
4041

4142
[package.metadata.cargo-udeps.ignore]
42-
normal = [ "attohttpc" ]
43+
normal = [ "attohttpc", "reqwest" ]
4344

4445
[dependencies]
4546
serde_json = { version = "1.0", features = [ "raw_value" ] }
@@ -72,7 +73,7 @@ percent-encoding = "2.1"
7273
base64 = { version = "0.13", optional = true }
7374
clap = { version = "3", optional = true }
7475
notify-rust = { version = "4.5", optional = true }
75-
reqwest = { version = "0.11", features = [ "json", "multipart", "stream" ], optional = true }
76+
reqwest = { version = "0.11", features = [ "json", "stream" ], optional = true }
7677
bytes = { version = "1", features = [ "serde" ], optional = true }
7778
attohttpc = { version = "0.19", features = [ "json", "form" ], optional = true }
7879
open = { version = "2.0", optional = true }
@@ -134,6 +135,7 @@ updater = [
134135
]
135136
__updater-docs = [ "minisign-verify", "base64", "http-api", "dialog-ask" ]
136137
http-api = [ "attohttpc" ]
138+
http-multipart = [ "attohttpc/multipart-form", "reqwest/multipart" ]
137139
shell-open-api = [ "open", "regex", "tauri-macros/shell-scope" ]
138140
fs-extract-api = [ "zip" ]
139141
reqwest-client = [ "reqwest", "bytes" ]

core/tauri/scripts/bundle.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/tauri/src/api/http.rs

Lines changed: 152 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use serde_json::Value;
1111
use serde_repr::{Deserialize_repr, Serialize_repr};
1212
use url::Url;
1313

14-
use std::{collections::HashMap, time::Duration};
14+
use std::{collections::HashMap, path::PathBuf, time::Duration};
1515

1616
#[cfg(feature = "reqwest-client")]
1717
pub use reqwest::header;
@@ -114,7 +114,7 @@ impl Client {
114114
request_builder = request_builder.params(&query);
115115
}
116116

117-
if let Some(headers) = request.headers {
117+
if let Some(headers) = &request.headers {
118118
for (name, value) in headers.0.iter() {
119119
request_builder = request_builder.header(name, value);
120120
}
@@ -130,14 +130,69 @@ impl Client {
130130
Body::Text(text) => request_builder.body(attohttpc::body::Bytes(text)).send()?,
131131
Body::Json(json) => request_builder.json(&json)?.send()?,
132132
Body::Form(form_body) => {
133-
let mut form = Vec::new();
134-
for (name, part) in form_body.0 {
135-
match part {
136-
FormPart::Bytes(bytes) => form.push((name, serde_json::to_string(&bytes)?)),
137-
FormPart::Text(text) => form.push((name, text)),
133+
#[allow(unused_variables)]
134+
fn send_form(
135+
request_builder: attohttpc::RequestBuilder,
136+
headers: &Option<HeaderMap>,
137+
form_body: FormBody,
138+
) -> crate::api::Result<attohttpc::Response> {
139+
#[cfg(feature = "http-multipart")]
140+
if matches!(
141+
headers
142+
.as_ref()
143+
.and_then(|h| h.0.get("content-type"))
144+
.map(|v| v.as_bytes()),
145+
Some(b"multipart/form-data")
146+
) {
147+
let mut multipart = attohttpc::MultipartBuilder::new();
148+
let mut byte_cache: HashMap<String, Vec<u8>> = Default::default();
149+
150+
for (name, part) in &form_body.0 {
151+
if let FormPart::File { file, .. } = part {
152+
byte_cache.insert(name.to_string(), file.clone().try_into()?);
153+
}
154+
}
155+
for (name, part) in &form_body.0 {
156+
multipart = match part {
157+
FormPart::File {
158+
file,
159+
mime,
160+
file_name,
161+
} => {
162+
// safe to unwrap: always set by previous loop
163+
let mut file =
164+
attohttpc::MultipartFile::new(name, byte_cache.get(name).unwrap());
165+
if let Some(mime) = mime {
166+
file = file.with_type(mime)?;
167+
}
168+
if let Some(file_name) = file_name {
169+
file = file.with_filename(file_name);
170+
}
171+
multipart.with_file(file)
172+
}
173+
FormPart::Text(value) => multipart.with_text(name, value),
174+
};
175+
}
176+
return request_builder
177+
.body(multipart.build()?)
178+
.send()
179+
.map_err(Into::into);
180+
}
181+
182+
let mut form = Vec::new();
183+
for (name, part) in form_body.0 {
184+
match part {
185+
FormPart::File { file, .. } => {
186+
let bytes: Vec<u8> = file.try_into()?;
187+
form.push((name, serde_json::to_string(&bytes)?))
188+
}
189+
FormPart::Text(value) => form.push((name, value)),
190+
}
138191
}
192+
request_builder.form(&form)?.send().map_err(Into::into)
139193
}
140-
request_builder.form(&form)?.send()?
194+
195+
send_form(request_builder, &request.headers, form_body)?
141196
}
142197
}
143198
} else {
@@ -176,14 +231,61 @@ impl Client {
176231
Body::Text(text) => request_builder.body(bytes::Bytes::from(text)),
177232
Body::Json(json) => request_builder.json(&json),
178233
Body::Form(form_body) => {
179-
let mut form = Vec::new();
180-
for (name, part) in form_body.0 {
181-
match part {
182-
FormPart::Bytes(bytes) => form.push((name, serde_json::to_string(&bytes)?)),
183-
FormPart::Text(text) => form.push((name, text)),
234+
#[allow(unused_variables)]
235+
fn send_form(
236+
request_builder: reqwest::RequestBuilder,
237+
headers: &Option<HeaderMap>,
238+
form_body: FormBody,
239+
) -> crate::api::Result<reqwest::RequestBuilder> {
240+
#[cfg(feature = "http-multipart")]
241+
if matches!(
242+
headers
243+
.as_ref()
244+
.and_then(|h| h.0.get("content-type"))
245+
.map(|v| v.as_bytes()),
246+
Some(b"multipart/form-data")
247+
) {
248+
let mut multipart = reqwest::multipart::Form::new();
249+
250+
for (name, part) in form_body.0 {
251+
let part = match part {
252+
FormPart::File {
253+
file,
254+
mime,
255+
file_name,
256+
} => {
257+
let bytes: Vec<u8> = file.try_into()?;
258+
let mut part = reqwest::multipart::Part::bytes(bytes);
259+
if let Some(mime) = mime {
260+
part = part.mime_str(&mime)?;
261+
}
262+
if let Some(file_name) = file_name {
263+
part = part.file_name(file_name);
264+
}
265+
part
266+
}
267+
FormPart::Text(value) => reqwest::multipart::Part::text(value),
268+
};
269+
270+
multipart = multipart.part(name, part);
271+
}
272+
273+
return Ok(request_builder.multipart(multipart));
184274
}
275+
276+
let mut form = Vec::new();
277+
for (name, part) in form_body.0 {
278+
match part {
279+
FormPart::File { file, .. } => {
280+
let bytes: Vec<u8> = file.try_into()?;
281+
form.push((name, serde_json::to_string(&bytes)?))
282+
}
283+
FormPart::Text(value) => form.push((name, value)),
284+
}
285+
}
286+
Ok(request_builder.form(&form))
185287
}
186-
request_builder.form(&form)
288+
send_form(request_builder, &request.headers, form_body)?
187289
}
188290
};
189291
}
@@ -216,20 +318,52 @@ pub enum ResponseType {
216318
Binary,
217319
}
218320

321+
/// A file path or contents.
322+
#[derive(Debug, Clone, Deserialize)]
323+
#[serde(untagged)]
324+
#[non_exhaustive]
325+
pub enum FilePart {
326+
/// File path.
327+
Path(PathBuf),
328+
/// File contents.
329+
Contents(Vec<u8>),
330+
}
331+
332+
impl TryFrom<FilePart> for Vec<u8> {
333+
type Error = crate::api::Error;
334+
fn try_from(file: FilePart) -> crate::api::Result<Self> {
335+
let bytes = match file {
336+
FilePart::Path(path) => std::fs::read(&path)?,
337+
FilePart::Contents(bytes) => bytes,
338+
};
339+
Ok(bytes)
340+
}
341+
}
342+
219343
/// [`FormBody`] data types.
220344
#[derive(Debug, Deserialize)]
221345
#[serde(untagged)]
222346
#[non_exhaustive]
223347
pub enum FormPart {
224348
/// A string value.
225349
Text(String),
226-
/// A byte array value.
227-
Bytes(Vec<u8>),
350+
/// A file value.
351+
#[serde(rename_all = "camelCase")]
352+
File {
353+
/// File path or content.
354+
file: FilePart,
355+
/// Mime type of this part.
356+
/// Only used when the `Content-Type` header is set to `multipart/form-data`.
357+
mime: Option<String>,
358+
/// File name.
359+
/// Only used when the `Content-Type` header is set to `multipart/form-data`.
360+
file_name: Option<String>,
361+
},
228362
}
229363

230364
/// Form body definition.
231365
#[derive(Debug, Deserialize)]
232-
pub struct FormBody(HashMap<String, FormPart>);
366+
pub struct FormBody(pub(crate) HashMap<String, FormPart>);
233367

234368
impl FormBody {
235369
/// Creates a new form body.
@@ -243,7 +377,7 @@ impl FormBody {
243377
#[serde(tag = "type", content = "payload")]
244378
#[non_exhaustive]
245379
pub enum Body {
246-
/// A multipart formdata body.
380+
/// A form body.
247381
Form(FormBody),
248382
/// A JSON body.
249383
Json(Value),

core/tauri/src/endpoints/http.rs

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,19 +79,31 @@ impl Cmd {
7979
options: Box<HttpRequestBuilder>,
8080
) -> super::Result<ResponseData> {
8181
use crate::Manager;
82-
if context
83-
.window
84-
.state::<crate::Scopes>()
85-
.http
86-
.is_allowed(&options.url)
87-
{
82+
let scopes = context.window.state::<crate::Scopes>();
83+
if scopes.http.is_allowed(&options.url) {
8884
let client = clients()
8985
.lock()
9086
.unwrap()
9187
.get(&client_id)
9288
.ok_or_else(|| crate::Error::HttpClientNotInitialized.into_anyhow())?
9389
.clone();
94-
let response = client.send(*options).await?;
90+
let options = *options;
91+
if let Some(crate::api::http::Body::Form(form)) = &options.body {
92+
for value in form.0.values() {
93+
if let crate::api::http::FormPart::File {
94+
file: crate::api::http::FilePart::Path(path),
95+
..
96+
} = value
97+
{
98+
if crate::api::file::SafePathBuf::new(path.clone()).is_err()
99+
|| scopes.fs.is_allowed(&path)
100+
{
101+
return Err(crate::Error::PathNotAllowed(path.clone()).into_anyhow());
102+
}
103+
}
104+
}
105+
}
106+
let response = client.send(options).await?;
95107
Ok(response.read().await?)
96108
} else {
97109
Err(crate::Error::UrlNotAllowed(options.url).into_anyhow())

examples/api/dist/assets/index.js

Lines changed: 26 additions & 25 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)