From fba8d10337148bab9c82d8ba3d62a8295f07a594 Mon Sep 17 00:00:00 2001 From: Brooks Townsend Date: Fri, 17 May 2024 06:26:37 -0400 Subject: [PATCH 1/4] refactor(wasm): move wasm_bindgen support to js folder Signed-off-by: Brooks Townsend refactor(wasm): add wasm mod Signed-off-by: Brooks Townsend --- src/wasm/{ => js}/body.rs | 8 ++--- src/wasm/{ => js}/client.rs | 0 src/wasm/js/mod.rs | 53 ++++++++++++++++++++++++++++++++ src/wasm/{ => js}/multipart.rs | 6 ++-- src/wasm/{ => js}/request.rs | 0 src/wasm/{ => js}/response.rs | 2 +- src/wasm/mod.rs | 55 ++-------------------------------- 7 files changed, 63 insertions(+), 61 deletions(-) rename src/wasm/{ => js}/body.rs (96%) rename src/wasm/{ => js}/client.rs (100%) create mode 100644 src/wasm/js/mod.rs rename src/wasm/{ => js}/multipart.rs (97%) rename src/wasm/{ => js}/request.rs (100%) rename src/wasm/{ => js}/response.rs (99%) diff --git a/src/wasm/body.rs b/src/wasm/js/body.rs similarity index 96% rename from src/wasm/body.rs rename to src/wasm/js/body.rs index 241aa8173..fbc117b95 100644 --- a/src/wasm/body.rs +++ b/src/wasm/js/body.rs @@ -225,7 +225,7 @@ mod tests { let js_req = web_sys::Request::new_with_str_and_init("", &init) .expect("could not create JS request"); let text_promise = js_req.text().expect("could not get text promise"); - let text = crate::wasm::promise::(text_promise) + let text = crate::wasm::js::promise::(text_promise) .await .expect("could not get request body as text"); @@ -247,7 +247,7 @@ mod tests { let js_req = web_sys::Request::new_with_str_and_init("", &init) .expect("could not create JS request"); let text_promise = js_req.text().expect("could not get text promise"); - let text = crate::wasm::promise::(text_promise) + let text = crate::wasm::js::promise::(text_promise) .await .expect("could not get request body as text"); @@ -273,7 +273,7 @@ mod tests { let array_buffer_promise = js_req .array_buffer() .expect("could not get array_buffer promise"); - let array_buffer = crate::wasm::promise::(array_buffer_promise) + let array_buffer = crate::wasm::js::promise::(array_buffer_promise) .await .expect("could not get request body as array buffer"); @@ -301,7 +301,7 @@ mod tests { let array_buffer_promise = js_req .array_buffer() .expect("could not get array_buffer promise"); - let array_buffer = crate::wasm::promise::(array_buffer_promise) + let array_buffer = crate::wasm::js::promise::(array_buffer_promise) .await .expect("could not get request body as array buffer"); diff --git a/src/wasm/client.rs b/src/wasm/js/client.rs similarity index 100% rename from src/wasm/client.rs rename to src/wasm/js/client.rs diff --git a/src/wasm/js/mod.rs b/src/wasm/js/mod.rs new file mode 100644 index 000000000..e99fb11fb --- /dev/null +++ b/src/wasm/js/mod.rs @@ -0,0 +1,53 @@ +use wasm_bindgen::JsCast; +use web_sys::{AbortController, AbortSignal}; + +mod body; +mod client; +/// TODO +#[cfg(feature = "multipart")] +pub mod multipart; +mod request; +mod response; + +pub use self::body::Body; +pub use self::client::{Client, ClientBuilder}; +pub use self::request::{Request, RequestBuilder}; +pub use self::response::Response; + +async fn promise(promise: js_sys::Promise) -> Result +where + T: JsCast, +{ + use wasm_bindgen_futures::JsFuture; + + let js_val = JsFuture::from(promise).await.map_err(crate::error::wasm)?; + + js_val + .dyn_into::() + .map_err(|_js_val| "promise resolved to unexpected type".into()) +} + +/// A guard that cancels a fetch request when dropped. +struct AbortGuard { + ctrl: AbortController, +} + +impl AbortGuard { + fn new() -> crate::Result { + Ok(AbortGuard { + ctrl: AbortController::new() + .map_err(crate::error::wasm) + .map_err(crate::error::builder)?, + }) + } + + fn signal(&self) -> AbortSignal { + self.ctrl.signal() + } +} + +impl Drop for AbortGuard { + fn drop(&mut self) { + self.ctrl.abort(); + } +} diff --git a/src/wasm/multipart.rs b/src/wasm/js/multipart.rs similarity index 97% rename from src/wasm/multipart.rs rename to src/wasm/js/multipart.rs index 9b5b4c951..3d29a95b4 100644 --- a/src/wasm/multipart.rs +++ b/src/wasm/js/multipart.rs @@ -377,7 +377,7 @@ mod tests { let form_data_promise = js_req.form_data().expect("could not get form_data promise"); - let form_data = crate::wasm::promise::(form_data_promise) + let form_data = crate::wasm::js::promise::(form_data_promise) .await .expect("could not get body as form data"); @@ -387,7 +387,7 @@ mod tests { assert_eq!(text_file.type_(), text_file_type); let text_promise = text_file.text(); - let text = crate::wasm::promise::(text_promise) + let text = crate::wasm::js::promise::(text_promise) .await .expect("could not get text body as text"); assert_eq!( @@ -408,7 +408,7 @@ mod tests { assert_eq!(string, string_content); let binary_array_buffer_promise = binary_file.array_buffer(); - let array_buffer = crate::wasm::promise::(binary_array_buffer_promise) + let array_buffer = crate::wasm::js::promise::(binary_array_buffer_promise) .await .expect("could not get request body as array buffer"); diff --git a/src/wasm/request.rs b/src/wasm/js/request.rs similarity index 100% rename from src/wasm/request.rs rename to src/wasm/js/request.rs diff --git a/src/wasm/response.rs b/src/wasm/js/response.rs similarity index 99% rename from src/wasm/response.rs rename to src/wasm/js/response.rs index 47a90d04d..9a00f7356 100644 --- a/src/wasm/response.rs +++ b/src/wasm/js/response.rs @@ -5,7 +5,7 @@ use http::{HeaderMap, StatusCode}; use js_sys::Uint8Array; use url::Url; -use crate::wasm::AbortGuard; +use crate::wasm::js::AbortGuard; #[cfg(feature = "stream")] use wasm_bindgen::JsCast; diff --git a/src/wasm/mod.rs b/src/wasm/mod.rs index e99fb11fb..0bac9fe1a 100644 --- a/src/wasm/mod.rs +++ b/src/wasm/mod.rs @@ -1,53 +1,2 @@ -use wasm_bindgen::JsCast; -use web_sys::{AbortController, AbortSignal}; - -mod body; -mod client; -/// TODO -#[cfg(feature = "multipart")] -pub mod multipart; -mod request; -mod response; - -pub use self::body::Body; -pub use self::client::{Client, ClientBuilder}; -pub use self::request::{Request, RequestBuilder}; -pub use self::response::Response; - -async fn promise(promise: js_sys::Promise) -> Result -where - T: JsCast, -{ - use wasm_bindgen_futures::JsFuture; - - let js_val = JsFuture::from(promise).await.map_err(crate::error::wasm)?; - - js_val - .dyn_into::() - .map_err(|_js_val| "promise resolved to unexpected type".into()) -} - -/// A guard that cancels a fetch request when dropped. -struct AbortGuard { - ctrl: AbortController, -} - -impl AbortGuard { - fn new() -> crate::Result { - Ok(AbortGuard { - ctrl: AbortController::new() - .map_err(crate::error::wasm) - .map_err(crate::error::builder)?, - }) - } - - fn signal(&self) -> AbortSignal { - self.ctrl.signal() - } -} - -impl Drop for AbortGuard { - fn drop(&mut self) { - self.ctrl.abort(); - } -} +pub mod js; +pub use js::*; From fa45231476f3d4f4db96467ca985fd686fa57ace Mon Sep 17 00:00:00 2001 From: Brooks Townsend Date: Thu, 15 Aug 2024 12:55:14 -0400 Subject: [PATCH 2/4] chore: add .vscode to .gitignore Signed-off-by: Brooks Townsend --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a57891807..fe1ceca80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target Cargo.lock *.swp -.idea \ No newline at end of file +.idea +.vscode \ No newline at end of file From a5de2b01047622af6dccaaf489c35a62f77b20ca Mon Sep 17 00:00:00 2001 From: Brooks Townsend Date: Mon, 21 Oct 2024 16:01:44 -0400 Subject: [PATCH 3/4] feat(wasm): add wasm32-wasip2 stable support Signed-off-by: Brooks Townsend cleanup extraneous comment Signed-off-by: Brooks Townsend feat: give back incoming body too Signed-off-by: Brooks Townsend deps(url): remove patch Signed-off-by: Brooks Townsend --- Cargo.toml | 3 + src/lib.rs | 34 ++- src/wasm/component/body.rs | 111 +++++++++ src/wasm/component/client/mod.rs | 254 ++++++++++++++++++++ src/wasm/component/mod.rs | 9 + src/wasm/component/request.rs | 391 +++++++++++++++++++++++++++++++ src/wasm/component/response.rs | 199 ++++++++++++++++ src/wasm/mod.rs | 7 + 8 files changed, 1007 insertions(+), 1 deletion(-) create mode 100644 src/wasm/component/body.rs create mode 100644 src/wasm/component/client/mod.rs create mode 100644 src/wasm/component/mod.rs create mode 100644 src/wasm/component/request.rs create mode 100644 src/wasm/component/response.rs diff --git a/Cargo.toml b/Cargo.toml index e38e68a9f..a57714cd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -195,6 +195,9 @@ wasm-bindgen = "0.2.89" wasm-bindgen-futures = "0.4.18" wasm-streams = { version = "0.4", optional = true } +[target.'cfg(all(target_os = "wasi", target_env = "p2"))'.dependencies] +wasi = "=0.13.3" # For compatibility, pin to wasi@0.2.2 bindings + [target.'cfg(target_arch = "wasm32")'.dependencies.web-sys] version = "0.3.28" features = [ diff --git a/src/lib.rs b/src/lib.rs index cf3d39d0f..c93f7cf5a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -306,10 +306,42 @@ pub use self::response::ResponseBuilderExt; /// - supplied `Url` cannot be parsed /// - there was an error while sending request /// - redirect limit was exhausted +#[cfg(not(any(target_os = "wasi", target_env = "p2")))] pub async fn get(url: T) -> crate::Result { Client::builder().build()?.get(url).send().await } +/// Shortcut method to quickly make a `GET` request. +/// +/// See also the methods on the [`reqwest::Response`](./struct.Response.html) +/// type. +/// +/// **NOTE**: This function creates a new internal `Client` on each call, +/// and so should not be used if making many requests. Create a +/// [`Client`](./struct.Client.html) instead. +/// +/// # Examples +/// +/// ```rust +/// # fn run() -> Result<(), reqwest::Error> { +/// let body = reqwest::get("https://www.rust-lang.org")? +/// .text()?; +/// # Ok(()) +/// # } +/// ``` +/// +/// # Errors +/// +/// This function fails if: +/// +/// - supplied `Url` cannot be parsed +/// - there was an error while sending request +/// - redirect limit was exhausted +#[cfg(all(target_os = "wasi", target_env = "p2"))] +pub fn get(url: T) -> crate::Result { + Client::builder().build()?.get(url).send() +} + fn _assert_impls() { fn assert_send() {} fn assert_sync() {} @@ -372,6 +404,6 @@ if_wasm! { mod util; pub use self::wasm::{Body, Client, ClientBuilder, Request, RequestBuilder, Response}; - #[cfg(feature = "multipart")] + #[cfg(all(not(all(target_os = "wasi", target_env = "p2")), feature = "multipart"))] pub use self::wasm::multipart; } diff --git a/src/wasm/component/body.rs b/src/wasm/component/body.rs new file mode 100644 index 000000000..3bfabadba --- /dev/null +++ b/src/wasm/component/body.rs @@ -0,0 +1,111 @@ +use bytes::Bytes; +use std::{borrow::Cow, fmt}; + +/// The body of a [`super::Request`]. +pub struct Body { + inner: Inner, +} + +enum Inner { + Single(Single), +} + +#[derive(Clone)] +pub(crate) enum Single { + Bytes(Bytes), + Text(Cow<'static, str>), +} + +impl Single { + fn as_bytes(&self) -> &[u8] { + match self { + Single::Bytes(bytes) => bytes.as_ref(), + Single::Text(text) => text.as_bytes(), + } + } + + fn is_empty(&self) -> bool { + match self { + Single::Bytes(bytes) => bytes.is_empty(), + Single::Text(text) => text.is_empty(), + } + } +} + +impl Body { + /// Returns a reference to the internal data of the `Body`. + /// + /// `None` is returned, if the underlying data is a multipart form. + #[inline] + pub fn as_bytes(&self) -> Option<&[u8]> { + match &self.inner { + Inner::Single(single) => Some(single.as_bytes()), + } + } + + #[allow(unused)] + pub(crate) fn is_empty(&self) -> bool { + match &self.inner { + Inner::Single(single) => single.is_empty(), + } + } + + pub(crate) fn try_clone(&self) -> Option { + match &self.inner { + Inner::Single(single) => Some(Self { + inner: Inner::Single(single.clone()), + }), + } + } +} + +impl From for Body { + #[inline] + fn from(bytes: Bytes) -> Body { + Body { + inner: Inner::Single(Single::Bytes(bytes)), + } + } +} + +impl From> for Body { + #[inline] + fn from(vec: Vec) -> Body { + Body { + inner: Inner::Single(Single::Bytes(vec.into())), + } + } +} + +impl From<&'static [u8]> for Body { + #[inline] + fn from(s: &'static [u8]) -> Body { + Body { + inner: Inner::Single(Single::Bytes(Bytes::from_static(s))), + } + } +} + +impl From for Body { + #[inline] + fn from(s: String) -> Body { + Body { + inner: Inner::Single(Single::Text(s.into())), + } + } +} + +impl From<&'static str> for Body { + #[inline] + fn from(s: &'static str) -> Body { + Body { + inner: Inner::Single(Single::Text(s.into())), + } + } +} + +impl fmt::Debug for Body { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Body").finish() + } +} diff --git a/src/wasm/component/client/mod.rs b/src/wasm/component/client/mod.rs new file mode 100644 index 000000000..3ffdb3593 --- /dev/null +++ b/src/wasm/component/client/mod.rs @@ -0,0 +1,254 @@ +use http::header::{Entry, USER_AGENT}; +use http::{HeaderMap, HeaderValue, Method}; +use std::convert::TryInto; +use std::sync::Arc; + +use super::{Request, RequestBuilder, Response}; +use crate::IntoUrl; + +/// A client for making HTTP requests. +#[derive(Default, Debug, Clone)] +pub struct Client { + config: Arc, +} + +/// A builder to configure a [`Client`]. +#[derive(Default, Debug)] +pub struct ClientBuilder { + config: Config, +} + +impl Client { + /// Constructs a new [`Client`]. + pub fn new() -> Self { + Client::builder().build().expect("Client::new()") + } + + /// Constructs a new [`ClientBuilder`]. + pub fn builder() -> ClientBuilder { + ClientBuilder::new() + } + + /// Convenience method to make a `GET` request to a URL. + /// + /// # Errors + /// + /// This method fails whenever supplied `Url` cannot be parsed. + pub fn get(&self, url: U) -> RequestBuilder { + self.request(Method::GET, url) + } + + /// Convenience method to make a `POST` request to a URL. + /// + /// # Errors + /// + /// This method fails whenever supplied `Url` cannot be parsed. + pub fn post(&self, url: U) -> RequestBuilder { + self.request(Method::POST, url) + } + + /// Convenience method to make a `PUT` request to a URL. + /// + /// # Errors + /// + /// This method fails whenever supplied `Url` cannot be parsed. + pub fn put(&self, url: U) -> RequestBuilder { + self.request(Method::PUT, url) + } + + /// Convenience method to make a `PATCH` request to a URL. + /// + /// # Errors + /// + /// This method fails whenever supplied `Url` cannot be parsed. + pub fn patch(&self, url: U) -> RequestBuilder { + self.request(Method::PATCH, url) + } + + /// Convenience method to make a `DELETE` request to a URL. + /// + /// # Errors + /// + /// This method fails whenever supplied `Url` cannot be parsed. + pub fn delete(&self, url: U) -> RequestBuilder { + self.request(Method::DELETE, url) + } + + /// Convenience method to make a `HEAD` request to a URL. + /// + /// # Errors + /// + /// This method fails whenever supplied `Url` cannot be parsed. + pub fn head(&self, url: U) -> RequestBuilder { + self.request(Method::HEAD, url) + } + + /// Start building a `Request` with the `Method` and `Url`. + /// + /// Returns a `RequestBuilder`, which will allow setting headers and + /// request body before sending. + /// + /// # Errors + /// + /// This method fails whenever supplied `Url` cannot be parsed. + pub fn request(&self, method: Method, url: U) -> RequestBuilder { + let req = url.into_url().map(move |url| Request::new(method, url)); + RequestBuilder::new(self.clone(), req) + } + + /// Executes a `Request`. + /// + /// A `Request` can be built manually with `Request::new()` or obtained + /// from a RequestBuilder with `RequestBuilder::build()`. + /// + /// You should prefer to use the `RequestBuilder` and + /// `RequestBuilder::send()`. + /// + /// # Errors + /// + /// This method fails if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub fn execute(&self, request: Request) -> Result { + self.execute_request(request) + } + + /// Merge [`Request`] headers with default headers set in [`Config`] + fn merge_default_headers(&self, req: &mut Request) { + let headers: &mut HeaderMap = req.headers_mut(); + // Insert without overwriting existing headers + for (key, value) in self.config.headers.iter() { + if let Entry::Vacant(entry) = headers.entry(key) { + entry.insert(value.clone()); + } + } + } + + pub(super) fn execute_request(&self, mut req: Request) -> crate::Result { + self.merge_default_headers(&mut req); + fetch(req) + } +} + +fn fetch(req: Request) -> crate::Result { + let headers = wasi::http::types::Fields::new(); + for (name, value) in req.headers() { + headers + .append(&name.to_string(), &value.as_bytes().to_vec()) + .map_err(crate::error::builder)?; + } + + // Construct `OutgoingRequest` + let outgoing_request = wasi::http::types::OutgoingRequest::new(headers); + let url = req.url(); + if url.has_authority() { + outgoing_request + .set_authority(Some(url.authority())) + .map_err(|_| crate::error::request("failed to set authority on request"))?; + } + outgoing_request + .set_path_with_query(Some(url.path())) + .map_err(|_| crate::error::request("failed to set path with query on request"))?; + match url.scheme() { + "http" => outgoing_request.set_scheme(Some(&wasi::http::types::Scheme::Http)), + "https" => outgoing_request.set_scheme(Some(&wasi::http::types::Scheme::Https)), + scheme => { + outgoing_request.set_scheme(Some(&wasi::http::types::Scheme::Other(scheme.to_string()))) + } + } + .map_err(|_| crate::error::request("failed to set scheme on request"))?; + + match req.method() { + &Method::GET => outgoing_request.set_method(&wasi::http::types::Method::Get), + &Method::POST => outgoing_request.set_method(&wasi::http::types::Method::Post), + &Method::PUT => outgoing_request.set_method(&wasi::http::types::Method::Put), + &Method::DELETE => outgoing_request.set_method(&wasi::http::types::Method::Delete), + &Method::HEAD => outgoing_request.set_method(&wasi::http::types::Method::Head), + &Method::OPTIONS => outgoing_request.set_method(&wasi::http::types::Method::Options), + &Method::CONNECT => outgoing_request.set_method(&wasi::http::types::Method::Connect), + &Method::PATCH => outgoing_request.set_method(&wasi::http::types::Method::Patch), + &Method::TRACE => outgoing_request.set_method(&wasi::http::types::Method::Trace), + // The only other methods are ExtensionInline and ExtensionAllocated, which are + // private first of all (can't match on it here) and don't have a strongly typed + // version in wasi-http, so we fall back to Other. + _ => { + outgoing_request.set_method(&wasi::http::types::Method::Other(req.method().to_string())) + } + } + .map_err(|_| { + crate::error::builder(format!( + "failed to set method, invalid method {}", + req.method().to_string() + )) + })?; + + let response = match wasi::http::outgoing_handler::handle(outgoing_request, None) { + Ok(resp) => { + resp.subscribe().block(); + let response = match resp.get() { + None => Err(crate::error::request("http request response missing")), + // Shouldn't occur + Some(Err(_)) => Err(crate::error::request( + "http request response requested more than once", + )), + Some(Ok(response)) => response.map_err(crate::error::request), + }?; + + Response::new(http::Response::new(response), url.clone()) + } + Err(e) => return Err(crate::error::request(e)), + }; + + Ok(response) +} + +impl ClientBuilder { + /// Return a new `ClientBuilder`. + pub fn new() -> Self { + ClientBuilder { + config: Config::default(), + } + } + + /// Returns a 'Client' that uses this ClientBuilder configuration + pub fn build(mut self) -> Result { + if let Some(err) = self.config.error { + return Err(err); + } + + let config = std::mem::take(&mut self.config); + Ok(Client { + config: Arc::new(config), + }) + } + + /// Sets the `User-Agent` header to be used by this client. + pub fn user_agent(mut self, value: V) -> ClientBuilder + where + V: TryInto, + V::Error: Into, + { + match value.try_into() { + Ok(value) => { + self.config.headers.insert(USER_AGENT, value); + } + Err(e) => { + self.config.error = Some(crate::error::builder(e.into())); + } + } + self + } + + /// Sets the default headers for every request + pub fn default_headers(mut self, headers: HeaderMap) -> ClientBuilder { + for (key, value) in headers.iter() { + self.config.headers.insert(key, value.clone()); + } + self + } +} + +#[derive(Default, Debug)] +struct Config { + headers: HeaderMap, + error: Option, +} diff --git a/src/wasm/component/mod.rs b/src/wasm/component/mod.rs new file mode 100644 index 000000000..e46333cde --- /dev/null +++ b/src/wasm/component/mod.rs @@ -0,0 +1,9 @@ +mod body; +mod client; +mod request; +mod response; + +pub use self::body::Body; +pub use self::client::{Client, ClientBuilder}; +pub use self::request::{Request, RequestBuilder}; +pub use self::response::Response; diff --git a/src/wasm/component/request.rs b/src/wasm/component/request.rs new file mode 100644 index 000000000..6c9957225 --- /dev/null +++ b/src/wasm/component/request.rs @@ -0,0 +1,391 @@ +use std::convert::TryFrom; +use std::fmt; + +use bytes::Bytes; +use http::{request::Parts, Method, Request as HttpRequest}; +use serde::Serialize; +#[cfg(feature = "json")] +use serde_json; +use url::Url; + +use super::{Client, Response}; +use crate::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE}; +use crate::Body; + +/// A request which can be executed with `Client::execute()`. +#[derive(Debug)] +pub struct Request { + method: Method, + url: Url, + headers: HeaderMap, + body: Option, +} + +/// A builder to construct the properties of a `Request`. +#[derive(Debug)] +pub struct RequestBuilder { + client: Client, + request: crate::Result, +} + +impl Request { + /// Constructs a new request. + #[inline] + pub fn new(method: Method, url: Url) -> Self { + Request { + method, + url, + headers: HeaderMap::new(), + body: None, + } + } + + /// Get the method. + #[inline] + pub fn method(&self) -> &Method { + &self.method + } + + /// Get a mutable reference to the method. + #[inline] + pub fn method_mut(&mut self) -> &mut Method { + &mut self.method + } + + /// Get the url. + #[inline] + pub fn url(&self) -> &Url { + &self.url + } + + /// Get a mutable reference to the url. + #[inline] + pub fn url_mut(&mut self) -> &mut Url { + &mut self.url + } + + /// Get the headers. + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + /// Get a mutable reference to the headers. + #[inline] + pub fn headers_mut(&mut self) -> &mut HeaderMap { + &mut self.headers + } + + /// Get the body. + #[inline] + pub fn body(&self) -> Option<&Body> { + self.body.as_ref() + } + + /// Get a mutable reference to the body. + #[inline] + pub fn body_mut(&mut self) -> &mut Option { + &mut self.body + } + + /// Attempts to clone the `Request`. + /// + /// None is returned if a body is which can not be cloned. + pub fn try_clone(&self) -> Option { + let body = match self.body.as_ref() { + Some(body) => Some(body.try_clone()?), + None => None, + }; + + Some(Self { + method: self.method.clone(), + url: self.url.clone(), + headers: self.headers.clone(), + body, + }) + } +} + +impl RequestBuilder { + pub(super) fn new(client: Client, request: crate::Result) -> RequestBuilder { + RequestBuilder { client, request } + } + + /// Assemble a builder starting from an existing `Client` and a `Request`. + pub fn from_parts(client: crate::Client, request: crate::Request) -> crate::RequestBuilder { + crate::RequestBuilder { + client, + request: crate::Result::Ok(request), + } + } + + /// Modify the query string of the URL. + /// + /// Modifies the URL of this request, adding the parameters provided. + /// This method appends and does not overwrite. This means that it can + /// be called multiple times and that existing query parameters are not + /// overwritten if the same key is used. The key will simply show up + /// twice in the query string. + /// Calling `.query([("foo", "a"), ("foo", "b")])` gives `"foo=a&foo=b"`. + /// + /// # Note + /// This method does not support serializing a single key-value + /// pair. Instead of using `.query(("key", "val"))`, use a sequence, such + /// as `.query(&[("key", "val")])`. It's also possible to serialize structs + /// and maps into a key-value pair. + /// + /// # Errors + /// This method will fail if the object you provide cannot be serialized + /// into a query string. + pub fn query(mut self, query: &T) -> RequestBuilder { + let mut error = None; + if let Ok(ref mut req) = self.request { + let url = req.url_mut(); + let mut pairs = url.query_pairs_mut(); + let serializer = serde_urlencoded::Serializer::new(&mut pairs); + + if let Err(err) = query.serialize(serializer) { + error = Some(crate::error::builder(err)); + } + } + if let Ok(ref mut req) = self.request { + if let Some("") = req.url().query() { + req.url_mut().set_query(None); + } + } + if let Some(err) = error { + self.request = Err(err); + } + self + } + + /// Send a form body. + /// + /// Sets the body to the url encoded serialization of the passed value, + /// and also sets the `Content-Type: application/x-www-form-urlencoded` + /// header. + /// + /// # Errors + /// + /// This method fails if the passed value cannot be serialized into + /// url encoded format + pub fn form(mut self, form: &T) -> RequestBuilder { + let mut error = None; + if let Ok(ref mut req) = self.request { + match serde_urlencoded::to_string(form) { + Ok(body) => { + req.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("application/x-www-form-urlencoded"), + ); + *req.body_mut() = Some(body.into()); + } + Err(err) => error = Some(crate::error::builder(err)), + } + } + if let Some(err) = error { + self.request = Err(err); + } + self + } + + #[cfg(feature = "json")] + #[cfg_attr(docsrs, doc(cfg(feature = "json")))] + /// Set the request json + pub fn json(mut self, json: &T) -> RequestBuilder { + let mut error = None; + if let Ok(ref mut req) = self.request { + match serde_json::to_vec(json) { + Ok(body) => { + req.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + *req.body_mut() = Some(body.into()); + } + Err(err) => error = Some(crate::error::builder(err)), + } + } + if let Some(err) = error { + self.request = Err(err); + } + self + } + + /// Enable HTTP basic authentication. + pub fn basic_auth(self, username: U, password: Option

) -> RequestBuilder + where + U: fmt::Display, + P: fmt::Display, + { + let header_value = crate::util::basic_auth(username, password); + self.header(crate::header::AUTHORIZATION, header_value) + } + + /// Enable HTTP bearer authentication. + pub fn bearer_auth(self, token: T) -> RequestBuilder + where + T: fmt::Display, + { + let header_value = format!("Bearer {token}"); + self.header(crate::header::AUTHORIZATION, header_value) + } + + /// Set the request body. + pub fn body>(mut self, body: T) -> RequestBuilder { + if let Ok(ref mut req) = self.request { + req.body = Some(body.into()); + } + self + } + + /// Add a `Header` to this Request. + pub fn header(mut self, key: K, value: V) -> RequestBuilder + where + HeaderName: TryFrom, + >::Error: Into, + HeaderValue: TryFrom, + >::Error: Into, + { + let mut error = None; + if let Ok(ref mut req) = self.request { + match >::try_from(key) { + Ok(key) => match >::try_from(value) { + Ok(value) => { + req.headers_mut().append(key, value); + } + Err(e) => error = Some(crate::error::builder(e.into())), + }, + Err(e) => error = Some(crate::error::builder(e.into())), + }; + } + if let Some(err) = error { + self.request = Err(err); + } + self + } + + /// Add a set of Headers to the existing ones on this Request. + /// + /// The headers will be merged in to any already set. + pub fn headers(mut self, headers: crate::header::HeaderMap) -> RequestBuilder { + if let Ok(ref mut req) = self.request { + crate::util::replace_headers(req.headers_mut(), headers); + } + self + } + + /// Build a `Request`, which can be inspected, modified and executed with + /// `Client::execute()`. + pub fn build(self) -> crate::Result { + self.request + } + + /// Build a `Request`, which can be inspected, modified and executed with + /// `Client::execute()`. + /// + /// This is similar to [`RequestBuilder::build()`], but also returns the + /// embedded `Client`. + pub fn build_split(self) -> (Client, crate::Result) { + (self.client, self.request) + } + + /// Constructs the Request and sends it to the target URL, returning a + /// future Response. + /// + /// # Errors + /// + /// This method fails if there was an error while sending request. + /// + /// # Example + /// + /// ```no_run + /// # use reqwest::Error; + /// # + /// # async fn run() -> Result<(), Error> { + /// let response = reqwest::Client::new() + /// .get("https://hyper.rs") + /// .send() + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn send(self) -> crate::Result { + let req = self.request?; + self.client.execute_request(req) + } + + /// Attempt to clone the RequestBuilder. + /// + /// `None` is returned if the RequestBuilder can not be cloned. + /// + /// # Examples + /// + /// ```no_run + /// # use reqwest::Error; + /// # + /// # fn run() -> Result<(), Error> { + /// let client = reqwest::Client::new(); + /// let builder = client.post("http://httpbin.org/post") + /// .body("from a &str!"); + /// let clone = builder.try_clone(); + /// assert!(clone.is_some()); + /// # Ok(()) + /// # } + /// ``` + pub fn try_clone(&self) -> Option { + self.request + .as_ref() + .ok() + .and_then(|req| req.try_clone()) + .map(|req| RequestBuilder { + client: self.client.clone(), + request: Ok(req), + }) + } +} + +impl TryFrom> for Request +where + T: Into, +{ + type Error = crate::Error; + + fn try_from(req: HttpRequest) -> crate::Result { + let (parts, body) = req.into_parts(); + let Parts { + method, + uri, + headers, + .. + } = parts; + let url = Url::parse(&uri.to_string()).map_err(crate::error::builder)?; + Ok(Request { + method, + url, + headers, + body: Some(body.into()), + }) + } +} + +impl TryFrom for HttpRequest { + type Error = crate::Error; + + fn try_from(req: Request) -> crate::Result { + let Request { + method, + url, + headers, + body, + .. + } = req; + + let mut req = HttpRequest::builder() + .method(method) + .uri(url.as_str()) + .body(body.unwrap_or_else(|| Body::from(Bytes::default()))) + .map_err(crate::error::builder)?; + + *req.headers_mut() = headers; + Ok(req) + } +} diff --git a/src/wasm/component/response.rs b/src/wasm/component/response.rs new file mode 100644 index 000000000..382e4783a --- /dev/null +++ b/src/wasm/component/response.rs @@ -0,0 +1,199 @@ +use std::{fmt, io::Read as _}; + +use bytes::Bytes; +use http::{HeaderMap, StatusCode, Version}; +#[cfg(feature = "json")] +use serde::de::DeserializeOwned; +use url::Url; + +/// A Response to a submitted `Request`. +pub struct Response { + http: http::Response, + // Boxed to save space (11 words to 1 word), and it's not accessed + // frequently internally. + url: Box, +} + +impl Response { + pub(super) fn new( + http: http::Response, + url: Url, + ) -> Response { + Response { + http, + url: Box::new(url), + } + } + + /// Get the `StatusCode` of this `Response`. + #[inline] + pub fn status(&self) -> StatusCode { + self.http.status() + } + + /// Get the `Headers` of this `Response`. + #[inline] + pub fn headers(&self) -> &HeaderMap { + self.http.headers() + } + + /// Get a mutable reference to the `Headers` of this `Response`. + #[inline] + pub fn headers_mut(&mut self) -> &mut HeaderMap { + self.http.headers_mut() + } + + /// Get the content-length of this response, if known. + /// + /// Reasons it may not be known: + /// + /// - The server didn't send a `content-length` header. + /// - The response is compressed and automatically decoded (thus changing + /// the actual decoded length). + pub fn content_length(&self) -> Option { + self.headers() + .get(http::header::CONTENT_LENGTH)? + .to_str() + .ok()? + .parse() + .ok() + } + + /// Get the final `Url` of this `Response`. + #[inline] + pub fn url(&self) -> &Url { + &self.url + } + + /// Get the HTTP `Version` of this `Response`. + #[inline] + pub fn version(&self) -> Version { + self.http.version() + } + + /// Try to deserialize the response body as JSON. + #[cfg(feature = "json")] + #[cfg_attr(docsrs, doc(cfg(feature = "json")))] + pub fn json(self) -> crate::Result { + let full = self.bytes()?; + + serde_json::from_slice(&full).map_err(crate::error::decode) + } + + /// Get the response as text + pub fn text(self) -> crate::Result { + self.bytes() + .map(|s| String::from_utf8_lossy(&s).to_string()) + } + + /// Get the response as bytes + pub fn bytes(self) -> crate::Result { + let response_body = self + .http + .body() + .consume() + .map_err(|_| crate::error::decode("failed to consume response body"))?; + let body = { + let mut buf = vec![]; + let mut stream = response_body + .stream() + .map_err(|_| crate::error::decode("failed to stream response body"))?; + InputStreamReader::from(&mut stream) + .read_to_end(&mut buf) + .map_err(crate::error::decode_io)?; + buf + }; + let _trailers = wasi::http::types::IncomingBody::finish(response_body); + Ok(body.into()) + } + + /// Convert the response into a [`wasi::http::types::IncomingBody`] resource which can + /// then be used to stream the body. + #[cfg(feature = "stream")] + pub fn bytes_stream( + &mut self, + ) -> crate::Result<( + wasi::io::streams::InputStream, + wasi::http::types::IncomingBody, + )> { + let incoming_body = self + .http + .body() + .consume() + .map_err(|_| crate::error::decode("failed to consume response body"))?; + let input_stream = incoming_body + .stream() + .map_err(|_| crate::error::decode("failed to stream response body"))?; + Ok((input_stream, incoming_body)) + } + + /// Turn a response into an error if the server returned an error. + pub fn error_for_status(self) -> crate::Result { + let status = self.status(); + if status.is_client_error() || status.is_server_error() { + Err(crate::error::status_code(*self.url, status)) + } else { + Ok(self) + } + } + + /// Turn a reference to a response into an error if the server returned an error. + pub fn error_for_status_ref(&self) -> crate::Result<&Self> { + let status = self.status(); + if status.is_client_error() || status.is_server_error() { + Err(crate::error::status_code(*self.url.clone(), status)) + } else { + Ok(self) + } + } +} + +impl fmt::Debug for Response { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Response") + .field("url", self.url()) + .field("status", &self.status()) + .field("headers", self.headers()) + .finish() + } +} + +/// Implements `std::io::Read` for a `wasi::io::streams::InputStream`. +pub struct InputStreamReader<'a> { + stream: &'a mut wasi::io::streams::InputStream, +} + +impl<'a> From<&'a mut wasi::io::streams::InputStream> for InputStreamReader<'a> { + fn from(stream: &'a mut wasi::io::streams::InputStream) -> Self { + Self { stream } + } +} + +impl std::io::Read for InputStreamReader<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + use std::io; + use wasi::io::streams::StreamError; + + let n = buf + .len() + .try_into() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + match self.stream.blocking_read(n) { + Ok(chunk) => { + let n = chunk.len(); + if n > buf.len() { + return Err(io::Error::new( + io::ErrorKind::Other, + "more bytes read than requested", + )); + } + buf[..n].copy_from_slice(&chunk); + Ok(n) + } + Err(StreamError::Closed) => Ok(0), + Err(StreamError::LastOperationFailed(e)) => { + Err(io::Error::new(io::ErrorKind::Other, e.to_debug_string())) + } + } + } +} diff --git a/src/wasm/mod.rs b/src/wasm/mod.rs index 0bac9fe1a..874947dbb 100644 --- a/src/wasm/mod.rs +++ b/src/wasm/mod.rs @@ -1,2 +1,9 @@ +#[cfg(all(target_os = "wasi", target_env = "p2"))] +pub mod component; +#[cfg(all(target_os = "wasi", target_env = "p2"))] +pub use component::*; + +#[cfg(not(all(target_os = "wasi", target_env = "p2")))] pub mod js; +#[cfg(not(all(target_os = "wasi", target_env = "p2")))] pub use js::*; From 3b3c99eae8f925e84bd64cb0cb081326eb3385a1 Mon Sep 17 00:00:00 2001 From: Brooks Townsend Date: Mon, 21 Oct 2024 16:01:55 -0400 Subject: [PATCH 4/4] chore(wasm): add wasm32-wasip2 example Signed-off-by: Brooks Townsend example cleanup extra blocking feature Signed-off-by: Brooks Townsend use body example Signed-off-by: Brooks Townsend deps(url): remove patch Signed-off-by: Brooks Townsend --- examples/wasm_component/.cargo/config.toml | 2 ++ examples/wasm_component/Cargo.toml | 20 +++++++++++++ examples/wasm_component/README.md | 34 ++++++++++++++++++++++ examples/wasm_component/src/lib.rs | 34 ++++++++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 examples/wasm_component/.cargo/config.toml create mode 100644 examples/wasm_component/Cargo.toml create mode 100644 examples/wasm_component/README.md create mode 100644 examples/wasm_component/src/lib.rs diff --git a/examples/wasm_component/.cargo/config.toml b/examples/wasm_component/.cargo/config.toml new file mode 100644 index 000000000..dac24597a --- /dev/null +++ b/examples/wasm_component/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip2" \ No newline at end of file diff --git a/examples/wasm_component/Cargo.toml b/examples/wasm_component/Cargo.toml new file mode 100644 index 000000000..fa1ace404 --- /dev/null +++ b/examples/wasm_component/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "http-reqwest" +edition = "2021" +version = "0.1.0" + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +futures = "0.3.30" +reqwest = { version = "0.12.8", path = "../../", features = ["stream"] } +wasi = "=0.13.3" # For compatibility, pin to wasi@0.2.2 bindings + +[profile.release] +# Optimize for small code size +lto = true +opt-level = "s" +strip = true diff --git a/examples/wasm_component/README.md b/examples/wasm_component/README.md new file mode 100644 index 000000000..9f5b73816 --- /dev/null +++ b/examples/wasm_component/README.md @@ -0,0 +1,34 @@ +# HTTP Reqwest + +This is a simple Rust Wasm example that sends an outgoing http request using the `reqwest` library to [https://hyper.rs](https://hyper.rs). + +## Prerequisites + +- `cargo` 1.82+ +- `rustup target add wasm32-wasip2` +- [wasmtime 23.0.0+](https://github.com/bytecodealliance/wasmtime) + +## Building + +```bash +# Build Wasm component +cargo build --target wasm32-wasip2 +``` + +## Running with wasmtime + +```bash +wasmtime serve -Scommon ./target/wasm32-wasip2/debug/http_reqwest.wasm +``` + +Then send a request to `localhost:8080` + +```bash +> curl localhost:8080 + + + + + Example Domain +.... +``` diff --git a/examples/wasm_component/src/lib.rs b/examples/wasm_component/src/lib.rs new file mode 100644 index 000000000..defaa2831 --- /dev/null +++ b/examples/wasm_component/src/lib.rs @@ -0,0 +1,34 @@ +use wasi::http::types::{ + Fields, IncomingBody, IncomingRequest, OutgoingBody, OutgoingResponse, ResponseOutparam, +}; + +#[allow(unused)] +struct ReqwestComponent; + +impl wasi::exports::http::incoming_handler::Guest for ReqwestComponent { + fn handle(_request: IncomingRequest, response_out: ResponseOutparam) { + let response = OutgoingResponse::new(Fields::new()); + response.set_status_code(200).unwrap(); + let response_body = response + .body() + .expect("should be able to get response body"); + ResponseOutparam::set(response_out, Ok(response)); + + let mut response = reqwest::get("https://hyper.rs").expect("should get response bytes"); + let (mut body_stream, incoming_body) = response + .bytes_stream() + .expect("should be able to get response body stream"); + std::io::copy( + &mut body_stream, + &mut response_body + .write() + .expect("should be able to write to response body"), + ) + .expect("should be able to stream input to output"); + drop(body_stream); + IncomingBody::finish(incoming_body); + OutgoingBody::finish(response_body, None).expect("failed to finish response body"); + } +} + +wasi::http::proxy::export!(ReqwestComponent);