Skip to main content

rustlavel_http/
headers.rs

1//! Case-insensitive header storage.
2//!
3//! Names are lowercased on insert, which is how HTTP/2 sends them anyway, and
4//! makes lookups a plain map hit rather than a scan.
5
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
9pub struct Headers {
10    entries: BTreeMap<String, Vec<String>>,
11}
12
13impl Headers {
14    pub fn new() -> Self {
15        Self::default()
16    }
17
18    /// Replace any existing values for this name.
19    pub fn set(&mut self, name: &str, value: impl Into<String>) {
20        self.entries.insert(name.to_ascii_lowercase(), vec![value.into()]);
21    }
22
23    /// Add a value, keeping the ones already present. Used for `Set-Cookie`,
24    /// which is the one header that legitimately repeats.
25    pub fn append(&mut self, name: &str, value: impl Into<String>) {
26        self.entries.entry(name.to_ascii_lowercase()).or_default().push(value.into());
27    }
28
29    pub fn get(&self, name: &str) -> Option<&str> {
30        self.entries.get(&name.to_ascii_lowercase()).and_then(|v| v.first()).map(String::as_str)
31    }
32
33    pub fn get_all(&self, name: &str) -> &[String] {
34        self.entries.get(&name.to_ascii_lowercase()).map_or(&[], Vec::as_slice)
35    }
36
37    pub fn contains(&self, name: &str) -> bool {
38        self.entries.contains_key(&name.to_ascii_lowercase())
39    }
40
41    pub fn remove(&mut self, name: &str) {
42        self.entries.remove(&name.to_ascii_lowercase());
43    }
44
45    pub fn is_empty(&self) -> bool {
46        self.entries.is_empty()
47    }
48
49    /// Iterate every (name, value) pair, repeated headers included.
50    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
51        self.entries
52            .iter()
53            .flat_map(|(name, values)| values.iter().map(move |value| (name.as_str(), value.as_str())))
54    }
55
56    /// The parsed `Content-Length`, when present and well-formed.
57    pub fn content_length(&self) -> Option<usize> {
58        self.get("content-length")?.trim().parse().ok()
59    }
60
61    /// The media type without parameters: `application/json; charset=utf-8` → `application/json`.
62    pub fn content_type(&self) -> Option<&str> {
63        let value = self.get("content-type")?;
64        Some(value.split(';').next().unwrap_or(value).trim())
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn lookups_ignore_case() {
74        let mut headers = Headers::new();
75        headers.set("Content-Type", "application/json");
76
77        assert_eq!(headers.get("content-type"), Some("application/json"));
78        assert_eq!(headers.get("CONTENT-TYPE"), Some("application/json"));
79        assert!(headers.contains("Content-Type"));
80    }
81
82    #[test]
83    fn set_replaces_while_append_accumulates() {
84        let mut headers = Headers::new();
85        headers.set("x-tag", "one");
86        headers.set("x-tag", "two");
87        assert_eq!(headers.get_all("x-tag"), ["two"]);
88
89        headers.append("set-cookie", "a=1");
90        headers.append("set-cookie", "b=2");
91        assert_eq!(headers.get_all("set-cookie").len(), 2);
92    }
93
94    #[test]
95    fn parses_content_metadata() {
96        let mut headers = Headers::new();
97        headers.set("content-type", "application/json; charset=utf-8");
98        headers.set("content-length", "42");
99
100        assert_eq!(headers.content_type(), Some("application/json"));
101        assert_eq!(headers.content_length(), Some(42));
102    }
103}