Skip to main content

cpex_core/extensions/
http.rs

1// Location: ./crates/cpex-core/src/extensions/http.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// HttpExtension — HTTP request and response headers.
7// Mirrors cpex/framework/extensions/http.py.
8
9use std::collections::HashMap;
10
11use serde::{Deserialize, Serialize};
12
13/// HTTP-related extensions.
14///
15/// Carries both request and response headers separately. The host
16/// populates what's available at each hook point:
17/// - Pre-invoke: `request_headers` filled, `response_headers` empty
18/// - Post-invoke: both filled (request from original, response from upstream)
19///
20/// Capability-gated: requires `read_headers` to see, `write_headers`
21/// to modify (both request and response).
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23pub struct HttpExtension {
24    /// HTTP request headers (inbound from caller).
25    #[serde(default)]
26    pub request_headers: HashMap<String, String>,
27
28    /// HTTP response headers (from upstream, populated post-invoke).
29    #[serde(default)]
30    pub response_headers: HashMap<String, String>,
31}
32
33impl HttpExtension {
34    // -- Request header helpers --
35
36    /// Set a request header (overwrites if exists).
37    pub fn set_request_header(&mut self, name: impl Into<String>, value: impl Into<String>) {
38        self.request_headers.insert(name.into(), value.into());
39    }
40
41    /// Get a request header value (case-insensitive lookup).
42    pub fn get_request_header(&self, name: &str) -> Option<&str> {
43        get_header_ci(&self.request_headers, name)
44    }
45
46    /// Check if a request header exists (case-insensitive).
47    pub fn has_request_header(&self, name: &str) -> bool {
48        self.get_request_header(name).is_some()
49    }
50
51    /// Add request header only if it doesn't exist. Returns true if added.
52    pub fn add_request_header(
53        &mut self,
54        name: impl Into<String>,
55        value: impl Into<String>,
56    ) -> bool {
57        let name = name.into();
58        if self.has_request_header(&name) {
59            return false;
60        }
61        self.request_headers.insert(name, value.into());
62        true
63    }
64
65    /// Remove a request header by name. Returns the removed value.
66    pub fn remove_request_header(&mut self, name: &str) -> Option<String> {
67        remove_header_ci(&mut self.request_headers, name)
68    }
69
70    // -- Response header helpers --
71
72    /// Set a response header (overwrites if exists).
73    pub fn set_response_header(&mut self, name: impl Into<String>, value: impl Into<String>) {
74        self.response_headers.insert(name.into(), value.into());
75    }
76
77    /// Get a response header value (case-insensitive lookup).
78    pub fn get_response_header(&self, name: &str) -> Option<&str> {
79        get_header_ci(&self.response_headers, name)
80    }
81
82    /// Check if a response header exists (case-insensitive).
83    pub fn has_response_header(&self, name: &str) -> bool {
84        self.get_response_header(name).is_some()
85    }
86
87    // -- Convenience aliases (backward-compatible, default to request) --
88
89    /// Set a header on request headers (convenience alias).
90    pub fn set_header(&mut self, name: impl Into<String>, value: impl Into<String>) {
91        self.set_request_header(name, value);
92    }
93
94    /// Get a header from request headers (convenience alias, case-insensitive).
95    pub fn get_header(&self, name: &str) -> Option<&str> {
96        self.get_request_header(name)
97    }
98
99    /// Check if a request header exists (convenience alias).
100    pub fn has_header(&self, name: &str) -> bool {
101        self.has_request_header(name)
102    }
103}
104
105// -- Internal helpers --
106
107fn get_header_ci<'a>(headers: &'a HashMap<String, String>, name: &str) -> Option<&'a str> {
108    let lower = name.to_lowercase();
109    headers
110        .iter()
111        .find(|(k, _)| k.to_lowercase() == lower)
112        .map(|(_, v)| v.as_str())
113}
114
115fn remove_header_ci(headers: &mut HashMap<String, String>, name: &str) -> Option<String> {
116    let lower = name.to_lowercase();
117    let key = headers.keys().find(|k| k.to_lowercase() == lower).cloned();
118    key.and_then(|k| headers.remove(&k))
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_request_header_set_and_get() {
127        let mut http = HttpExtension::default();
128        http.set_request_header("Content-Type", "application/json");
129        assert_eq!(
130            http.get_request_header("Content-Type"),
131            Some("application/json")
132        );
133    }
134
135    #[test]
136    fn test_request_header_case_insensitive() {
137        let mut http = HttpExtension::default();
138        http.set_request_header("Authorization", "Bearer tok");
139        assert_eq!(http.get_request_header("authorization"), Some("Bearer tok"));
140        assert_eq!(http.get_request_header("AUTHORIZATION"), Some("Bearer tok"));
141    }
142
143    #[test]
144    fn test_response_header_set_and_get() {
145        let mut http = HttpExtension::default();
146        http.set_response_header("Content-Type", "text/html");
147        assert_eq!(http.get_response_header("Content-Type"), Some("text/html"));
148        assert!(http.has_response_header("content-type"));
149    }
150
151    #[test]
152    fn test_request_and_response_independent() {
153        let mut http = HttpExtension::default();
154        http.set_request_header("Authorization", "Bearer req-tok");
155        http.set_response_header("X-Response-Time", "42ms");
156
157        // Request headers don't leak into response
158        assert!(http.get_response_header("Authorization").is_none());
159        // Response headers don't leak into request
160        assert!(http.get_request_header("X-Response-Time").is_none());
161    }
162
163    #[test]
164    fn test_convenience_aliases_default_to_request() {
165        let mut http = HttpExtension::default();
166        http.set_header("X-Custom", "value");
167        assert_eq!(http.get_header("X-Custom"), Some("value"));
168        assert!(http.has_header("X-Custom"));
169        // Verify it went to request_headers
170        assert_eq!(http.get_request_header("X-Custom"), Some("value"));
171    }
172
173    #[test]
174    fn test_add_request_header_only_if_absent() {
175        let mut http = HttpExtension::default();
176        assert!(http.add_request_header("X-New", "first"));
177        assert!(!http.add_request_header("X-New", "second"));
178        assert_eq!(http.get_request_header("X-New"), Some("first"));
179    }
180
181    #[test]
182    fn test_remove_request_header() {
183        let mut http = HttpExtension::default();
184        http.set_request_header("X-Remove", "value");
185        let removed = http.remove_request_header("x-remove");
186        assert_eq!(removed, Some("value".to_string()));
187        assert!(!http.has_request_header("X-Remove"));
188    }
189
190    #[test]
191    fn test_serde_roundtrip() {
192        let mut http = HttpExtension::default();
193        http.set_request_header("Authorization", "Bearer tok");
194        http.set_request_header("X-Request-ID", "req-123");
195        http.set_response_header("Content-Type", "application/json");
196        http.set_response_header("X-Response-Time", "15ms");
197
198        let json = serde_json::to_string(&http).unwrap();
199        let deserialized: HttpExtension = serde_json::from_str(&json).unwrap();
200
201        assert_eq!(
202            deserialized.get_request_header("Authorization"),
203            Some("Bearer tok")
204        );
205        assert_eq!(
206            deserialized.get_response_header("Content-Type"),
207            Some("application/json")
208        );
209    }
210}