Skip to content

Commit 2297d87

Browse files
committed
Add initial support for handling multipart/form-data body
1 parent f5bb417 commit 2297d87

File tree

11 files changed

+457
-36
lines changed

11 files changed

+457
-36
lines changed

test-programs/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ edition = "2021"
55
publish = false
66

77
[dependencies]
8-
waki = { path = "../waki", features = ["json"] }
8+
waki = { path = "../waki", features = ["json", "multipart"] }
99
serde = { workspace = true, features = ["derive"] }
10+
mime = "0.3.17"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
use serde::Deserialize;
2+
use std::time::Duration;
3+
use waki::Client;
4+
5+
#[derive(Deserialize)]
6+
struct Data {
7+
data: String,
8+
}
9+
10+
fn main() {
11+
let resp = Client::new()
12+
.post("https://httpbin.org/post")
13+
.body("hello")
14+
.connect_timeout(Duration::from_secs(5))
15+
.send()
16+
.unwrap();
17+
assert_eq!(resp.status_code(), 200);
18+
19+
let data = resp.json::<Data>().unwrap();
20+
assert_eq!(data.data, "hello");
21+
}
Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
use serde::Deserialize;
22
use std::collections::HashMap;
33
use std::time::Duration;
4-
use waki::Client;
4+
use waki::{
5+
multipart::{Form, Part},
6+
Client,
7+
};
58

69
#[derive(Deserialize)]
710
struct Data {
@@ -12,19 +15,16 @@ struct Data {
1215
fn main() {
1316
let resp = Client::new()
1417
.post("https://httpbin.org/post")
15-
.header("Content-Type", "multipart/form-data; boundary=boundary")
16-
.body(
17-
"--boundary
18-
Content-Disposition: form-data; name=field1
19-
20-
value1
21-
--boundary
22-
Content-Disposition: form-data; name=field2; filename=file.txt
23-
Content-Type: text/plain
24-
25-
hello
26-
--boundary--"
27-
.as_bytes(),
18+
.multipart(
19+
Form::new()
20+
.text("field1", "value1")
21+
.file("field2", "file.txt")
22+
.unwrap()
23+
.part(
24+
Part::new("field3", "hello")
25+
.filename("file.txt")
26+
.mime(mime::TEXT_PLAIN),
27+
),
2828
)
2929
.connect_timeout(Duration::from_secs(5))
3030
.send()
@@ -33,5 +33,6 @@ hello
3333

3434
let data = resp.json::<Data>().unwrap();
3535
assert_eq!(data.form.get("field1").unwrap(), "value1");
36-
assert_eq!(data.files.get("field2").unwrap(), "hello");
36+
assert_eq!(data.files.get("field2").unwrap(), "hello\n");
37+
assert_eq!(data.files.get("field3").unwrap(), "hello");
3738
}

waki/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,16 @@ wit-bindgen = "0.26.0"
2323
url = "2.5.0"
2424
serde_urlencoded = "0.7.1"
2525
serde_json = { version = "1.0.117", optional = true }
26+
mime = { version = "0.3.17", optional = true }
27+
mime_guess = { version = "2.0.4", optional = true }
28+
rand = { version = "0.8.5", optional = true }
29+
memchr = { version = "2.7.2", optional = true }
30+
bytes = { version = "1.6.0", optional = true }
31+
httparse = { version = "1.9.3", optional = true }
2632

2733
[features]
2834
json = ["dep:serde_json"]
35+
multipart = ["dep:mime", "dep:mime_guess", "dep:rand", "dep:memchr", "dep:bytes", "dep:httparse"]
2936

3037
[build-dependencies]
3138
anyhow.workspace = true

waki/src/lib.rs

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
mod body;
3636
mod client;
3737
mod header;
38+
#[cfg(feature = "multipart")]
39+
#[cfg_attr(docsrs, doc(cfg(feature = "multipart")))]
40+
pub mod multipart;
3841
mod request;
3942
mod response;
4043

@@ -73,7 +76,9 @@ pub use self::{
7376
pub use waki_macros::handler;
7477

7578
use crate::body::Body;
76-
use anyhow::Result;
79+
#[cfg(feature = "multipart")]
80+
use crate::multipart::{parser::parse, Form, Part};
81+
use anyhow::{anyhow, Result};
7782
use serde::Serialize;
7883
use std::collections::HashMap;
7984

@@ -129,6 +134,33 @@ macro_rules! impl_common_get_methods {
129134
pub fn json<T: serde::de::DeserializeOwned>(self) -> Result<T> {
130135
Ok(serde_json::from_slice(self.body()?.as_ref())?)
131136
}
137+
138+
/// Parse the body as multipart/form-data.
139+
///
140+
/// # Optional
141+
///
142+
/// This requires the `multipart` feature enabled.
143+
#[cfg(feature = "multipart")]
144+
#[cfg_attr(docsrs, doc(cfg(feature = "multipart")))]
145+
pub fn multipart(self) -> Result<HashMap<String, Part>> {
146+
match self.headers.get("Content-Type") {
147+
Some(header) => {
148+
let mime = header.parse::<mime::Mime>()?;
149+
let boundary = match mime.get_param(mime::BOUNDARY) {
150+
Some(v) => v.as_str(),
151+
None => {
152+
return Err(anyhow!(
153+
"unable to find the boundary value in the Content-Type header"
154+
))
155+
}
156+
};
157+
parse(self.body()?.as_ref(), boundary)
158+
}
159+
None => Err(anyhow!(
160+
"parse body as multipart failed, unable to find the Content-Type header"
161+
)),
162+
}
163+
}
132164
}
133165
)+)
134166
}
@@ -141,12 +173,10 @@ macro_rules! impl_common_set_methods {
141173
/// Add a header.
142174
///
143175
/// ```
144-
/// # use anyhow::Result;
145176
/// # use waki::ResponseBuilder;
146-
/// # fn run() -> Result<()> {
177+
/// # fn run() {
147178
/// # let r = ResponseBuilder::new();
148179
/// r.header("Content-Type", "application/json");
149-
/// # Ok(())
150180
/// # }
151181
/// ```
152182
pub fn header<S: Into<String>>(mut self, key: S, value: S) -> Self {
@@ -159,12 +189,10 @@ macro_rules! impl_common_set_methods {
159189
/// Add a set of headers.
160190
///
161191
/// ```
162-
/// # use anyhow::Result;
163192
/// # use waki::ResponseBuilder;
164-
/// # fn run() -> Result<()> {
193+
/// # fn run() {
165194
/// # let r = ResponseBuilder::new();
166195
/// r.headers([("Content-Type", "application/json"), ("Accept", "*/*")]);
167-
/// # Ok(())
168196
/// # }
169197
/// ```
170198
pub fn headers<S, I>(mut self, headers: I) -> Self
@@ -182,15 +210,13 @@ macro_rules! impl_common_set_methods {
182210
/// Set the body.
183211
///
184212
/// ```
185-
/// # use anyhow::Result;
186213
/// # use waki::ResponseBuilder;
187-
/// # fn run() -> Result<()> {
214+
/// # fn run() {
188215
/// # let r = ResponseBuilder::new();
189-
/// r.body("hello".as_bytes());
190-
/// # Ok(())
216+
/// r.body("hello");
191217
/// # }
192218
/// ```
193-
pub fn body(mut self, body: &[u8]) -> Self {
219+
pub fn body<V: Into<Vec<u8>>>(mut self, body: V) -> Self {
194220
if let Ok(ref mut inner) = self.inner {
195221
inner.body = Body::Bytes(body.into());
196222
}
@@ -204,13 +230,11 @@ macro_rules! impl_common_set_methods {
204230
/// This requires the `json` feature enabled.
205231
///
206232
/// ```
207-
/// # use anyhow::Result;
208233
/// # use std::collections::HashMap;
209234
/// # use waki::ResponseBuilder;
210-
/// # fn run() -> Result<()> {
235+
/// # fn run() {
211236
/// # let r = ResponseBuilder::new();
212237
/// r.json(&HashMap::from([("data", "hello")]));
213-
/// # Ok(())
214238
/// # }
215239
/// ```
216240
#[cfg(feature = "json")]
@@ -234,12 +258,10 @@ macro_rules! impl_common_set_methods {
234258
/// Set a form body.
235259
///
236260
/// ```
237-
/// # use anyhow::Result;
238261
/// # use waki::ResponseBuilder;
239-
/// # fn run() -> Result<()> {
262+
/// # fn run() {
240263
/// # let r = ResponseBuilder::new();
241264
/// r.form(&[("a", "b"), ("c", "d")]);
242-
/// # Ok(())
243265
/// # }
244266
/// ```
245267
pub fn form<T: Serialize + ?Sized>(mut self, form: &T) -> Self {
@@ -259,6 +281,46 @@ macro_rules! impl_common_set_methods {
259281
}
260282
self
261283
}
284+
285+
/// Set a multipart/form-data body.
286+
///
287+
/// # Optional
288+
///
289+
/// This requires the `multipart` feature enabled.
290+
///
291+
/// ```
292+
/// # use anyhow::Result;
293+
/// # use waki::ResponseBuilder;
294+
/// # fn run() -> Result<()> {
295+
/// # let r = ResponseBuilder::new();
296+
/// let form = waki::multipart::Form::new()
297+
/// // Add a text field
298+
/// .text("key", "value")
299+
/// // And a file
300+
/// .file("file", "/path/to/file.txt")?
301+
/// // And a custom part
302+
/// .part(
303+
/// waki::multipart::Part::new("key2", "value2")
304+
/// .filename("file.txt")
305+
/// .mime_str("text/plain")?,
306+
/// );
307+
///
308+
/// r.multipart(form);
309+
/// # Ok(())
310+
/// # }
311+
/// ```
312+
#[cfg(feature = "multipart")]
313+
#[cfg_attr(docsrs, doc(cfg(feature = "multipart")))]
314+
pub fn multipart(mut self, form: Form) -> Self {
315+
if let Ok(ref mut inner) = self.inner {
316+
inner.headers.insert(
317+
"Content-Type".into(),
318+
format!("multipart/form-data; boundary={}", form.boundary()),
319+
);
320+
inner.body = Body::Bytes(form.build());
321+
}
322+
self
323+
}
262324
}
263325
)+)
264326
}

waki/src/multipart/constants.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
pub const MAX_HEADERS: usize = 32;
2+
pub const BOUNDARY_EXT: &str = "--";
3+
pub const CRLF: &str = "\r\n";
4+
pub const CRLF_CRLF: &str = "\r\n\r\n";

0 commit comments

Comments
 (0)