1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ChioHttpRequest {
17 pub request_id: String,
19
20 pub method: HttpMethod,
22
23 pub route_pattern: String,
26
27 pub path: String,
29
30 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
32 pub query: HashMap<String, String>,
33
34 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
38 pub headers: HashMap<String, String>,
39
40 pub caller: CallerIdentity,
42
43 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub body_hash: Option<String>,
47
48 #[serde(default)]
50 pub body_length: u64,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub session_id: Option<String>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub capability_id: Option<String>,
59
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub tool_server: Option<String>,
63
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub tool_name: Option<String>,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub arguments: Option<serde_json::Value>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub model_metadata: Option<ModelMetadata>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub governed_intent: Option<serde_json::Value>,
81
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub approval_token: Option<serde_json::Value>,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub approval_tokens: Option<serde_json::Value>,
89
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub threshold_approval_proposal: Option<serde_json::Value>,
93
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub supplemental_authorization: Option<serde_json::Value>,
97
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub execution_nonce: Option<SignedExecutionNonce>,
101
102 pub timestamp: u64,
104}
105
106impl ChioHttpRequest {
107 #[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 #[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 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#[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}