use std::collections::HashMap;
use chio_core_types::capability::ModelMetadata;
use serde::{Deserialize, Serialize};
use crate::identity::CallerIdentity;
use crate::method::HttpMethod;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChioHttpRequest {
pub request_id: String,
pub method: HttpMethod,
pub route_pattern: String,
pub path: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub query: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
pub caller: CallerIdentity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_hash: Option<String>,
#[serde(default)]
pub body_length: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_server: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arguments: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_metadata: Option<ModelMetadata>,
pub timestamp: u64,
}
impl ChioHttpRequest {
#[must_use]
pub fn new(
request_id: String,
method: HttpMethod,
route_pattern: String,
path: String,
caller: CallerIdentity,
) -> Self {
let now = chrono::Utc::now().timestamp() as u64;
Self {
request_id,
method,
route_pattern,
path,
query: HashMap::new(),
headers: HashMap::new(),
caller,
body_hash: None,
body_length: 0,
session_id: None,
capability_id: None,
tool_server: None,
tool_name: None,
arguments: None,
model_metadata: None,
timestamp: now,
}
}
pub fn content_hash(&self) -> chio_core_types::Result<String> {
let binding = RequestContentBinding {
method: self.method,
route_pattern: &self.route_pattern,
path: &self.path,
query: &self.query,
body_hash: self.body_hash.as_deref(),
};
let bytes = chio_core_types::canonical_json_bytes(&binding)?;
Ok(chio_core_types::sha256_hex(&bytes))
}
}
#[derive(Serialize)]
struct RequestContentBinding<'a> {
method: HttpMethod,
route_pattern: &'a str,
path: &'a str,
query: &'a HashMap<String, String>,
body_hash: Option<&'a str>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::CallerIdentity;
#[test]
fn new_request_defaults() {
let req = ChioHttpRequest::new(
"req-001".to_string(),
HttpMethod::Get,
"/pets/{petId}".to_string(),
"/pets/42".to_string(),
CallerIdentity::anonymous(),
);
assert_eq!(req.method, HttpMethod::Get);
assert!(req.body_hash.is_none());
assert_eq!(req.body_length, 0);
assert!(req.query.is_empty());
}
#[test]
fn content_hash_deterministic() {
let req = ChioHttpRequest::new(
"req-002".to_string(),
HttpMethod::Post,
"/pets".to_string(),
"/pets".to_string(),
CallerIdentity::anonymous(),
);
let h1 = req.content_hash().unwrap();
let h2 = req.content_hash().unwrap();
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
}
#[test]
fn serde_roundtrip() {
let mut req = ChioHttpRequest::new(
"req-003".to_string(),
HttpMethod::Put,
"/pets/{petId}".to_string(),
"/pets/7".to_string(),
CallerIdentity::anonymous(),
);
req.query.insert("verbose".to_string(), "true".to_string());
req.body_hash = Some("abc123".to_string());
let json = serde_json::to_string(&req).unwrap();
let back: ChioHttpRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.method, HttpMethod::Put);
assert_eq!(back.query.get("verbose").map(|s| s.as_str()), Some("true"));
assert_eq!(back.body_hash.as_deref(), Some("abc123"));
}
#[test]
fn content_hash_changes_with_query_params() {
let mut req1 = ChioHttpRequest::new(
"req-a".to_string(),
HttpMethod::Get,
"/search".to_string(),
"/search".to_string(),
CallerIdentity::anonymous(),
);
let mut req2 = req1.clone();
req1.query.insert("q".to_string(), "cats".to_string());
req2.query.insert("q".to_string(), "dogs".to_string());
let h1 = req1.content_hash().unwrap();
let h2 = req2.content_hash().unwrap();
assert_ne!(
h1, h2,
"different query params should produce different hashes"
);
}
#[test]
fn content_hash_changes_with_body_hash() {
let mut req1 = ChioHttpRequest::new(
"req-b".to_string(),
HttpMethod::Post,
"/data".to_string(),
"/data".to_string(),
CallerIdentity::anonymous(),
);
let mut req2 = req1.clone();
req1.body_hash = Some("bodyhash1".to_string());
req2.body_hash = Some("bodyhash2".to_string());
let h1 = req1.content_hash().unwrap();
let h2 = req2.content_hash().unwrap();
assert_ne!(
h1, h2,
"different body hashes should produce different content hashes"
);
}
#[test]
fn content_hash_differs_between_methods() {
let req_get = ChioHttpRequest::new(
"req-c".to_string(),
HttpMethod::Get,
"/resource".to_string(),
"/resource".to_string(),
CallerIdentity::anonymous(),
);
let req_post = ChioHttpRequest::new(
"req-d".to_string(),
HttpMethod::Post,
"/resource".to_string(),
"/resource".to_string(),
CallerIdentity::anonymous(),
);
let h1 = req_get.content_hash().unwrap();
let h2 = req_post.content_hash().unwrap();
assert_ne!(
h1, h2,
"different methods should produce different content hashes"
);
}
}