From 3913c2c8afb34fac59360dd5ef1c27acfcc621ea Mon Sep 17 00:00:00 2001 From: JustFrederik <121750393+JustFrederik@users.noreply.github.com> Date: Thu, 1 Feb 2024 12:17:45 +0100 Subject: [PATCH] multipart & json feature (#47) --- Cargo.lock | 56 +++++++++++++ ehttp/Cargo.toml | 22 ++++- ehttp/src/lib.rs | 3 + ehttp/src/multipart.rs | 180 +++++++++++++++++++++++++++++++++++++++++ ehttp/src/types.rs | 54 +++++++++++++ 5 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 ehttp/src/multipart.rs diff --git a/Cargo.lock b/Cargo.lock index 0a57f52..ecfe23b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -848,7 +848,13 @@ dependencies = [ "async-channel", "document-features", "futures-util", + "getrandom", "js-sys", + "mime", + "mime_guess", + "rand", + "serde", + "serde_json", "ureq", "wasm-bindgen", "wasm-bindgen-futures", @@ -1155,8 +1161,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1347,6 +1355,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + [[package]] name = "jni" version = "0.21.1" @@ -1521,6 +1535,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.7.1" @@ -2069,6 +2099,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "ryu" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" + [[package]] name = "same-file" version = "1.0.6" @@ -2133,6 +2169,17 @@ dependencies = [ "syn 2.0.43", ] +[[package]] +name = "serde_json" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0652c533506ad7a2e353cce269330d6afd8bdfb6d75e0ace5b35aacbd7b9e9" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.17" @@ -2432,6 +2479,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.14" diff --git a/ehttp/Cargo.toml b/ehttp/Cargo.toml index 6c28631..4889f96 100644 --- a/ehttp/Cargo.toml +++ b/ehttp/Cargo.toml @@ -17,19 +17,34 @@ include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"] all-features = true [features] -"default" = [] +default = [] ## Support `fetch_async` on native -"native-async" = ["async-channel"] +native-async = ["async-channel"] ## Support streaming fetch streaming = ["dep:wasm-streams", "dep:futures-util"] +## Support json fetch +json = ["dep:serde", "dep:serde_json"] + +## Support multipart fetch +multipart = ["dep:getrandom", "dep:mime", "dep:mime_guess", "dep:rand" ] + [lib] [dependencies] document-features = "0.2" +# Multipart request +mime = { version = "0.3", optional = true } +mime_guess = { version = "2.0", optional = true } +rand = { version = "0.8.5", optional = true } + +# Json request +serde = { version = "1.0", optional = true } +serde_json = { version = "1.0", optional = true } + # For compiling natively: [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # ureq = { version = "2.0", default-features = false, features = ["gzip", "tls_native_certs"] } @@ -42,6 +57,9 @@ js-sys = "0.3" wasm-bindgen = "0.2.89" wasm-bindgen-futures = "0.4" +# Multipart request +getrandom = { version = "0.2.10", features = ["js"], optional = true } + # Streaming response futures-util = { version = "0.3", optional = true } wasm-streams = { version = "0.4", optional = true } diff --git a/ehttp/src/lib.rs b/ehttp/src/lib.rs index ab89103..96a5796 100644 --- a/ehttp/src/lib.rs +++ b/ehttp/src/lib.rs @@ -88,6 +88,9 @@ pub use web::spawn_future; #[cfg(feature = "streaming")] pub mod streaming; +#[cfg(feature = "multipart")] +pub mod multipart; + #[deprecated = "Use ehttp::Headers::new"] pub fn headers(headers: &[(&str, &str)]) -> Headers { Headers::new(headers) diff --git a/ehttp/src/multipart.rs b/ehttp/src/multipart.rs new file mode 100644 index 0000000..831d3c7 --- /dev/null +++ b/ehttp/src/multipart.rs @@ -0,0 +1,180 @@ +//! Multipart HTTP request 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_text("label", "lorem ipsum") +//! .add_stream( +//! &mut Cursor::new(vec![0, 0, 0, 0]), +//! "4_empty_bytes", +//! Some("4_empty_bytes.png"), +//! None, +//! ) +//! .unwrap(), +//! ); +//! ehttp::fetch(request, |result| {}); +//! ``` +//! Taken from ureq_multipart 1.1.1 +//! + +use mime::Mime; +use rand::Rng; + +use std::fs::File; +use std::io::{self, Read, Write}; +use std::path::Path; + +const BOUNDARY_LEN: usize = 29; + +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)] +/// The Builder for the multipart +pub struct MultipartBuilder { + boundary: String, + inner: Vec, + data_written: bool, +} + +impl Default for MultipartBuilder { + fn default() -> Self { + Self::new() + } +} + +#[allow(dead_code)] +impl MultipartBuilder { + /// creates a new MultipartBuilder with empty inner + pub fn new() -> Self { + Self { + boundary: random_alphanumeric(BOUNDARY_LEN), + inner: Vec::new(), + data_written: false, + } + } + + /// add text field + /// + /// * name field name + /// * text field text value + 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 + /// + /// * name file field name + /// * path the sending file path + #[cfg(not(target_arch = "wasm32"))] + pub fn add_file>(self, name: &str, path: P) -> io::Result { + let path = path.as_ref(); + let (content_type, filename) = mime_filename(path); + let mut file = File::open(path)?; + self.add_stream(&mut file, name, filename, Some(content_type)) + } + + /// add some stream + pub fn add_stream( + mut self, + stream: &mut S, + name: &str, + filename: Option<&str>, + content_type: Option, + ) -> io::Result { + // 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)?; + Ok(self) + } + + fn write_boundary(&mut self) { + if self.data_written { + self.inner.write_all(b"\r\n").unwrap(); + } + + write!( + self.inner, + "-----------------------------{}\r\n", + self.boundary + ) + .unwrap() + } + + fn write_field_headers( + &mut self, + name: &str, + filename: Option<&str>, + content_type: Option, + ) { + self.write_boundary(); + if !self.data_written { + self.data_written = true; + } + write!( + self.inner, + "Content-Disposition: form-data; name=\"{name}\"" + ) + .unwrap(); + if let Some(filename) = filename { + write!(self.inner, "; filename=\"{filename}\"").unwrap(); + } + if let Some(content_type) = content_type { + write!(self.inner, "\r\nContent-Type: {content_type}").unwrap(); + } + self.inner.write_all(b"\r\n\r\n").unwrap(); + } + + /// general multipart data + /// + /// # Return + /// * (content_type,post_data) + /// * content_type http header content type + /// * post_data ureq.req.send_send_bytes(&post_data) + /// + pub fn finish(mut self) -> (String, Vec) { + if self.data_written { + self.inner.write_all(b"\r\n").unwrap(); + } + + // always write the closing boundary, even for empty bodies + write!( + self.inner, + "-----------------------------{}--\r\n", + self.boundary + ) + .unwrap(); + ( + format!( + "multipart/form-data; boundary=---------------------------{}", + self.boundary + ), + self.inner, + ) + } +} diff --git a/ehttp/src/types.rs b/ehttp/src/types.rs index 63ad677..729a851 100644 --- a/ehttp/src/types.rs +++ b/ehttp/src/types.rs @@ -1,3 +1,9 @@ +#[cfg(feature = "json")] +use serde::Serialize; + +#[cfg(feature = "multipart")] +use crate::multipart::MultipartBuilder; + /// Headers in a [`Request`] or [`Response`]. /// /// Note that the same header key can appear twice. @@ -139,6 +145,54 @@ impl Request { ]), } } + + /// 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_text("label", "lorem ipsum") + /// .add_stream( + /// &mut Cursor::new(vec![0, 0, 0, 0]), + /// "4_empty_bytes", + /// Some("4_empty_bytes.png"), + /// None, + /// ) + /// .unwrap(), + /// ); + /// ehttp::fetch(request, |result| {}); + #[cfg(feature = "multipart")] + pub fn multipart(url: impl ToString, builder: MultipartBuilder) -> Self { + let (content_type, data) = builder.finish(); + Self { + method: "POST".to_string(), + url: url.to_string(), + body: data, + headers: Headers::new(&[("Accept", "*/*"), ("Content-Type", content_type.as_str())]), + } + } + + #[cfg(feature = "json")] + /// Create a `POST` request with the given url and json body. + #[allow(clippy::needless_pass_by_value)] + pub fn json(url: impl ToString, body: &T) -> serde_json::error::Result + where + T: ?Sized + Serialize, + { + Ok(Self { + method: "POST".to_owned(), + url: url.to_string(), + body: serde_json::to_string(body)?.into_bytes(), + headers: Headers::new(&[("Accept", "*/*"), ("Content-Type", "application/json")]), + }) + } } /// Response from a completed HTTP request.