Skip to content

Commit dbe7202

Browse files
committed
Multipart form support
1 parent e846669 commit dbe7202

6 files changed

Lines changed: 277 additions & 4 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ exclude = ["/cargo_deny.sh", "/deny.toml", "/test.sh"]
1515
rust-version = "1.71.1"
1616

1717
[package.metadata.docs.rs]
18-
features = ["rustls", "platform-verifier", "native-tls", "socks-proxy", "cookies", "gzip", "brotli", "charset", "json", "_test", "_doc"]
18+
features = ["rustls", "platform-verifier", "native-tls", "socks-proxy", "cookies", "gzip", "brotli", "charset", "json", "multipart", "_test", "_doc"]
1919

2020
[features]
2121
default = ["rustls", "gzip"]
@@ -31,6 +31,7 @@ gzip = ["dep:flate2"]
3131
brotli = ["dep:brotli-decompressor"]
3232
charset = ["dep:encoding_rs"]
3333
json = ["dep:serde", "dep:serde_json", "cookie_store?/serde_json"]
34+
multipart = ["dep:mime_guess", "dep:fastrand"]
3435

3536
######## UNSTABLE FEATURES.
3637
# Might be removed or changed in a minor version.
@@ -89,6 +90,9 @@ encoding_rs = { version = "0.8.34", optional = true }
8990
serde = { version = "1.0.138", optional = true, default-features = false, features = ["std"] }
9091
serde_json = { version = "1.0.120", optional = true, default-features = false, features = ["std"] }
9192

93+
mime_guess = { version = "2.0.5", optional = true }
94+
fastrand = { version = "2.3.0", optional = true }
95+
9296
[dev-dependencies]
9397
env_logger = "0.11.7"
9498
auto-args = "0.3.0"

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,14 +143,15 @@ The default enabled features are: **rustls** and **gzip**.
143143
* **platform-verifier** enables verifying the server certificates using a method native to the
144144
platform ureq is executing on. See [rustls-platform-verifier] crate
145145
* **socks-proxy** enables proxy config using the `socks4://`, `socks4a://`, `socks5://`
146-
and `socks://` (equal to `socks5://`) prefix
146+
and `socks://` (equal to `socks5://`) prefix
147147
* **cookies** enables cookies
148148
* **gzip** enables requests of gzip-compressed responses and decompresses them
149149
* **brotli** enables requests brotli-compressed responses and decompresses them
150150
* **charset** enables interpreting the charset part of the Content-Type header
151-
(e.g. `Content-Type: text/plain; charset=iso-8859-1`). Without this, the
152-
library defaults to Rust's built in `utf-8`
151+
(e.g. `Content-Type: text/plain; charset=iso-8859-1`). Without this, the
152+
library defaults to Rust's built in `utf-8`
153153
* **json** enables JSON sending and receiving via serde_json
154+
* **multipart** enables multipart/form-data sending via [`multipart::Form`]
154155

155156
#### Unstable
156157

src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@
155155
//! (e.g. `Content-Type: text/plain; charset=iso-8859-1`). Without this, the
156156
//! library defaults to Rust's built in `utf-8`
157157
//! * **json** enables JSON sending and receiving via serde_json
158+
//! * **multipart** enables multipart/form-data sending via [`multipart::Form`]
158159
//!
159160
//! ### Unstable
160161
//!
@@ -564,6 +565,9 @@ mod request_ext;
564565
#[cfg(feature = "cookies")]
565566
pub use cookies::{Cookie, CookieJar};
566567

568+
#[cfg(feature = "multipart")]
569+
pub mod multipart;
570+
567571
pub use agent::Agent;
568572
pub use error::Error;
569573
pub use send_body::SendBody;

src/multipart.rs

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
//! Multipart support.
2+
3+
use mime_guess::Mime;
4+
use ureq_proto::http;
5+
6+
use crate::{util::private::Private, AsSendBody, SendBody};
7+
use std::io::{self, Read};
8+
use std::path::Path;
9+
10+
const BOUNDARY_PREFIX: &str = "----formdata-ureq-";
11+
const BOUNDARY_SUFFIX_LEN: usize = 16;
12+
13+
/// A multipart/form-data request.
14+
///
15+
/// Use this to send multipart form data, which is commonly used for file uploads
16+
/// and forms with mixed content types.
17+
///
18+
/// # Examples
19+
///
20+
/// Basic usage with file upload:
21+
///
22+
/// ```
23+
/// # async fn no_run() -> Result<(), ureq::Error> {
24+
/// use ureq::multipart::Form;
25+
///
26+
/// let form = Form::new()
27+
/// .text("description", "My uploaded file")
28+
/// .file("upload", "path/to/file.txt").await?;
29+
///
30+
/// // Send the form as part of a POST request
31+
/// let response = ureq::post("http://httpbin.org/post")
32+
/// .send(form)?;
33+
/// # Ok(())}
34+
/// ```
35+
///
36+
/// Adding different types of parts:
37+
///
38+
/// ```
39+
/// # fn no_run() -> Result<(), ureq::Error> {
40+
/// use ureq::multipart::{Form, Part};
41+
///
42+
/// let data = b"binary data";
43+
/// let form = Form::new()
44+
/// .text("field1", "text value")
45+
/// .part("field2", Part::bytes(data))
46+
/// .part("field3", Part::text("another text").file_name("data.txt"));
47+
///
48+
/// let response = ureq::post("http://httpbin.org/post")
49+
/// .send(form)?;
50+
/// # Ok(())}
51+
/// ```
52+
pub struct Form<'a> {
53+
parts: Vec<(&'a str, Part<'a>)>,
54+
boundary: String,
55+
state: ReadState,
56+
}
57+
58+
/// A field in a multipart form.
59+
pub struct Part<'a> {
60+
inner: PartInner<'a>,
61+
meta: PartMeta,
62+
}
63+
64+
enum PartInner<'a> {
65+
Borrowed(SendBody<'a>),
66+
Owned(SendBody<'static>),
67+
}
68+
69+
struct PartMeta {
70+
mime: Option<Mime>,
71+
file_name: Option<String>,
72+
headers: http::HeaderMap,
73+
}
74+
75+
impl<'a> Form<'a> {
76+
/// Creates a new async Form without any content.
77+
pub fn new() -> Self {
78+
// Generate a random boundary using fastrand
79+
use std::iter::repeat_with;
80+
let mut boundary = String::with_capacity(BOUNDARY_PREFIX.len() + BOUNDARY_SUFFIX_LEN);
81+
boundary.push_str(BOUNDARY_PREFIX);
82+
boundary.extend(repeat_with(fastrand::alphanumeric).take(BOUNDARY_SUFFIX_LEN));
83+
84+
Form {
85+
parts: Vec::new(),
86+
boundary,
87+
state: ReadState::default(),
88+
}
89+
}
90+
91+
/// Get the boundary that this form will use.
92+
pub fn boundary(&self) -> &str {
93+
&self.boundary
94+
}
95+
96+
/// Add a data field with supplied name and value.
97+
pub fn text(mut self, name: &'a str, value: &'a str) -> Self {
98+
let part = Part::text(value);
99+
self.parts.push((name, part));
100+
self
101+
}
102+
103+
/// Adds a file field.
104+
pub async fn file<P: AsRef<Path>>(mut self, name: &'a str, path: P) -> std::io::Result<Self> {
105+
let part = Part::file(path).await?;
106+
self.parts.push((name, part));
107+
Ok(self)
108+
}
109+
110+
/// Adds a customized Part.
111+
pub fn part(mut self, name: &'a str, part: Part<'a>) -> Self {
112+
self.parts.push((name, part));
113+
self
114+
}
115+
}
116+
117+
impl<'a> Part<'a> {
118+
/// Create a text part.
119+
pub fn text(text: &'a str) -> Self {
120+
Part {
121+
inner: PartInner::Borrowed(SendBody::from_bytes(text.as_bytes())),
122+
meta: PartMeta {
123+
mime: None,
124+
file_name: None,
125+
headers: http::HeaderMap::new(),
126+
},
127+
}
128+
}
129+
130+
/// Create a part from bytes.
131+
pub fn bytes(bytes: &'a [u8]) -> Self {
132+
Part {
133+
inner: PartInner::Borrowed(SendBody::from_bytes(bytes)),
134+
meta: PartMeta {
135+
mime: None,
136+
file_name: None,
137+
headers: http::HeaderMap::new(),
138+
},
139+
}
140+
}
141+
142+
/// Create a part from a reader.
143+
pub fn reader(reader: &'a mut dyn Read) -> Self {
144+
Part {
145+
inner: PartInner::Borrowed(SendBody::from_reader(reader)),
146+
meta: PartMeta {
147+
mime: None,
148+
file_name: None,
149+
headers: http::HeaderMap::new(),
150+
},
151+
}
152+
}
153+
154+
/// Create a part from an owned reader.
155+
pub fn owned_reader(reader: impl Read + 'static) -> Part<'a> {
156+
Part {
157+
inner: PartInner::Owned(SendBody::from_owned_reader(reader)),
158+
meta: PartMeta {
159+
mime: None,
160+
file_name: None,
161+
headers: http::HeaderMap::new(),
162+
},
163+
}
164+
}
165+
166+
/// Create a part from a file.
167+
pub async fn file<P: AsRef<Path>>(path: P) -> std::io::Result<Part<'a>> {
168+
let mime = mime_guess::from_path(&path).first();
169+
let file_name = path
170+
.as_ref()
171+
.file_name()
172+
.map(|filename| filename.to_string_lossy().into_owned());
173+
let file = std::fs::File::open(path)?;
174+
Ok(Part {
175+
inner: PartInner::Owned(SendBody::from_file(file)),
176+
meta: PartMeta {
177+
mime,
178+
file_name,
179+
headers: http::HeaderMap::new(),
180+
},
181+
})
182+
}
183+
184+
/// Set the file name for this part.
185+
pub fn file_name(mut self, name: &str) -> Self {
186+
self.meta.file_name = Some(name.to_string());
187+
self
188+
}
189+
190+
/// Set the MIME type for this part.
191+
pub fn mime_str(mut self, mime: &str) -> Self {
192+
if let Ok(mime_type) = mime.parse() {
193+
self.meta.mime = Some(mime_type);
194+
}
195+
self
196+
}
197+
198+
/// Get the headers for this part.
199+
pub fn headers(&self) -> &http::HeaderMap {
200+
&self.meta.headers
201+
}
202+
}
203+
204+
impl<'a> Private for Form<'a> {}
205+
impl<'a> AsSendBody for Form<'a> {
206+
fn as_body(&mut self) -> SendBody {
207+
// TODO(martin): here we should be able to know the size of the body
208+
// and therefore use (some new) constructor in SendBody that sets the size.
209+
SendBody::from_reader(self)
210+
}
211+
}
212+
213+
#[derive(Default)]
214+
struct ReadState {
215+
// TODO(martin)
216+
}
217+
218+
impl io::Read for Form<'_> {
219+
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
220+
// TODO(martin): implement a streaming reader of the multipart body.
221+
todo!()
222+
}
223+
}

src/send_body.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ impl<'a> SendBody<'a> {
3838
(None, BodyInner::OwnedReader(Box::new(reader))).into()
3939
}
4040

41+
pub(crate) fn from_file(file: File) -> SendBody<'static> {
42+
let size = lazy_file_size(&file);
43+
SendBody {
44+
inner: BodyInner::OwnedReader(Box::new(file)),
45+
size: Some(size),
46+
ended: false,
47+
}
48+
}
49+
4150
/// Creates a body to send as JSON from any [`Serialize`](serde::ser::Serialize) value.
4251
#[cfg(feature = "json")]
4352
pub fn from_json(
@@ -143,6 +152,14 @@ impl<'a> SendBody<'a> {
143152
pub fn into_reader(self) -> impl Sized + io::Read + 'a {
144153
ReadAdapter(self)
145154
}
155+
156+
pub(crate) fn from_bytes<'b>(bytes: &'b [u8]) -> SendBody<'b> {
157+
SendBody {
158+
inner: BodyInner::ByteSlice(bytes),
159+
size: Some(Ok(bytes.len() as u64)),
160+
ended: false,
161+
}
162+
}
146163
}
147164

148165
struct ReadAdapter<'a>(SendBody<'a>);

0 commit comments

Comments
 (0)