Skip to main content

chio_http_core/
request.rs

1//! Protocol-agnostic HTTP request model for Chio evaluation.
2
3use std::collections::HashMap;
4
5use chio_core_types::capability::ModelMetadata;
6use serde::{Deserialize, Serialize};
7
8use crate::identity::CallerIdentity;
9use crate::method::HttpMethod;
10
11/// A protocol-agnostic HTTP request that Chio evaluates.
12/// This is the shared input type for all HTTP substrate adapters --
13/// reverse proxy, framework middleware, and sidecar alike.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ChioHttpRequest {
16    /// Unique request identifier (UUIDv7 recommended).
17    pub request_id: String,
18
19    /// HTTP method.
20    pub method: HttpMethod,
21
22    /// The matched route pattern (e.g., "/pets/{petId}"), not the raw path.
23    /// Used for policy matching.
24    pub route_pattern: String,
25
26    /// The actual request path (e.g., "/pets/42").
27    pub path: String,
28
29    /// Query parameters.
30    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
31    pub query: HashMap<String, String>,
32
33    /// Selected request headers relevant to policy evaluation.
34    /// Substrate adapters extract only the headers needed for guards
35    /// (e.g., Content-Type, Content-Length) -- never raw auth headers.
36    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
37    pub headers: HashMap<String, String>,
38
39    /// The extracted caller identity.
40    pub caller: CallerIdentity,
41
42    /// SHA-256 hash of the request body (for content binding in receipts).
43    /// None for bodyless requests (GET, HEAD, OPTIONS).
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub body_hash: Option<String>,
46
47    /// Content-Length of the request body in bytes.
48    #[serde(default)]
49    pub body_length: u64,
50
51    /// Session ID this request belongs to.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub session_id: Option<String>,
54
55    /// Capability token ID presented with this request, if any.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub capability_id: Option<String>,
58
59    /// Optional sidecar tool server identity for synthetic tool-call evaluations.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub tool_server: Option<String>,
62
63    /// Optional sidecar tool name for synthetic tool-call evaluations.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub tool_name: Option<String>,
66
67    /// Optional structured tool-call arguments for synthetic sidecar evaluations.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub arguments: Option<serde_json::Value>,
70
71    /// Optional model identity and safety tier for model-constrained grants.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub model_metadata: Option<ModelMetadata>,
74
75    /// Unix timestamp (seconds) when the request was received.
76    pub timestamp: u64,
77}
78
79impl ChioHttpRequest {
80    /// Create a minimal request for testing or simple evaluations.
81    #[must_use]
82    pub fn new(
83        request_id: String,
84        method: HttpMethod,
85        route_pattern: String,
86        path: String,
87        caller: CallerIdentity,
88    ) -> Self {
89        let now = chrono::Utc::now().timestamp() as u64;
90        Self {
91            request_id,
92            method,
93            route_pattern,
94            path,
95            query: HashMap::new(),
96            headers: HashMap::new(),
97            caller,
98            body_hash: None,
99            body_length: 0,
100            session_id: None,
101            capability_id: None,
102            tool_server: None,
103            tool_name: None,
104            arguments: None,
105            model_metadata: None,
106            timestamp: now,
107        }
108    }
109
110    /// Compute a content hash binding this request to a receipt.
111    /// Hashes the canonical JSON of the route pattern, method, body hash,
112    /// and query parameters.
113    pub fn content_hash(&self) -> chio_core_types::Result<String> {
114        let binding = RequestContentBinding {
115            method: self.method,
116            route_pattern: &self.route_pattern,
117            path: &self.path,
118            query: &self.query,
119            body_hash: self.body_hash.as_deref(),
120        };
121        let bytes = chio_core_types::canonical_json_bytes(&binding)?;
122        Ok(chio_core_types::sha256_hex(&bytes))
123    }
124}
125
126/// Internal struct for deterministic content hashing.
127#[derive(Serialize)]
128struct RequestContentBinding<'a> {
129    method: HttpMethod,
130    route_pattern: &'a str,
131    path: &'a str,
132    query: &'a HashMap<String, String>,
133    body_hash: Option<&'a str>,
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::identity::CallerIdentity;
140
141    #[test]
142    fn new_request_defaults() {
143        let req = ChioHttpRequest::new(
144            "req-001".to_string(),
145            HttpMethod::Get,
146            "/pets/{petId}".to_string(),
147            "/pets/42".to_string(),
148            CallerIdentity::anonymous(),
149        );
150        assert_eq!(req.method, HttpMethod::Get);
151        assert!(req.body_hash.is_none());
152        assert_eq!(req.body_length, 0);
153        assert!(req.query.is_empty());
154    }
155
156    #[test]
157    fn content_hash_deterministic() {
158        let req = ChioHttpRequest::new(
159            "req-002".to_string(),
160            HttpMethod::Post,
161            "/pets".to_string(),
162            "/pets".to_string(),
163            CallerIdentity::anonymous(),
164        );
165        let h1 = req.content_hash().unwrap();
166        let h2 = req.content_hash().unwrap();
167        assert_eq!(h1, h2);
168        assert_eq!(h1.len(), 64);
169    }
170
171    #[test]
172    fn serde_roundtrip() {
173        let mut req = ChioHttpRequest::new(
174            "req-003".to_string(),
175            HttpMethod::Put,
176            "/pets/{petId}".to_string(),
177            "/pets/7".to_string(),
178            CallerIdentity::anonymous(),
179        );
180        req.query.insert("verbose".to_string(), "true".to_string());
181        req.body_hash = Some("abc123".to_string());
182
183        let json = serde_json::to_string(&req).unwrap();
184        let back: ChioHttpRequest = serde_json::from_str(&json).unwrap();
185        assert_eq!(back.method, HttpMethod::Put);
186        assert_eq!(back.query.get("verbose").map(|s| s.as_str()), Some("true"));
187        assert_eq!(back.body_hash.as_deref(), Some("abc123"));
188    }
189
190    #[test]
191    fn content_hash_changes_with_query_params() {
192        let mut req1 = ChioHttpRequest::new(
193            "req-a".to_string(),
194            HttpMethod::Get,
195            "/search".to_string(),
196            "/search".to_string(),
197            CallerIdentity::anonymous(),
198        );
199        let mut req2 = req1.clone();
200
201        req1.query.insert("q".to_string(), "cats".to_string());
202        req2.query.insert("q".to_string(), "dogs".to_string());
203
204        let h1 = req1.content_hash().unwrap();
205        let h2 = req2.content_hash().unwrap();
206        assert_ne!(
207            h1, h2,
208            "different query params should produce different hashes"
209        );
210    }
211
212    #[test]
213    fn content_hash_changes_with_body_hash() {
214        let mut req1 = ChioHttpRequest::new(
215            "req-b".to_string(),
216            HttpMethod::Post,
217            "/data".to_string(),
218            "/data".to_string(),
219            CallerIdentity::anonymous(),
220        );
221        let mut req2 = req1.clone();
222
223        req1.body_hash = Some("bodyhash1".to_string());
224        req2.body_hash = Some("bodyhash2".to_string());
225
226        let h1 = req1.content_hash().unwrap();
227        let h2 = req2.content_hash().unwrap();
228        assert_ne!(
229            h1, h2,
230            "different body hashes should produce different content hashes"
231        );
232    }
233
234    #[test]
235    fn content_hash_differs_between_methods() {
236        let req_get = ChioHttpRequest::new(
237            "req-c".to_string(),
238            HttpMethod::Get,
239            "/resource".to_string(),
240            "/resource".to_string(),
241            CallerIdentity::anonymous(),
242        );
243        let req_post = ChioHttpRequest::new(
244            "req-d".to_string(),
245            HttpMethod::Post,
246            "/resource".to_string(),
247            "/resource".to_string(),
248            CallerIdentity::anonymous(),
249        );
250
251        let h1 = req_get.content_hash().unwrap();
252        let h2 = req_post.content_hash().unwrap();
253        assert_ne!(
254            h1, h2,
255            "different methods should produce different content hashes"
256        );
257    }
258}