Skip to content

Commit

Permalink
# add post multipart
Browse files Browse the repository at this point in the history
  • Loading branch information
tmtbe committed Dec 7, 2023
1 parent e1da30b commit 29c88d6
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 14 deletions.
35 changes: 33 additions & 2 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ You can communicate the results back to the main thread using something like:
* [`eventuals::Eventual`](https://docs.rs/eventuals/latest/eventuals/struct.Eventual.html)
* [`tokio::sync::watch::channel`](https://docs.rs/tokio/latest/tokio/sync/watch/fn.channel.html)

Multipart requests need to be enabled with the `multipart` feature flag and can be used with `ehttp::Request::multipart`.

There is also a streaming version under `ehttp::fetch::streaming`, hidden behind the `streaming` feature flag.
8 changes: 8 additions & 0 deletions ehttp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,17 @@ all-features = true
## Support streaming fetch
streaming = ["dep:wasm-streams", "dep:futures-util"]

## Support multipart fetch
multipart = ["dep:rand", "dep:mime", "dep:mime_guess", "dep:getrandom"]

[lib]

[dependencies]
document-features = "0.2"
# For multipart request
mime = { version = "0.3", optional = true }
rand = { version = "0.8", optional = true }
mime_guess = { version = "2.0", optional = true }

# For compiling natively:
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
Expand All @@ -41,6 +48,7 @@ async-channel = { version = "1.8", optional = true }
js-sys = "0.3"
wasm-bindgen = "0.2.87"
wasm-bindgen-futures = "0.4"
getrandom = { version = "0.2.10", features = ["js"], optional = true }

# Streaming response
futures-util = { version = "0.3", optional = true }
Expand Down
2 changes: 2 additions & 0 deletions ehttp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ mod web;
#[cfg(target_arch = "wasm32")]
pub use web::spawn_future;

#[cfg(feature = "multipart")]
pub mod multipart;
#[cfg(feature = "streaming")]
pub mod streaming;

Expand Down
145 changes: 145 additions & 0 deletions ehttp/src/multipart.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
use mime::Mime;
use rand::Rng;
use std::fmt::format;
use std::fs::File;
use std::io::{self, Read};
use std::path::Path;

const BOUNDARY_LEN: usize = 29;

#[macro_export]
macro_rules! extend {
($dst:expr, $($arg:tt)*) => {
$dst.extend(format_args!($($arg)*).to_string().as_bytes().to_vec())
};
}

fn opt_filename(path: &Path) -> Option<&str> {
path.file_name().and_then(|filename| filename.to_str())
}

fn random_alphanumeric(len: usize) -> String {
rand::thread_rng()
.sample_iter(&rand::distributions::Uniform::from(0..=9))
.take(len)
.map(|num| num.to_string())
.collect()
}

fn mime_filename(path: &Path) -> (Mime, Option<&str>) {
let content_type = mime_guess::from_path(path);
let filename = opt_filename(path);
(content_type.first_or_octet_stream(), filename)
}

#[derive(Debug)]
pub struct MultipartBuilder {
boundary: String,
inner: Vec<u8>,
data_written: bool,
}

impl Default for MultipartBuilder {
fn default() -> Self {
Self::new()
}
}

#[allow(dead_code)]
impl MultipartBuilder {
/// New MultipartBuilder
pub fn new() -> Self {
Self {
boundary: random_alphanumeric(BOUNDARY_LEN),
inner: Vec::new(),
data_written: false,
}
}

/// Add text field
pub fn add_text(mut self, name: &str, text: &str) -> Self {
self.write_field_headers(name, None, None);
self.inner.extend(text.as_bytes());
self
}

/// Add file
pub fn add_file<P: AsRef<Path>>(self, name: &str, path: P) -> Self {
let path = path.as_ref();
let (content_type, filename) = mime_filename(path);
let mut file = File::open(path).expect(format!("open {:?} error", filename).as_str());
self.add_stream(&mut file, name, filename, Some(content_type))
}

/// Add some stream
pub fn add_stream<S: Read>(
mut self,
stream: &mut S,
name: &str,
filename: Option<&str>,
content_type: Option<Mime>,
) -> Self {
// This is necessary to make sure it is interpreted as a file on the server end.
let content_type = Some(content_type.unwrap_or(mime::APPLICATION_OCTET_STREAM));
self.write_field_headers(name, filename, content_type);
io::copy(stream, &mut self.inner).expect("add_stream io copy error");
self
}

fn write_boundary(&mut self) {
if self.data_written {
self.inner.extend(b"\r\n");
}

extend!(
self.inner,
"-----------------------------{}\r\n",
self.boundary
)
}

fn write_field_headers(
&mut self,
name: &str,
filename: Option<&str>,
content_type: Option<Mime>,
) {
self.write_boundary();
if !self.data_written {
self.data_written = true;
}
extend!(
self.inner,
"Content-Disposition: form-data; name=\"{name}\""
);

if let Some(filename) = filename {
extend!(self.inner, "; filename=\"{filename}\"");
}
if let Some(content_type) = content_type {
extend!(self.inner, "\r\nContent-Type: {content_type}");
}
self.inner.extend(b"\r\n\r\n")
}

/// Build multipart data
pub fn build(mut self) -> (String, Vec<u8>) {
if self.data_written {
self.inner.extend(b"\r\n");
}

// always write the closing boundary, even for empty bodies
extend!(
self.inner,
"-----------------------------{}--\r\n",
self.boundary
);
(
format!(
"multipart/form-data; boundary=---------------------------{}",
self.boundary
),
self.inner,
)
}
}
33 changes: 33 additions & 0 deletions ehttp/src/types.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#[cfg(feature = "multipart")]
use crate::multipart::MultipartBuilder;

/// A simple HTTP request.
#[derive(Clone, Debug)]
pub struct Request {
Expand Down Expand Up @@ -40,6 +43,36 @@ impl Request {
self.headers.push((key, value))
}

/// Multipart HTTP for both native and WASM.
///
/// Requires the `multipart` feature to be enabled.
///
/// Example:
/// ```
/// use std::io::Cursor;
/// use ehttp::multipart::MultipartBuilder;
/// let url = "https://www.example.com";
/// let request = ehttp::Request::multipart(url, MultipartBuilder::new()
/// .add_file("image", "/home/user/image.png")
/// .add_text("label", "lorem ipsum")
/// .add_stream(&mut Cursor::new(vec![0,0,0,0]),
/// "4_empty_bytes",
/// Some("4_empty_bytes.png"),
/// None));
/// ehttp::fetch(request, |result| {
///
/// });
#[cfg(feature = "multipart")]
pub fn multipart(url: impl ToString, builder: MultipartBuilder) -> Self {
let (content_type, data) = builder.build();
Self {
method: "POST".to_string(),
url: url.to_string(),
body: data,
headers: crate::headers(&[("Accept", "*/*"), ("Content-Type", &*content_type)]),
}
}

/// Create a `POST` request with the given url and body.
#[allow(clippy::needless_pass_by_value)]
pub fn post(url: impl ToString, body: Vec<u8>) -> Self {
Expand Down
2 changes: 1 addition & 1 deletion example_eframe/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ publish = false
crate-type = ["cdylib", "rlib"]

[dependencies]
ehttp = { path = "../ehttp", features = ["streaming"] }
ehttp = { path = "../ehttp", features = ["streaming","multipart"] }
eframe = "0.22"
log = "0.4"

Expand Down
Loading

0 comments on commit 29c88d6

Please sign in to comment.