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::scope::ModelMetadata;
6use chio_kernel::SignedExecutionNonce;
7use serde::{Deserialize, Serialize};
8
9use crate::identity::CallerIdentity;
10use crate::method::HttpMethod;
11
12/// A protocol-agnostic HTTP request that Chio evaluates.
13/// This is the shared input type for all HTTP substrate adapters --
14/// reverse proxy, framework middleware, and sidecar alike.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ChioHttpRequest {
17    /// Unique request identifier (UUIDv7 recommended).
18    pub request_id: String,
19
20    /// HTTP method.
21    pub method: HttpMethod,
22
23    /// The matched route pattern (e.g., "/pets/{petId}"), not the raw path.
24    /// Used for policy matching.
25    pub route_pattern: String,
26
27    /// The actual request path (e.g., "/pets/42").
28    pub path: String,
29
30    /// Query parameters.
31    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
32    pub query: HashMap<String, String>,
33
34    /// Selected request headers relevant to policy evaluation.
35    /// Substrate adapters extract only the headers needed for guards
36    /// (e.g., Content-Type, Content-Length) -- never raw auth headers.
37    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
38    pub headers: HashMap<String, String>,
39
40    /// The extracted caller identity.
41    pub caller: CallerIdentity,
42
43    /// SHA-256 hash of the request body (for content binding in receipts).
44    /// None for bodyless requests (GET, HEAD, OPTIONS).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub body_hash: Option<String>,
47
48    /// Content-Length of the request body in bytes.
49    #[serde(default)]
50    pub body_length: u64,
51
52    /// Session ID this request belongs to.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub session_id: Option<String>,
55
56    /// Capability token ID presented with this request, if any.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub capability_id: Option<String>,
59
60    /// Optional sidecar tool server identity for synthetic tool-call evaluations.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub tool_server: Option<String>,
63
64    /// Optional sidecar tool name for synthetic tool-call evaluations.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub tool_name: Option<String>,
67
68    /// Optional structured tool-call arguments for synthetic sidecar evaluations.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub arguments: Option<serde_json::Value>,
71
72    /// Optional model identity and safety tier for model-constrained grants.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub model_metadata: Option<ModelMetadata>,
75
76    /// Governed intent is not supported by the HTTP authority projection.
77    /// It is retained here so the boundary can reject it instead of silently
78    /// ignoring a security-sensitive field.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub governed_intent: Option<serde_json::Value>,
81
82    /// Singular governed approval is unsupported on this projection.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub approval_token: Option<serde_json::Value>,
85
86    /// Threshold governed approvals are unsupported on this projection.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub approval_tokens: Option<serde_json::Value>,
89
90    /// Threshold proposal is unsupported on this projection.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub threshold_approval_proposal: Option<serde_json::Value>,
93
94    /// Supplemental authorization is unsupported on this projection.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub supplemental_authorization: Option<serde_json::Value>,
97
98    /// Optional signed nonce presented to complete a strict nonce preflight.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub execution_nonce: Option<SignedExecutionNonce>,
101
102    /// Unix timestamp (seconds) when the request was received.
103    pub timestamp: u64,
104}
105
106impl ChioHttpRequest {
107    /// Create a minimal request for testing or simple evaluations.
108    #[must_use]
109    pub fn new(
110        request_id: String,
111        method: HttpMethod,
112        route_pattern: String,
113        path: String,
114        caller: CallerIdentity,
115    ) -> Self {
116        let now = chrono::Utc::now().timestamp() as u64;
117        Self {
118            request_id,
119            method,
120            route_pattern,
121            path,
122            query: HashMap::new(),
123            headers: HashMap::new(),
124            caller,
125            body_hash: None,
126            body_length: 0,
127            session_id: None,
128            capability_id: None,
129            tool_server: None,
130            tool_name: None,
131            arguments: None,
132            model_metadata: None,
133            governed_intent: None,
134            approval_token: None,
135            approval_tokens: None,
136            threshold_approval_proposal: None,
137            supplemental_authorization: None,
138            execution_nonce: None,
139            timestamp: now,
140        }
141    }
142
143    /// Return the first authorization extension this HTTP projection cannot
144    /// enforce. Callers use this to deny explicitly at the authority boundary.
145    #[must_use]
146    pub fn unsupported_authorization_extension(&self) -> Option<&'static str> {
147        if self.governed_intent.is_some() {
148            return Some("governed_intent");
149        }
150        if self.approval_token.is_some() {
151            return Some("approval_token");
152        }
153        if self.approval_tokens.is_some() {
154            return Some("approval_tokens");
155        }
156        if self.threshold_approval_proposal.is_some() {
157            return Some("threshold_approval_proposal");
158        }
159        if self.supplemental_authorization.is_some() {
160            return Some("supplemental_authorization");
161        }
162        None
163    }
164
165    /// Compute a content hash binding this request to a receipt.
166    /// Hashes the canonical JSON of the route pattern, method, body hash,
167    /// and query parameters.
168    pub fn content_hash(&self) -> chio_core_types::Result<String> {
169        let binding = RequestContentBinding {
170            method: self.method,
171            route_pattern: &self.route_pattern,
172            path: &self.path,
173            query: &self.query,
174            body_hash: self.body_hash.as_deref(),
175        };
176        let bytes = chio_core_types::canonical_json_bytes(&binding)?;
177        Ok(chio_core_types::sha256_hex(&bytes))
178    }
179}
180
181/// Internal struct for deterministic content hashing.
182#[derive(Serialize)]
183struct RequestContentBinding<'a> {
184    method: HttpMethod,
185    route_pattern: &'a str,
186    path: &'a str,
187    query: &'a HashMap<String, String>,
188    body_hash: Option<&'a str>,
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::identity::CallerIdentity;
195
196    use chio_test_support::prelude::*;
197
198    #[test]
199    fn new_request_defaults() {
200        let req = ChioHttpRequest::new(
201            "req-001".to_string(),
202            HttpMethod::Get,
203            "/pets/{petId}".to_string(),
204            "/pets/42".to_string(),
205            CallerIdentity::anonymous(),
206        );
207        assert_eq!(req.method, HttpMethod::Get);
208        assert!(req.body_hash.is_none());
209        assert_eq!(req.body_length, 0);
210        assert!(req.query.is_empty());
211    }
212
213    #[test]
214    fn content_hash_deterministic() {
215        let req = ChioHttpRequest::new(
216            "req-002".to_string(),
217            HttpMethod::Post,
218            "/pets".to_string(),
219            "/pets".to_string(),
220            CallerIdentity::anonymous(),
221        );
222        let h1 = req.content_hash().test_unwrap();
223        let h2 = req.content_hash().test_unwrap();
224        assert_eq!(h1, h2);
225        assert_eq!(h1.len(), 64);
226    }
227
228    #[test]
229    fn serde_roundtrip() {
230        let mut req = ChioHttpRequest::new(
231            "req-003".to_string(),
232            HttpMethod::Put,
233            "/pets/{petId}".to_string(),
234            "/pets/7".to_string(),
235            CallerIdentity::anonymous(),
236        );
237        req.query.insert("verbose".to_string(), "true".to_string());
238        req.body_hash = Some("abc123".to_string());
239
240        let json = serde_json::to_string(&req).test_unwrap();
241        let back: ChioHttpRequest = serde_json::from_str(&json).test_unwrap();
242        assert_eq!(back.method, HttpMethod::Put);
243        assert_eq!(back.query.get("verbose").map(|s| s.as_str()), Some("true"));
244        assert_eq!(back.body_hash.as_deref(), Some("abc123"));
245    }
246
247    #[test]
248    fn serde_retains_unsupported_authorization_extensions_for_rejection() {
249        let mut req = ChioHttpRequest::new(
250            "req-auth-extension".to_string(),
251            HttpMethod::Post,
252            "/chio/evaluate".to_string(),
253            "/chio/evaluate".to_string(),
254            CallerIdentity::anonymous(),
255        );
256        req.approval_tokens = Some(serde_json::json!([
257            { "id": "approval-a" },
258            { "id": "approval-b" }
259        ]));
260
261        let json = serde_json::to_string(&req).test_unwrap();
262        let back: ChioHttpRequest = serde_json::from_str(&json).test_unwrap();
263
264        assert_eq!(
265            back.unsupported_authorization_extension(),
266            Some("approval_tokens")
267        );
268        assert_eq!(back.approval_tokens, req.approval_tokens);
269    }
270
271    #[test]
272    fn content_hash_changes_with_query_params() {
273        let mut req1 = ChioHttpRequest::new(
274            "req-a".to_string(),
275            HttpMethod::Get,
276            "/search".to_string(),
277            "/search".to_string(),
278            CallerIdentity::anonymous(),
279        );
280        let mut req2 = req1.clone();
281
282        req1.query.insert("q".to_string(), "cats".to_string());
283        req2.query.insert("q".to_string(), "dogs".to_string());
284
285        let h1 = req1.content_hash().test_unwrap();
286        let h2 = req2.content_hash().test_unwrap();
287        assert_ne!(
288            h1, h2,
289            "different query params should produce different hashes"
290        );
291    }
292
293    #[test]
294    fn content_hash_changes_with_body_hash() {
295        let mut req1 = ChioHttpRequest::new(
296            "req-b".to_string(),
297            HttpMethod::Post,
298            "/data".to_string(),
299            "/data".to_string(),
300            CallerIdentity::anonymous(),
301        );
302        let mut req2 = req1.clone();
303
304        req1.body_hash = Some("bodyhash1".to_string());
305        req2.body_hash = Some("bodyhash2".to_string());
306
307        let h1 = req1.content_hash().test_unwrap();
308        let h2 = req2.content_hash().test_unwrap();
309        assert_ne!(
310            h1, h2,
311            "different body hashes should produce different content hashes"
312        );
313    }
314
315    #[test]
316    fn content_hash_differs_between_methods() {
317        let req_get = ChioHttpRequest::new(
318            "req-c".to_string(),
319            HttpMethod::Get,
320            "/resource".to_string(),
321            "/resource".to_string(),
322            CallerIdentity::anonymous(),
323        );
324        let req_post = ChioHttpRequest::new(
325            "req-d".to_string(),
326            HttpMethod::Post,
327            "/resource".to_string(),
328            "/resource".to_string(),
329            CallerIdentity::anonymous(),
330        );
331
332        let h1 = req_get.content_hash().test_unwrap();
333        let h2 = req_post.content_hash().test_unwrap();
334        assert_ne!(
335            h1, h2,
336            "different methods should produce different content hashes"
337        );
338    }
339}