Skip to main content

tokn_headers/
name.rs

1//! Case- and order-preserving HTTP header name.
2//!
3//! Internally stores both the original-cased `SmolStr` (for fidelity in logs,
4//! golden snapshots, and outbound serialization) and a lowercase `SmolStr`
5//! used for [`Eq`] / [`Hash`] comparisons. This lets us round-trip header
6//! casing without paying for case-insensitive comparison at lookup time.
7//!
8//! Construct compile-time constants with [`HeaderName::new_static`]:
9//!
10//! ```
11//! use tokn_headers::HeaderName;
12//! const AUTH: HeaderName = HeaderName::new_static("Authorization", "authorization");
13//! assert_eq!(AUTH.original(), "Authorization");
14//! assert_eq!(AUTH.as_str(), "authorization");
15//! ```
16
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18use smol_str::SmolStr;
19use std::fmt;
20use std::hash::{Hash, Hasher};
21
22/// A header name that preserves its original ASCII casing while comparing
23/// case-insensitively.
24#[derive(Debug, Clone)]
25pub struct HeaderName {
26  original: SmolStr,
27  lower: SmolStr,
28}
29
30impl Serialize for HeaderName {
31  fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
32    serializer.serialize_str(&self.original)
33  }
34}
35
36impl<'de> Deserialize<'de> for HeaderName {
37  fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
38    let s = SmolStr::deserialize(deserializer)?;
39    Ok(HeaderName::new(s))
40  }
41}
42
43impl HeaderName {
44  /// Construct a header name from a `'static` original/lowercase pair.
45  ///
46  /// Both arguments must agree byte-for-byte modulo ASCII case. Use this for
47  /// the entries in [`crate::keys`].
48  pub const fn new_static(original: &'static str, lower: &'static str) -> Self {
49    Self {
50      original: SmolStr::new_static(original),
51      lower: SmolStr::new_static(lower),
52    }
53  }
54
55  /// Construct a header name from an arbitrary string. The original casing is
56  /// preserved; the lowercase form used for comparisons is computed eagerly.
57  pub fn new(name: impl Into<SmolStr>) -> Self {
58    let original: SmolStr = name.into();
59    let lower = if original.bytes().all(|b| !b.is_ascii_uppercase()) {
60      original.clone()
61    } else {
62      SmolStr::from(original.to_ascii_lowercase())
63    };
64    Self { original, lower }
65  }
66
67  /// The original-cased name as inserted by the caller.
68  pub fn original(&self) -> &str {
69    &self.original
70  }
71
72  /// The lowercase canonical form. Matches the on-the-wire HTTP/2 form.
73  pub fn as_str(&self) -> &str {
74    &self.lower
75  }
76}
77
78impl PartialEq for HeaderName {
79  fn eq(&self, other: &Self) -> bool {
80    self.lower == other.lower
81  }
82}
83
84impl Eq for HeaderName {}
85
86impl Hash for HeaderName {
87  fn hash<H: Hasher>(&self, state: &mut H) {
88    self.lower.hash(state);
89  }
90}
91
92impl fmt::Display for HeaderName {
93  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94    f.write_str(&self.original)
95  }
96}
97
98impl From<&str> for HeaderName {
99  fn from(value: &str) -> Self {
100    Self::new(value)
101  }
102}
103
104impl From<String> for HeaderName {
105  fn from(value: String) -> Self {
106    Self::new(SmolStr::from(value))
107  }
108}
109
110impl From<SmolStr> for HeaderName {
111  fn from(value: SmolStr) -> Self {
112    Self::new(value)
113  }
114}
115
116impl From<&HeaderName> for HeaderName {
117  fn from(value: &HeaderName) -> Self {
118    value.clone()
119  }
120}
121
122#[cfg(test)]
123mod tests {
124  use super::*;
125
126  #[test]
127  fn case_insensitive_equality_preserves_original() {
128    let a = HeaderName::new("Authorization");
129    let b = HeaderName::new("authorization");
130    let c = HeaderName::new("AUTHORIZATION");
131    assert_eq!(a, b);
132    assert_eq!(b, c);
133    assert_eq!(a.original(), "Authorization");
134    assert_eq!(b.original(), "authorization");
135    assert_eq!(c.original(), "AUTHORIZATION");
136  }
137
138  #[test]
139  fn lowercase_input_avoids_extra_allocation() {
140    let n = HeaderName::new("content-type");
141    assert_eq!(n.original(), "content-type");
142    assert_eq!(n.as_str(), "content-type");
143  }
144
145  #[test]
146  fn display_uses_original_case() {
147    let n = HeaderName::new("X-Session-Id");
148    assert_eq!(format!("{n}"), "X-Session-Id");
149  }
150
151  #[test]
152  fn hash_matches_equality_under_case_change() {
153    use std::collections::hash_map::DefaultHasher;
154    fn h(n: &HeaderName) -> u64 {
155      let mut s = DefaultHasher::new();
156      n.hash(&mut s);
157      s.finish()
158    }
159    assert_eq!(h(&HeaderName::new("Foo-Bar")), h(&HeaderName::new("foo-bar")));
160  }
161
162  #[test]
163  fn new_static_round_trips_both_cases() {
164    const N: HeaderName = HeaderName::new_static("X-Request-Id", "x-request-id");
165    assert_eq!(N.original(), "X-Request-Id");
166    assert_eq!(N.as_str(), "x-request-id");
167    assert_eq!(N, HeaderName::new("X-REQUEST-ID"));
168  }
169}