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    /// HTTP request method (e.g. `GET`, `POST`). Set by the host when
33    /// the request is HTTP; `None` for non-HTTP transports.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub method: Option<String>,
36
37    /// HTTP request path (e.g. `/api/v1/widgets`). Excludes the query
38    /// string unless the host chooses to include it.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub path: Option<String>,
41
42    /// HTTP request authority/host. The host MUST populate this from a
43    /// validated authority (e.g. the HTTP/2 `:authority` pseudo-header),
44    /// never a raw client-supplied `Host` header, so host-based policy
45    /// is not bypassable.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub host: Option<String>,
48
49    /// HTTP request scheme (`http` / `https`).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub scheme: Option<String>,
52}
53
54impl HttpExtension {
55    // -- Request header helpers --
56
57    /// Set a request header (overwrites if exists).
58    pub fn set_request_header(&mut self, name: impl Into<String>, value: impl Into<String>) {
59        self.request_headers.insert(name.into(), value.into());
60    }
61
62    /// Get a request header value (case-insensitive lookup).
63    pub fn get_request_header(&self, name: &str) -> Option<&str> {
64        get_header_ci(&self.request_headers, name)
65    }
66
67    /// Check if a request header exists (case-insensitive).
68    pub fn has_request_header(&self, name: &str) -> bool {
69        self.get_request_header(name).is_some()
70    }
71
72    /// Add request header only if it doesn't exist. Returns true if added.
73    pub fn add_request_header(
74        &mut self,
75        name: impl Into<String>,
76        value: impl Into<String>,
77    ) -> bool {
78        let name = name.into();
79        if self.has_request_header(&name) {
80            return false;
81        }
82        self.request_headers.insert(name, value.into());
83        true
84    }
85
86    /// Remove a request header by name. Returns the removed value.
87    pub fn remove_request_header(&mut self, name: &str) -> Option<String> {
88        remove_header_ci(&mut self.request_headers, name)
89    }
90
91    // -- Response header helpers --
92
93    /// Set a response header (overwrites if exists).
94    pub fn set_response_header(&mut self, name: impl Into<String>, value: impl Into<String>) {
95        self.response_headers.insert(name.into(), value.into());
96    }
97
98    /// Get a response header value (case-insensitive lookup).
99    pub fn get_response_header(&self, name: &str) -> Option<&str> {
100        get_header_ci(&self.response_headers, name)
101    }
102
103    /// Check if a response header exists (case-insensitive).
104    pub fn has_response_header(&self, name: &str) -> bool {
105        self.get_response_header(name).is_some()
106    }
107
108    // -- Convenience aliases (backward-compatible, default to request) --
109
110    /// Set a header on request headers (convenience alias).
111    pub fn set_header(&mut self, name: impl Into<String>, value: impl Into<String>) {
112        self.set_request_header(name, value);
113    }
114
115    /// Get a header from request headers (convenience alias, case-insensitive).
116    pub fn get_header(&self, name: &str) -> Option<&str> {
117        self.get_request_header(name)
118    }
119
120    /// Check if a request header exists (convenience alias).
121    pub fn has_header(&self, name: &str) -> bool {
122        self.has_request_header(name)
123    }
124}
125
126// -- Internal helpers --
127
128fn get_header_ci<'a>(headers: &'a HashMap<String, String>, name: &str) -> Option<&'a str> {
129    let lower = name.to_lowercase();
130    headers
131        .iter()
132        .find(|(k, _)| k.to_lowercase() == lower)
133        .map(|(_, v)| v.as_str())
134}
135
136fn remove_header_ci(headers: &mut HashMap<String, String>, name: &str) -> Option<String> {
137    let lower = name.to_lowercase();
138    let key = headers.keys().find(|k| k.to_lowercase() == lower).cloned();
139    key.and_then(|k| headers.remove(&k))
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_request_header_set_and_get() {
148        let mut http = HttpExtension::default();
149        http.set_request_header("Content-Type", "application/json");
150        assert_eq!(
151            http.get_request_header("Content-Type"),
152            Some("application/json")
153        );
154    }
155
156    #[test]
157    fn test_request_header_case_insensitive() {
158        let mut http = HttpExtension::default();
159        http.set_request_header("Authorization", "Bearer tok");
160        assert_eq!(http.get_request_header("authorization"), Some("Bearer tok"));
161        assert_eq!(http.get_request_header("AUTHORIZATION"), Some("Bearer tok"));
162    }
163
164    #[test]
165    fn test_response_header_set_and_get() {
166        let mut http = HttpExtension::default();
167        http.set_response_header("Content-Type", "text/html");
168        assert_eq!(http.get_response_header("Content-Type"), Some("text/html"));
169        assert!(http.has_response_header("content-type"));
170    }
171
172    #[test]
173    fn test_request_and_response_independent() {
174        let mut http = HttpExtension::default();
175        http.set_request_header("Authorization", "Bearer req-tok");
176        http.set_response_header("X-Response-Time", "42ms");
177
178        // Request headers don't leak into response
179        assert!(http.get_response_header("Authorization").is_none());
180        // Response headers don't leak into request
181        assert!(http.get_request_header("X-Response-Time").is_none());
182    }
183
184    #[test]
185    fn test_convenience_aliases_default_to_request() {
186        let mut http = HttpExtension::default();
187        http.set_header("X-Custom", "value");
188        assert_eq!(http.get_header("X-Custom"), Some("value"));
189        assert!(http.has_header("X-Custom"));
190        // Verify it went to request_headers
191        assert_eq!(http.get_request_header("X-Custom"), Some("value"));
192    }
193
194    #[test]
195    fn test_add_request_header_only_if_absent() {
196        let mut http = HttpExtension::default();
197        assert!(http.add_request_header("X-New", "first"));
198        assert!(!http.add_request_header("X-New", "second"));
199        assert_eq!(http.get_request_header("X-New"), Some("first"));
200    }
201
202    #[test]
203    fn test_remove_request_header() {
204        let mut http = HttpExtension::default();
205        http.set_request_header("X-Remove", "value");
206        let removed = http.remove_request_header("x-remove");
207        assert_eq!(removed, Some("value".to_string()));
208        assert!(!http.has_request_header("X-Remove"));
209    }
210
211    #[test]
212    fn test_serde_roundtrip() {
213        let mut http = HttpExtension::default();
214        http.set_request_header("Authorization", "Bearer tok");
215        http.set_request_header("X-Request-ID", "req-123");
216        http.set_response_header("Content-Type", "application/json");
217        http.set_response_header("X-Response-Time", "15ms");
218
219        let json = serde_json::to_string(&http).unwrap();
220        let deserialized: HttpExtension = serde_json::from_str(&json).unwrap();
221
222        assert_eq!(
223            deserialized.get_request_header("Authorization"),
224            Some("Bearer tok")
225        );
226        assert_eq!(
227            deserialized.get_response_header("Content-Type"),
228            Some("application/json")
229        );
230    }
231}