From 9476eb8dc483627f7090f3e94f6a94c5f1ec0865 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 17 Jan 2024 14:30:30 +0100 Subject: [PATCH] Add `struct Headers` to allow duplicated headers --- ehttp/src/lib.rs | 21 ++---- ehttp/src/native.rs | 13 ++-- ehttp/src/streaming/native.rs | 8 +-- ehttp/src/types.rs | 124 +++++++++++++++++++++++++++++----- ehttp/src/web.rs | 10 ++- example_eframe/src/app.rs | 6 +- 6 files changed, 125 insertions(+), 57 deletions(-) diff --git a/ehttp/src/lib.rs b/ehttp/src/lib.rs index 6c1d8c2..ab89103 100644 --- a/ehttp/src/lib.rs +++ b/ehttp/src/lib.rs @@ -73,7 +73,7 @@ pub async fn fetch_async(request: Request) -> Result { } mod types; -pub use types::{Error, PartialResponse, Request, Response, Result}; +pub use types::{Error, Headers, PartialResponse, Request, Response, Result}; #[cfg(not(target_arch = "wasm32"))] mod native; @@ -88,20 +88,7 @@ pub use web::spawn_future; #[cfg(feature = "streaming")] pub mod streaming; -/// Helper for constructing [`Request::headers`]. -/// ``` -/// use ehttp::Request; -/// let request = Request { -/// headers: ehttp::headers(&[ -/// ("Accept", "*/*"), -/// ("Content-Type", "text/plain; charset=utf-8"), -/// ]), -/// ..Request::get("https://www.example.com") -/// }; -/// ``` -pub fn headers(headers: &[(&str, &str)]) -> std::collections::BTreeMap { - headers - .iter() - .map(|e| (e.0.to_owned(), e.1.to_owned())) - .collect() +#[deprecated = "Use ehttp::Headers::new"] +pub fn headers(headers: &[(&str, &str)]) -> Headers { + Headers::new(headers) } diff --git a/ehttp/src/native.rs b/ehttp/src/native.rs index e2e2efc..060825e 100644 --- a/ehttp/src/native.rs +++ b/ehttp/src/native.rs @@ -1,5 +1,3 @@ -use std::collections::BTreeMap; - use crate::{Request, Response}; #[cfg(feature = "native-async")] @@ -27,8 +25,8 @@ use async_channel::{Receiver, Sender}; pub fn fetch_blocking(request: &Request) -> crate::Result { let mut req = ureq::request(&request.method, &request.url); - for header in &request.headers { - req = req.set(header.0, header.1); + for (k, v) in &request.headers { + req = req.set(k, v); } let resp = if request.body.is_empty() { @@ -46,11 +44,10 @@ pub fn fetch_blocking(request: &Request) -> crate::Result { let url = resp.get_url().to_owned(); let status = resp.status(); let status_text = resp.status_text().to_owned(); - let mut headers = BTreeMap::new(); + let mut headers = crate::Headers::default(); for key in &resp.headers_names() { if let Some(value) = resp.header(key) { - // lowercase for easy lookup - headers.insert(key.to_ascii_lowercase(), value.to_owned()); + headers.insert(key, value.to_owned()); } } @@ -70,8 +67,8 @@ pub fn fetch_blocking(request: &Request) -> crate::Result { ok, status, status_text, - bytes, headers, + bytes, }; Ok(response) } diff --git a/ehttp/src/streaming/native.rs b/ehttp/src/streaming/native.rs index 7427087..32f4b66 100644 --- a/ehttp/src/streaming/native.rs +++ b/ehttp/src/streaming/native.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeMap; use std::ops::ControlFlow; use crate::Request; @@ -12,8 +11,8 @@ pub fn fetch_streaming_blocking( ) { let mut req = ureq::request(&request.method, &request.url); - for header in &request.headers { - req = req.set(header.0, header.1); + for (k, v) in &request.headers { + req = req.set(k, v); } let resp = if request.body.is_empty() { @@ -34,10 +33,9 @@ pub fn fetch_streaming_blocking( let url = resp.get_url().to_owned(); let status = resp.status(); let status_text = resp.status_text().to_owned(); - let mut headers = BTreeMap::new(); + let mut headers = crate::Headers::default(); for key in &resp.headers_names() { if let Some(value) = resp.header(key) { - // lowercase for easy lookup headers.insert(key.to_ascii_lowercase(), value.to_owned()); } } diff --git a/ehttp/src/types.rs b/ehttp/src/types.rs index 717f79f..17bb003 100644 --- a/ehttp/src/types.rs +++ b/ehttp/src/types.rs @@ -1,4 +1,82 @@ -use std::collections::BTreeMap; +/// Headers in a [`Request`] or [`Response`]. +/// +/// Note that the same header key can appear twice. +#[derive(Clone, Debug, Default)] +pub struct Headers { + /// Name-value pairs. + pub headers: Vec<(String, String)>, +} + +impl Headers { + /// ``` + /// use ehttp::Request; + /// let request = Request { + /// headers: ehttp::Headers::new(&[ + /// ("Accept", "*/*"), + /// ("Content-Type", "text/plain; charset=utf-8"), + /// ]), + /// ..Request::get("https://www.example.com") + /// }; + /// ``` + pub fn new(headers: &[(&str, &str)]) -> Self { + Self { + headers: headers + .iter() + .map(|e| (e.0.to_owned(), e.1.to_owned())) + .collect(), + } + } + + /// Will add the key/value pair to the headers. + /// + /// If the key already exists, it will also be kept, + /// so the same key can appear twice. + pub fn insert(&mut self, key: impl ToString, value: impl ToString) { + self.headers.push((key.to_string(), value.to_string())); + } + + /// Get the value of the first header with the given key. + /// + /// The lookup is case-insentive. + pub fn get(&self, key: &str) -> Option<&str> { + let key = key.to_string().to_lowercase(); + self.headers + .iter() + .find(|(k, _)| k.to_lowercase() == key) + .map(|(_, v)| v.as_str()) + } + + /// Get all the values that match the given key. + /// + /// The lookup is case-insentive. + pub fn get_all(&self, key: &str) -> impl Iterator { + let key = key.to_string().to_lowercase(); + self.headers + .iter() + .filter(move |(k, _)| k.to_lowercase() == key) + .map(|(_, v)| v.as_str()) + } +} + +impl IntoIterator for Headers { + type Item = (String, String); + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.headers.into_iter() + } +} + +impl<'h> IntoIterator for &'h Headers { + type Item = &'h (String, String); + type IntoIter = std::slice::Iter<'h, (String, String)>; + + fn into_iter(self) -> Self::IntoIter { + self.headers.iter() + } +} + +// ---------------------------------------------------------------------------- /// A simple HTTP request. #[derive(Clone, Debug)] @@ -13,7 +91,7 @@ pub struct Request { pub body: Vec, /// ("Accept", "*/*"), … - pub headers: BTreeMap, + pub headers: Headers, } impl Request { @@ -24,7 +102,7 @@ impl Request { method: "GET".to_owned(), url: url.to_string(), body: vec![], - headers: crate::headers(&[("Accept", "*/*")]), + headers: Headers::new(&[("Accept", "*/*")]), } } @@ -35,7 +113,7 @@ impl Request { method: "HEAD".to_owned(), url: url.to_string(), body: vec![], - headers: crate::headers(&[("Accept", "*/*")]), + headers: Headers::new(&[("Accept", "*/*")]), } } @@ -46,7 +124,7 @@ impl Request { method: "POST".to_owned(), url: url.to_string(), body, - headers: crate::headers(&[ + headers: Headers::new(&[ ("Accept", "*/*"), ("Content-Type", "text/plain; charset=utf-8"), ]), @@ -55,7 +133,7 @@ impl Request { } /// Response from a completed HTTP request. -#[derive(Clone, Eq, PartialEq)] +#[derive(Clone)] pub struct Response { /// The URL we ended up at. This can differ from the request url when we have followed redirects. pub url: String, @@ -69,8 +147,8 @@ pub struct Response { /// Status text (e.g. "File not found" for status code `404`). pub status_text: String, - /// The returned headers. All header names are lower-case. - pub headers: BTreeMap, + /// The returned headers. + pub headers: Headers, /// The raw bytes of the response body. pub bytes: Vec, @@ -81,26 +159,36 @@ impl Response { std::str::from_utf8(&self.bytes).ok() } + /// Convenience for getting the `content-type` header. pub fn content_type(&self) -> Option<&str> { - self.headers.get("content-type").map(|s| s.as_str()) + self.headers.get("content-type") } } impl std::fmt::Debug for Response { fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + url, + ok, + status, + status_text, + headers, + bytes, + } = self; + fmt.debug_struct("Response") - .field("url", &self.url) - .field("ok", &self.ok) - .field("status", &self.status) - .field("status_text", &self.status_text) - // .field("bytes", &self.bytes) - .field("headers", &self.headers) + .field("url", url) + .field("ok", ok) + .field("status", status) + .field("status_text", status_text) + .field("headers", headers) + .field("bytes", &format!("{} bytes", bytes.len())) .finish_non_exhaustive() } } /// An HTTP response status line and headers used for the [`streaming`](crate::streaming) API. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug)] pub struct PartialResponse { /// The URL we ended up at. This can differ from the request url when we have followed redirects. pub url: String, @@ -114,8 +202,8 @@ pub struct PartialResponse { /// Status text (e.g. "File not found" for status code `404`). pub status_text: String, - /// The returned headers. All header names are lower-case. - pub headers: BTreeMap, + /// The returned headers. + pub headers: Headers, } impl PartialResponse { diff --git a/ehttp/src/web.rs b/ehttp/src/web.rs index a53824b..246529a 100644 --- a/ehttp/src/web.rs +++ b/ehttp/src/web.rs @@ -53,8 +53,8 @@ pub(crate) async fn fetch_base(request: &Request) -> Result Result = array.to_vec(); - let mut key = v[0] + let key = v[0] .as_string() .ok_or_else(|| JsValue::from_str("headers name"))?; let value = v[1] .as_string() .ok_or_else(|| JsValue::from_str("headers value"))?; - // for easy lookup - key.make_ascii_lowercase(); headers.insert(key, value); } diff --git a/example_eframe/src/app.rs b/example_eframe/src/app.rs index 0ec7f39..6e8d8a1 100644 --- a/example_eframe/src/app.rs +++ b/example_eframe/src/app.rs @@ -268,9 +268,9 @@ fn response_ui(ui: &mut egui::Ui, response: &ehttp::Response) { egui::Grid::new("response_headers") .spacing(egui::vec2(ui.spacing().item_spacing.x * 2.0, 0.0)) .show(ui, |ui| { - for header in &response.headers { - ui.label(header.0); - ui.label(header.1); + for (k, v) in &response.headers { + ui.label(k); + ui.label(v); ui.end_row(); } })