Skip to main content

ferrijs_fetch/
headers.rs

1//! WHATWG header list.
2//!
3//! An ordered `(name, value)` list with case-insensitive lookup. `get`
4//! combines same-name values with `, ` (per WHATWG), except `set-cookie`
5//! which combines with `\n` and is also readable split via
6//! [`Headers::get_set_cookie`] — matching Playwright's `RawHeaders`
7//! (`client/network.ts:931`). Insertion order and the original name
8//! casing are preserved for `iter`, so the engine ships headers to
9//! reqwest exactly as assembled.
10
11/// An ordered, case-insensitive header list.
12#[derive(Debug, Clone, Default)]
13pub struct Headers {
14  entries: Vec<(String, String)>,
15}
16
17/// RFC 7230 token — a valid header field name (WHATWG "header name").
18#[must_use]
19pub fn is_valid_name(name: &str) -> bool {
20  !name.is_empty()
21    && name.bytes().all(|b| {
22      matches!(b,
23        b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+'
24        | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
25        | b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z')
26    })
27}
28
29/// Whether an already-[normalized](normalize_value) value is a valid
30/// WHATWG "header value": HTAB, SP, VCHAR, form-feed or NBSP.
31#[must_use]
32pub fn is_valid_value(value: &str) -> bool {
33  value
34    .chars()
35    .all(|c| c == '\t' || c == ' ' || ('\u{21}'..='\u{7E}').contains(&c) || c == '\u{0C}' || c == '\u{00A0}')
36}
37
38/// WHATWG header value normalization (WPT `headers-normalize`): strip
39/// leading/trailing SP/HTAB, drop bare CR/LF, and treat an obs-fold
40/// (CRLF + SP/HTAB) as a single space; runs of inner whitespace collapse
41/// to the last one seen.
42#[must_use]
43pub fn normalize_value(text: &str) -> String {
44  let input = text.as_bytes();
45  let mut out: Vec<u8> = Vec::with_capacity(input.len());
46  let mut read = 0;
47  while read < input.len() && (input[read] == b' ' || input[read] == b'\t') {
48    read += 1;
49  }
50  let mut pending: Option<u8> = None;
51  while read < input.len() {
52    match input[read] {
53      b'\r'
54        if read + 2 < input.len()
55          && input[read + 1] == b'\n'
56          && (input[read + 2] == b' ' || input[read + 2] == b'\t') =>
57      {
58        pending = Some(input[read + 2]);
59        read += 3;
60      },
61      b'\r' | b'\n' => read += 1,
62      b' ' | b'\t' => {
63        pending = Some(input[read]);
64        read += 1;
65      },
66      byte => {
67        if let Some(ws) = pending.take()
68          && !out.is_empty()
69        {
70          out.push(ws);
71        }
72        out.push(byte);
73        read += 1;
74      },
75    }
76  }
77  while matches!(out.last(), Some(b' ' | b'\t')) {
78    out.pop();
79  }
80  String::from_utf8_lossy(&out).into_owned()
81}
82
83impl Headers {
84  #[must_use]
85  pub fn new() -> Self {
86    Self::default()
87  }
88
89  /// Build from an ordered `(name, value)` list verbatim (duplicates and
90  /// casing preserved) — the shape reqwest response headers arrive in.
91  #[must_use]
92  pub fn from_pairs(entries: Vec<(String, String)>) -> Self {
93    Self { entries }
94  }
95
96  fn position(&self, name: &str) -> Option<usize> {
97    self.entries.iter().position(|(k, _)| k.eq_ignore_ascii_case(name))
98  }
99
100  /// WHATWG combined value: all same-name values joined with `, `
101  /// (`\n` for `set-cookie`). `None` when the header is absent.
102  #[must_use]
103  pub fn get(&self, name: &str) -> Option<String> {
104    let values = self.get_all(name);
105    if values.is_empty() {
106      return None;
107    }
108    let sep = if name.eq_ignore_ascii_case("set-cookie") {
109      "\n"
110    } else {
111      ", "
112    };
113    Some(values.join(sep))
114  }
115
116  /// The first value for `name`, uncombined (case-insensitive).
117  #[must_use]
118  pub fn get_first(&self, name: &str) -> Option<&str> {
119    self.position(name).map(|i| self.entries[i].1.as_str())
120  }
121
122  /// Every value stored under `name`, in insertion order.
123  #[must_use]
124  pub fn get_all(&self, name: &str) -> Vec<&str> {
125    self
126      .entries
127      .iter()
128      .filter(|(k, _)| k.eq_ignore_ascii_case(name))
129      .map(|(_, v)| v.as_str())
130      .collect()
131  }
132
133  /// The `Set-Cookie` values, kept individually (WHATWG `getSetCookie`).
134  #[must_use]
135  pub fn get_set_cookie(&self) -> Vec<&str> {
136    self.get_all("set-cookie")
137  }
138
139  #[must_use]
140  pub fn contains(&self, name: &str) -> bool {
141    self.position(name).is_some()
142  }
143
144  /// Replace every value for `name` with a single `value` (WHATWG
145  /// `set`). The first slot keeps its position; later duplicates drop.
146  pub fn set(&mut self, name: &str, value: impl Into<String>) {
147    let value = value.into();
148    match self.position(name) {
149      Some(i) => {
150        self.entries[i].1 = value;
151        // Drop any later duplicates so `set` truly replaces all.
152        let lower = name.to_ascii_lowercase();
153        let mut seen = false;
154        self.entries.retain(|(k, _)| {
155          if k.eq_ignore_ascii_case(&lower) {
156            let keep = !seen;
157            seen = true;
158            keep
159          } else {
160            true
161          }
162        });
163      },
164      None => self.entries.push((name.to_string(), value)),
165    }
166  }
167
168  /// Set `name` to `value` only if it is not already present.
169  pub fn set_if_absent(&mut self, name: &str, value: impl Into<String>) {
170    if !self.contains(name) {
171      self.entries.push((name.to_string(), value.into()));
172    }
173  }
174
175  /// Append a value under `name` without removing existing ones (WHATWG
176  /// `append`).
177  pub fn append(&mut self, name: impl Into<String>, value: impl Into<String>) {
178    self.entries.push((name.into(), value.into()));
179  }
180
181  /// WHATWG "append", as the JS `Headers` class exposes it: `set-cookie`
182  /// is never combined and stays a separate entry; every other repeat
183  /// joins the existing value with `, ` in place. `name_lc` must already
184  /// be lowercased and `value` [normalized](normalize_value).
185  pub fn append_combined(&mut self, name_lc: String, value: String) {
186    if name_lc == "set-cookie" {
187      self.entries.push((name_lc, value));
188      return;
189    }
190    match self.position(&name_lc) {
191      Some(i) => self.entries[i].1 = format!("{}, {value}", self.entries[i].1),
192      None => self.entries.push((name_lc, value)),
193    }
194  }
195
196  /// Combined value of `name` joined with `, ` for EVERY header,
197  /// `set-cookie` included — what the WHATWG `Headers.get()` returns.
198  /// [`Self::get`] differs deliberately: it splits `set-cookie` with
199  /// `\n` to match Playwright's `RawHeaders`.
200  #[must_use]
201  pub fn get_joined(&self, name: &str) -> Option<String> {
202    let values = self.get_all(name);
203    (!values.is_empty()).then(|| values.join(", "))
204  }
205
206  /// The entries sorted by name — the order the WHATWG `Headers`
207  /// iterators yield. The sort is stable, so repeated `set-cookie`
208  /// entries keep insertion order.
209  #[must_use]
210  pub fn sorted_entries(&self) -> Vec<(String, String)> {
211    let mut sorted = self.entries.clone();
212    sorted.sort_by(|a, b| a.0.cmp(&b.0));
213    sorted
214  }
215
216  /// Remove every value stored under `name`.
217  pub fn remove(&mut self, name: &str) {
218    self.entries.retain(|(k, _)| !k.eq_ignore_ascii_case(name));
219  }
220
221  /// Iterate the `(name, value)` entries in insertion order.
222  pub fn iter(&self) -> impl Iterator<Item = &(String, String)> {
223    self.entries.iter()
224  }
225
226  #[must_use]
227  pub fn entries(&self) -> &[(String, String)] {
228    &self.entries
229  }
230
231  #[must_use]
232  pub fn into_pairs(self) -> Vec<(String, String)> {
233    self.entries
234  }
235
236  /// Flattened header object: lowercased names, each mapped to its
237  /// combined value, ordered by first appearance. Playwright's
238  /// `RawHeaders.headers()` (`client/network.ts:959`), which backs
239  /// `apiResponse.headers()`.
240  #[must_use]
241  pub fn to_object(&self) -> Vec<(String, String)> {
242    let mut out: Vec<(String, String)> = Vec::new();
243    for (name, _) in &self.entries {
244      let lower = name.to_ascii_lowercase();
245      if out.iter().any(|(k, _)| *k == lower) {
246        continue;
247      }
248      let combined = self.get(&lower).unwrap_or_default();
249      out.push((lower, combined));
250    }
251    out
252  }
253
254  #[must_use]
255  pub fn is_empty(&self) -> bool {
256    self.entries.is_empty()
257  }
258}
259
260impl From<Vec<(String, String)>> for Headers {
261  fn from(entries: Vec<(String, String)>) -> Self {
262    Self::from_pairs(entries)
263  }
264}
265
266#[cfg(test)]
267mod tests {
268  use super::*;
269
270  #[test]
271  fn combined_get_and_set_cookie_split() {
272    let mut h = Headers::new();
273    h.append("Accept", "a");
274    h.append("accept", "b");
275    assert_eq!(h.get("accept").as_deref(), Some("a, b"));
276    h.append("Set-Cookie", "x=1");
277    h.append("set-cookie", "y=2");
278    assert_eq!(h.get("set-cookie").as_deref(), Some("x=1\ny=2"));
279    assert_eq!(h.get_set_cookie(), vec!["x=1", "y=2"]);
280  }
281
282  #[test]
283  fn to_object_lowercases_combines_and_keeps_first_appearance_order() {
284    let h = Headers::from_pairs(vec![
285      ("Content-Type".into(), "text/plain".into()),
286      ("Set-Cookie".into(), "a=1".into()),
287      ("X-Dup".into(), "one".into()),
288      ("set-cookie".into(), "b=2".into()),
289      ("x-dup".into(), "two".into()),
290    ]);
291    assert_eq!(
292      h.to_object(),
293      vec![
294        ("content-type".to_string(), "text/plain".to_string()),
295        ("set-cookie".to_string(), "a=1\nb=2".to_string()),
296        ("x-dup".to_string(), "one, two".to_string()),
297      ]
298    );
299  }
300
301  #[test]
302  fn set_replaces_all_same_name() {
303    let mut h = Headers::from_pairs(vec![
304      ("x".into(), "1".into()),
305      ("y".into(), "2".into()),
306      ("X".into(), "3".into()),
307    ]);
308    h.set("x", "9");
309    assert_eq!(h.get_all("x"), vec!["9"]);
310    // Order of the first slot is preserved, other headers untouched.
311    assert_eq!(h.entries()[0], ("x".into(), "9".into()));
312    assert_eq!(h.get_first("y"), Some("2"));
313  }
314
315  #[test]
316  fn set_if_absent_and_remove() {
317    let mut h = Headers::new();
318    h.set_if_absent("content-type", "application/json");
319    h.set_if_absent("content-type", "text/plain");
320    assert_eq!(h.get_first("content-type"), Some("application/json"));
321    h.remove("content-type");
322    assert!(!h.contains("content-type"));
323  }
324}