1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ChioHttpRequest {
16 pub request_id: String,
18
19 pub method: HttpMethod,
21
22 pub route_pattern: String,
25
26 pub path: String,
28
29 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
31 pub query: HashMap<String, String>,
32
33 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
37 pub headers: HashMap<String, String>,
38
39 pub caller: CallerIdentity,
41
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub body_hash: Option<String>,
46
47 #[serde(default)]
49 pub body_length: u64,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub session_id: Option<String>,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub capability_id: Option<String>,
58
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub tool_server: Option<String>,
62
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub tool_name: Option<String>,
66
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub arguments: Option<serde_json::Value>,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub model_metadata: Option<ModelMetadata>,
74
75 pub timestamp: u64,
77}
78
79impl ChioHttpRequest {
80 #[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 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#[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}