diff --git a/Cargo.lock b/Cargo.lock index f4c3b79..c349adc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.7" @@ -408,9 +414,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "base64" -version = "0.21.5" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" @@ -1022,12 +1028,12 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.28" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -1281,6 +1287,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "humantime" version = "2.1.0" @@ -1480,9 +1502,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.20" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "malloc_buf" @@ -1561,6 +1583,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.10" @@ -1886,7 +1918,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.7.1", ] [[package]] @@ -2079,23 +2111,36 @@ dependencies = [ [[package]] name = "rustls" -version = "0.21.10" +version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "log", + "once_cell", "ring", + "rustls-pki-types", "rustls-webpki", - "sct", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.101.7" +version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "ring", + "rustls-pki-types", "untrusted", ] @@ -2126,16 +2171,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "sctk-adwaita" version = "0.5.4" @@ -2304,6 +2339,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2517,20 +2558,33 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.9.1" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cdd25c339e200129fe4de81451814e5228c9b771d57378817d6117cc2b3f97" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" dependencies = [ "base64", "flate2", "log", - "once_cell", + "percent-encoding", "rustls", - "rustls-webpki", - "url", + "rustls-pki-types", + "ureq-proto", + "utf-8", "webpki-roots", ] +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.0" @@ -2542,6 +2596,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "vec_map" version = "0.8.2" @@ -2779,9 +2839,12 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.25.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1778a42e8b3b90bff8d0f5032bf22250792889a5cdc752aa0020c84abe3aaf10" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] [[package]] name = "winapi" @@ -3239,6 +3302,12 @@ dependencies = [ "syn 2.0.43", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zvariant" version = "3.15.0" diff --git a/ehttp/Cargo.toml b/ehttp/Cargo.toml index 4955f5b..88a48ea 100644 --- a/ehttp/Cargo.toml +++ b/ehttp/Cargo.toml @@ -50,7 +50,7 @@ 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"] } -ureq = "2.0" +ureq = "3.1" async-channel = { version = "2.0", optional = true } # For compiling to web: diff --git a/ehttp/src/lib.rs b/ehttp/src/lib.rs index 61769c6..a0588aa 100644 --- a/ehttp/src/lib.rs +++ b/ehttp/src/lib.rs @@ -75,7 +75,7 @@ pub async fn fetch_async(request: Request) -> Result { } mod types; -pub use types::{Error, Headers, PartialResponse, Request, Response, Result}; +pub use types::{Error, Headers, Method, PartialResponse, Request, Response, Result}; #[cfg(target_arch = "wasm32")] pub use types::Mode; diff --git a/ehttp/src/native.rs b/ehttp/src/native.rs index 363f233..f8eb3c8 100644 --- a/ehttp/src/native.rs +++ b/ehttp/src/native.rs @@ -1,4 +1,4 @@ -use crate::{Request, Response}; +use crate::{Method, Request, Response}; #[cfg(feature = "native-async")] use async_channel::{Receiver, Sender}; @@ -24,45 +24,43 @@ use async_channel::{Receiver, Sender}; /// * A browser extension blocked the request (e.g. ad blocker) /// * … pub fn fetch_blocking(request: &Request) -> crate::Result { - let mut req = ureq::request(&request.method, &request.url); + let mut resp = request.fetch_raw_native(true)?; - if let Some(timeout) = request.timeout { - req = req.timeout(timeout); - } - - for (k, v) in &request.headers { - req = req.set(k, v); - } - - let resp = if request.body.is_empty() { - req.call() - } else { - req.send_bytes(&request.body) - }; - - let (ok, resp) = match resp { - Ok(resp) => (true, resp), - Err(ureq::Error::Status(_, resp)) => (false, resp), // Still read the body on e.g. 404 - Err(ureq::Error::Transport(err)) => return Err(err.to_string()), - }; - - let url = resp.get_url().to_owned(); - let status = resp.status(); - let status_text = resp.status_text().to_owned(); + let ok = resp.status().is_success(); + use ureq::ResponseExt as _; + let url = resp.get_uri().to_string(); + let status = resp.status().as_u16(); + let status_text = resp + .status() + .canonical_reason() + .unwrap_or("ERROR") + .to_string(); let mut headers = crate::Headers::default(); - for key in &resp.headers_names() { - if let Some(value) = resp.header(key) { - headers.insert(key, value.to_owned()); - } + for (k, v) in resp.headers().iter() { + headers.insert( + k, + v.to_str() + .map_err(|e| format!("Failed to convert header value to string: {e}"))?, + ); } headers.sort(); // It reads nicer, and matches web backend. - let mut reader = resp.into_reader(); + let mut reader = resp.body_mut().as_reader(); let mut bytes = vec![]; use std::io::Read as _; if let Err(err) = reader.read_to_end(&mut bytes) { - if request.method == "HEAD" && err.kind() == std::io::ErrorKind::UnexpectedEof { - // We don't really expect a body for HEAD requests, so this is fine. + if err.kind() == std::io::ErrorKind::Other && request.method == Method::HEAD { + match err.downcast::() { + Ok(ureq::Error::Decompress(_, io_err)) + if io_err.kind() == std::io::ErrorKind::UnexpectedEof => + { + // We don't really expect a body for HEAD requests, so this is fine. + } + Ok(err_inner) => return Err(format!("Failed to read response body: {err_inner}")), + Err(err) => { + return Err(format!("Failed to read response body: {err}")); + } + } } else { return Err(format!("Failed to read response body: {err}")); } diff --git a/ehttp/src/streaming/native.rs b/ehttp/src/streaming/native.rs index b26c4c6..bcb1012 100644 --- a/ehttp/src/streaming/native.rs +++ b/ehttp/src/streaming/native.rs @@ -1,6 +1,6 @@ use std::ops::ControlFlow; -use crate::Request; +use crate::{Method, Request}; use super::Part; use crate::types::PartialResponse; @@ -9,35 +9,37 @@ pub fn fetch_streaming_blocking( request: Request, on_data: Box) -> ControlFlow<()> + Send>, ) { - let mut req = ureq::request(&request.method, &request.url); + let resp = request.fetch_raw_native(false); - for (k, v) in &request.headers { - req = req.set(k, v); - } - - let resp = if request.body.is_empty() { - req.call() - } else { - req.send_bytes(&request.body) - }; - - let (ok, resp) = match resp { - Ok(resp) => (true, resp), - Err(ureq::Error::Status(_, resp)) => (false, resp), // Still read the body on e.g. 404 - Err(ureq::Error::Transport(err)) => { - on_data(Err(err.to_string())); + let mut resp = match resp { + Ok(t) => t, + Err(e) => { + on_data(Err(e.to_string())); return; } }; - let url = resp.get_url().to_owned(); - let status = resp.status(); - let status_text = resp.status_text().to_owned(); + let ok = resp.status().is_success(); + use ureq::ResponseExt as _; + let url = resp.get_uri().to_string(); + let status = resp.status().as_u16(); + let status_text = resp + .status() + .canonical_reason() + .unwrap_or("ERROR") + .to_string(); let mut headers = crate::Headers::default(); - for key in &resp.headers_names() { - if let Some(value) = resp.header(key) { - headers.insert(key.to_ascii_lowercase(), value.to_owned()); - } + for (k, v) in resp.headers().iter() { + headers.insert( + k, + match v.to_str() { + Ok(t) => t, + Err(e) => { + on_data(Err(e.to_string())); + break; + } + }, + ); } headers.sort(); // It reads nicer, and matches web backend. @@ -52,9 +54,10 @@ pub fn fetch_streaming_blocking( return; }; - let mut reader = resp.into_reader(); + let mut reader = resp.body_mut().as_reader(); loop { let mut buf = vec![0; 2048]; + use std::io::Read; match reader.read(&mut buf) { Ok(n) if n > 0 => { // clone data from buffer and clear it @@ -68,10 +71,24 @@ pub fn fetch_streaming_blocking( break; } Err(err) => { - if request.method == "HEAD" && err.kind() == std::io::ErrorKind::UnexpectedEof { - // We don't really expect a body for HEAD requests, so this is fine. - on_data(Ok(Part::Chunk(vec![]))); - break; + if err.kind() == std::io::ErrorKind::Other && request.method == Method::HEAD { + match err.downcast::() { + Ok(ureq::Error::Decompress(_, io_err)) + if io_err.kind() == std::io::ErrorKind::UnexpectedEof => + { + // We don't really expect a body for HEAD requests, so this is fine. + on_data(Ok(Part::Chunk(vec![]))); + break; + } + Ok(err_inner) => { + on_data(Err(format!("Failed to read response body: {err_inner}"))); + return; + } + Err(err) => { + on_data(Err(format!("Failed to read response body: {err}"))); + return; + } + } } else { on_data(Err(format!("Failed to read response body: {err}"))); return; diff --git a/ehttp/src/types.rs b/ehttp/src/types.rs index f2a540f..48052b8 100644 --- a/ehttp/src/types.rs +++ b/ehttp/src/types.rs @@ -132,7 +132,7 @@ impl From for web_sys::RequestMode { #[derive(Clone, Debug)] pub struct Request { /// "GET", "POST", … - pub method: String, + pub method: Method, /// https://… pub url: String, @@ -159,7 +159,7 @@ impl Request { #[allow(clippy::needless_pass_by_value)] pub fn get(url: impl ToString) -> Self { Self { - method: "GET".to_owned(), + method: Method::GET, url: url.to_string(), body: vec![], headers: Headers::new(&[("Accept", "*/*")]), @@ -172,7 +172,7 @@ impl Request { #[allow(clippy::needless_pass_by_value)] pub fn head(url: impl ToString) -> Self { Self { - method: "HEAD".to_owned(), + method: Method::HEAD, url: url.to_string(), body: vec![], headers: Headers::new(&[("Accept", "*/*")]), @@ -185,7 +185,7 @@ impl Request { #[allow(clippy::needless_pass_by_value)] pub fn post(url: impl ToString, body: Vec) -> Self { Self { - method: "POST".to_owned(), + method: Method::POST, url: url.to_string(), body, headers: Headers::new(&[ @@ -201,7 +201,7 @@ impl Request { #[allow(clippy::needless_pass_by_value)] pub fn put(url: impl ToString, body: Vec) -> Self { Self { - method: "PUT".to_owned(), + method: Method::PUT, url: url.to_string(), body, headers: Headers::new(&[ @@ -216,7 +216,7 @@ impl Request { /// Create a 'DELETE' request with the given url. pub fn delete(url: &str) -> Self { Self { - method: "DELETE".to_owned(), + method: Method::DELETE, url: url.to_string(), body: vec![], headers: Headers::new(&[("Accept", "*/*")]), @@ -252,7 +252,7 @@ impl Request { pub fn multipart(url: impl ToString, builder: MultipartBuilder) -> Self { let (content_type, data) = builder.finish(); Self { - method: "POST".to_string(), + method: Method::POST, url: url.to_string(), body: data, headers: Headers::new(&[("Accept", "*/*"), ("Content-Type", content_type.as_str())]), @@ -269,7 +269,7 @@ impl Request { T: ?Sized + Serialize, { Ok(Self { - method: "POST".to_owned(), + method: Method::POST, url: url.to_string(), body: serde_json::to_string(body)?.into_bytes(), headers: Headers::new(&[("Accept", "*/*"), ("Content-Type", "application/json")]), @@ -286,7 +286,7 @@ impl Request { T: ?Sized + Serialize, { Ok(Self { - method: "PUT".to_owned(), + method: Method::PUT, url: url.to_string(), body: serde_json::to_string(body)?.into_bytes(), headers: Headers::new(&[("Accept", "*/*"), ("Content-Type", "application/json")]), @@ -299,6 +299,68 @@ impl Request { self.timeout = timeout; self } + + /// Fetch the ureq response from a page + #[cfg(not(target_arch = "wasm32"))] + pub fn fetch_raw_native(&self, with_timeout: bool) -> Result> { + if self.method.contains_body() { + let mut req = match self.method { + Method::POST => ureq::post(&self.url), + Method::PATCH => ureq::patch(&self.url), + Method::PUT => ureq::put(&self.url), + // These three are the only requests which contain a body, no other requests will be matched + _ => unreachable!(), // because of the `.contains_body()` call + }; + + for (k, v) in &self.headers { + req = req.header(k, v); + } + + req = { + if with_timeout { + req.config() + } else { + req.config().timeout_recv_body(self.timeout) + } + .http_status_as_error(false) + .build() + }; + + if self.body.is_empty() { + req.send_empty() + } else { + req.send(&self.body) + } + } else { + let mut req = match self.method { + Method::GET => ureq::get(&self.url), + Method::DELETE => ureq::delete(&self.url), + Method::CONNECT => ureq::connect(&self.url), + Method::HEAD => ureq::head(&self.url), + Method::OPTIONS => ureq::options(&self.url), + Method::TRACE => ureq::trace(&self.url), + // Include all other variants rather than a catch all here to prevent confusion if another variant were to be added + Method::PATCH | Method::POST | Method::PUT => unreachable!(), // because of the `.contains_body()` call + }; + + req = req + .config() + .timeout_recv_body(self.timeout) + .http_status_as_error(false) + .build(); + + for (k, v) in &self.headers { + req = req.header(k, v); + } + + if self.body.is_empty() { + req.call() + } else { + req.force_send_body().send(&self.body) + } + } + .map_err(|err| err.to_string()) + } } /// Response from a completed HTTP request. @@ -409,3 +471,62 @@ pub type Error = String; /// A type-alias for `Result`. pub type Result = std::result::Result; + +/// An [HTTP method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Method { + GET, + HEAD, + POST, + PUT, + DELETE, + CONNECT, + OPTIONS, + TRACE, + PATCH, +} + +impl Method { + /// Whether ureq creates a `RequestBuilder` or `RequestBuilder` + pub fn contains_body(&self) -> bool { + use Method::*; + match self { + // Methods that are created with a body + POST | PATCH | PUT => true, + // Everything else + _ => false, + } + } + + /// Convert an HTTP method string ("GET", "HEAD") to its enum variant + pub fn parse(string: &str) -> Result { + use Method::*; + match string { + "GET" => Ok(GET), + "HEAD" => Ok(HEAD), + "POST" => Ok(POST), + "PUT" => Ok(PUT), + "DELETE" => Ok(DELETE), + "CONNECT" => Ok(CONNECT), + "OPTIONS" => Ok(OPTIONS), + "TRACE" => Ok(TRACE), + "PATCH" => Ok(PATCH), + _ => Err(Error::from("Failed to parse HTTP method")), + } + } + + pub fn as_str(&self) -> &'static str { + use Method::*; + match self { + GET => "GET", + HEAD => "HEAD", + POST => "POST", + PUT => "PUT", + DELETE => "DELETE", + CONNECT => "CONNECT", + OPTIONS => "OPTIONS", + TRACE => "TRACE", + PATCH => "PATCH", + } + } +} diff --git a/ehttp/src/web.rs b/ehttp/src/web.rs index 8449d41..0187bca 100644 --- a/ehttp/src/web.rs +++ b/ehttp/src/web.rs @@ -49,7 +49,7 @@ pub(crate) fn string_from_fetch_error(value: JsValue) -> String { pub(crate) async fn fetch_base(request: &Request) -> Result { let mut opts = web_sys::RequestInit::new(); - opts.method(&request.method); + opts.method(request.method.as_str()); opts.mode(request.mode.into()); if !request.body.is_empty() {