Skip to main content

oxihttp_core/
header_ext.rs

1//! Typed header extension traits for `HeaderMap`.
2
3use http::{HeaderMap, HeaderValue};
4
5use crate::content_type::ContentType;
6use crate::OxiHttpError;
7
8/// Extension trait providing typed accessors for common HTTP headers.
9pub trait HeaderMapExt {
10    /// Get the `Content-Type` header as a parsed `ContentType`.
11    fn content_type(&self) -> Option<ContentType>;
12
13    /// Get the `Content-Length` header as a `u64`.
14    fn content_length(&self) -> Option<u64>;
15
16    /// Get the `Authorization` header value.
17    fn authorization(&self) -> Option<&str>;
18
19    /// Get the `Accept` header value.
20    fn accept(&self) -> Option<&str>;
21
22    /// Get the `Host` header value.
23    fn host(&self) -> Option<&str>;
24
25    /// Get the `User-Agent` header value.
26    fn user_agent(&self) -> Option<&str>;
27
28    /// Get the `Cache-Control` header value.
29    fn cache_control(&self) -> Option<&str>;
30
31    /// Get the `ETag` header value.
32    fn etag(&self) -> Option<&str>;
33
34    /// Get the `If-None-Match` header value.
35    fn if_none_match(&self) -> Option<&str>;
36
37    /// Get the `If-Modified-Since` header value.
38    fn if_modified_since(&self) -> Option<&str>;
39
40    /// Get the raw `Cookie` header value (not parsed).
41    fn cookie_header(&self) -> Option<&str>;
42
43    /// Get the `Location` header value.
44    fn location(&self) -> Option<&str>;
45
46    /// Get the `Referer` header value.
47    fn referer(&self) -> Option<&str>;
48
49    /// Set a typed `Content-Type` header.
50    fn set_content_type(&mut self, ct: &ContentType) -> Result<(), OxiHttpError>;
51
52    /// Set a `Content-Length` header.
53    fn set_content_length(&mut self, len: u64) -> Result<(), OxiHttpError>;
54
55    /// Set a `Bearer` authorization header.
56    fn set_bearer_auth(&mut self, token: &str) -> Result<(), OxiHttpError>;
57
58    /// Set a `Basic` authorization header from username and password.
59    fn set_basic_auth(
60        &mut self,
61        username: &str,
62        password: Option<&str>,
63    ) -> Result<(), OxiHttpError>;
64
65    /// Set the `Cache-Control` header.
66    fn set_cache_control(&mut self, value: &str) -> Result<(), OxiHttpError>;
67
68    /// Set the `ETag` header.
69    fn set_etag(&mut self, value: &str) -> Result<(), OxiHttpError>;
70
71    /// Set the `Location` header.
72    fn set_location(&mut self, value: &str) -> Result<(), OxiHttpError>;
73
74    /// Append a `Set-Cookie` header value (uses `append` per RFC 6265).
75    fn set_cookie_header(&mut self, value: &str) -> Result<(), OxiHttpError>;
76}
77
78impl HeaderMapExt for HeaderMap {
79    fn content_type(&self) -> Option<ContentType> {
80        let val = self.get(http::header::CONTENT_TYPE)?;
81        val.to_str().ok()?.parse().ok()
82    }
83
84    fn content_length(&self) -> Option<u64> {
85        let val = self.get(http::header::CONTENT_LENGTH)?;
86        val.to_str().ok()?.parse().ok()
87    }
88
89    fn authorization(&self) -> Option<&str> {
90        self.get(http::header::AUTHORIZATION)?.to_str().ok()
91    }
92
93    fn accept(&self) -> Option<&str> {
94        self.get(http::header::ACCEPT)?.to_str().ok()
95    }
96
97    fn host(&self) -> Option<&str> {
98        self.get(http::header::HOST)?.to_str().ok()
99    }
100
101    fn user_agent(&self) -> Option<&str> {
102        self.get(http::header::USER_AGENT)?.to_str().ok()
103    }
104
105    fn cache_control(&self) -> Option<&str> {
106        self.get(http::header::CACHE_CONTROL)?.to_str().ok()
107    }
108
109    fn etag(&self) -> Option<&str> {
110        self.get(http::header::ETAG)?.to_str().ok()
111    }
112
113    fn if_none_match(&self) -> Option<&str> {
114        self.get(http::header::IF_NONE_MATCH)?.to_str().ok()
115    }
116
117    fn if_modified_since(&self) -> Option<&str> {
118        self.get(http::header::IF_MODIFIED_SINCE)?.to_str().ok()
119    }
120
121    fn cookie_header(&self) -> Option<&str> {
122        self.get(http::header::COOKIE)?.to_str().ok()
123    }
124
125    fn location(&self) -> Option<&str> {
126        self.get(http::header::LOCATION)?.to_str().ok()
127    }
128
129    fn referer(&self) -> Option<&str> {
130        self.get(http::header::REFERER)?.to_str().ok()
131    }
132
133    fn set_content_type(&mut self, ct: &ContentType) -> Result<(), OxiHttpError> {
134        let val = HeaderValue::from_str(&ct.to_string())
135            .map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
136        self.insert(http::header::CONTENT_TYPE, val);
137        Ok(())
138    }
139
140    fn set_content_length(&mut self, len: u64) -> Result<(), OxiHttpError> {
141        let val = HeaderValue::from_str(&len.to_string())
142            .map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
143        self.insert(http::header::CONTENT_LENGTH, val);
144        Ok(())
145    }
146
147    fn set_bearer_auth(&mut self, token: &str) -> Result<(), OxiHttpError> {
148        let val = HeaderValue::from_str(&format!("Bearer {token}"))
149            .map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
150        self.insert(http::header::AUTHORIZATION, val);
151        Ok(())
152    }
153
154    fn set_basic_auth(
155        &mut self,
156        username: &str,
157        password: Option<&str>,
158    ) -> Result<(), OxiHttpError> {
159        use std::io::Write;
160        let mut buf = Vec::new();
161        let _ = write!(buf, "{username}:");
162        if let Some(pw) = password {
163            let _ = write!(buf, "{pw}");
164        }
165        // Base64 encode without external dependency - simple implementation
166        let encoded = base64_encode(&buf);
167        let val = HeaderValue::from_str(&format!("Basic {encoded}"))
168            .map_err(|e| OxiHttpError::InvalidHeader(e.to_string()))?;
169        self.insert(http::header::AUTHORIZATION, val);
170        Ok(())
171    }
172
173    fn set_cache_control(&mut self, value: &str) -> Result<(), OxiHttpError> {
174        let hv = HeaderValue::from_str(value).map_err(|_| {
175            OxiHttpError::InvalidHeader(format!("invalid Cache-Control value: {value}"))
176        })?;
177        self.insert(http::header::CACHE_CONTROL, hv);
178        Ok(())
179    }
180
181    fn set_etag(&mut self, value: &str) -> Result<(), OxiHttpError> {
182        let hv = HeaderValue::from_str(value)
183            .map_err(|_| OxiHttpError::InvalidHeader(format!("invalid ETag value: {value}")))?;
184        self.insert(http::header::ETAG, hv);
185        Ok(())
186    }
187
188    fn set_location(&mut self, value: &str) -> Result<(), OxiHttpError> {
189        let hv = HeaderValue::from_str(value)
190            .map_err(|_| OxiHttpError::InvalidHeader(format!("invalid Location value: {value}")))?;
191        self.insert(http::header::LOCATION, hv);
192        Ok(())
193    }
194
195    fn set_cookie_header(&mut self, value: &str) -> Result<(), OxiHttpError> {
196        let hv = HeaderValue::from_str(value).map_err(|_| {
197            OxiHttpError::InvalidHeader(format!("invalid Set-Cookie value: {value}"))
198        })?;
199        self.append(http::header::SET_COOKIE, hv);
200        Ok(())
201    }
202}
203
204/// Simple base64 encoding (RFC 4648) without external dependency.
205fn base64_encode(data: &[u8]) -> String {
206    const CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
207    let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
208    for chunk in data.chunks(3) {
209        let b0 = chunk[0] as u32;
210        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
211        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
212        let triple = (b0 << 16) | (b1 << 8) | b2;
213
214        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
215        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
216        if chunk.len() > 1 {
217            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
218        } else {
219            result.push('=');
220        }
221        if chunk.len() > 2 {
222            result.push(CHARS[(triple & 0x3F) as usize] as char);
223        } else {
224            result.push('=');
225        }
226    }
227    result
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_content_type_accessor() {
236        let mut headers = HeaderMap::new();
237        headers.insert(
238            http::header::CONTENT_TYPE,
239            HeaderValue::from_static("application/json"),
240        );
241        assert_eq!(headers.content_type(), Some(ContentType::Json));
242    }
243
244    #[test]
245    fn test_content_length_accessor() {
246        let mut headers = HeaderMap::new();
247        headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from_static("42"));
248        assert_eq!(headers.content_length(), Some(42));
249    }
250
251    #[test]
252    fn test_set_bearer_auth() {
253        let mut headers = HeaderMap::new();
254        headers.set_bearer_auth("mytoken123").expect("set bearer");
255        assert_eq!(headers.authorization(), Some("Bearer mytoken123"));
256    }
257
258    #[test]
259    fn test_set_basic_auth() {
260        let mut headers = HeaderMap::new();
261        headers
262            .set_basic_auth("user", Some("pass"))
263            .expect("set basic");
264        let auth = headers.authorization().expect("auth present");
265        assert!(auth.starts_with("Basic "));
266        // "user:pass" base64 = "dXNlcjpwYXNz"
267        assert_eq!(auth, "Basic dXNlcjpwYXNz");
268    }
269
270    #[test]
271    fn test_set_content_type() {
272        let mut headers = HeaderMap::new();
273        headers
274            .set_content_type(&ContentType::Json)
275            .expect("set ct");
276        assert_eq!(headers.content_type(), Some(ContentType::Json));
277    }
278
279    #[test]
280    fn test_host_accessor() {
281        let mut headers = HeaderMap::new();
282        headers.insert(http::header::HOST, HeaderValue::from_static("example.com"));
283        assert_eq!(headers.host(), Some("example.com"));
284    }
285
286    #[test]
287    fn test_base64_encode() {
288        assert_eq!(base64_encode(b""), "");
289        assert_eq!(base64_encode(b"f"), "Zg==");
290        assert_eq!(base64_encode(b"fo"), "Zm8=");
291        assert_eq!(base64_encode(b"foo"), "Zm9v");
292        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
293        assert_eq!(base64_encode(b"user:pass"), "dXNlcjpwYXNz");
294    }
295
296    #[test]
297    fn test_cache_control_getter_setter() {
298        let mut headers = HeaderMap::new();
299        headers
300            .set_cache_control("no-store, max-age=0")
301            .expect("set cache-control");
302        assert_eq!(headers.cache_control(), Some("no-store, max-age=0"));
303    }
304
305    #[test]
306    fn test_etag_getter_setter() {
307        let mut headers = HeaderMap::new();
308        headers
309            .set_etag("\"33a64df551425fcc55e4d42a148795d9f25f89d4\"")
310            .expect("set etag");
311        assert_eq!(
312            headers.etag(),
313            Some("\"33a64df551425fcc55e4d42a148795d9f25f89d4\"")
314        );
315    }
316
317    #[test]
318    fn test_if_none_match_getter() {
319        let mut headers = HeaderMap::new();
320        headers.insert(
321            http::header::IF_NONE_MATCH,
322            HeaderValue::from_static("\"abc123\""),
323        );
324        assert_eq!(headers.if_none_match(), Some("\"abc123\""));
325    }
326
327    #[test]
328    fn test_if_modified_since_getter() {
329        let mut headers = HeaderMap::new();
330        headers.insert(
331            http::header::IF_MODIFIED_SINCE,
332            HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"),
333        );
334        assert_eq!(
335            headers.if_modified_since(),
336            Some("Wed, 21 Oct 2015 07:28:00 GMT")
337        );
338    }
339
340    #[test]
341    fn test_cookie_header_getter() {
342        let mut headers = HeaderMap::new();
343        headers.insert(
344            http::header::COOKIE,
345            HeaderValue::from_static("session=abc"),
346        );
347        assert_eq!(headers.cookie_header(), Some("session=abc"));
348    }
349
350    #[test]
351    fn test_location_getter_setter() {
352        let mut headers = HeaderMap::new();
353        headers
354            .set_location("https://example.com/new-path")
355            .expect("set location");
356        assert_eq!(headers.location(), Some("https://example.com/new-path"));
357    }
358
359    #[test]
360    fn test_referer_getter() {
361        let mut headers = HeaderMap::new();
362        headers.insert(
363            http::header::REFERER,
364            HeaderValue::from_static("https://example.com/page"),
365        );
366        assert_eq!(headers.referer(), Some("https://example.com/page"));
367    }
368
369    #[test]
370    fn test_set_cookie_header_appends() {
371        let mut headers = HeaderMap::new();
372        headers
373            .set_cookie_header("session=abc; Path=/; HttpOnly")
374            .expect("first set-cookie");
375        headers
376            .set_cookie_header("theme=dark; Path=/; Max-Age=31536000")
377            .expect("second set-cookie");
378        let values: Vec<&str> = headers
379            .get_all(http::header::SET_COOKIE)
380            .iter()
381            .filter_map(|v| v.to_str().ok())
382            .collect();
383        assert_eq!(values.len(), 2);
384        assert!(values.contains(&"session=abc; Path=/; HttpOnly"));
385        assert!(values.contains(&"theme=dark; Path=/; Max-Age=31536000"));
386    }
387}