mockforge_core/
request_fingerprint.rs

1//! Request fingerprinting system for unique request identification
2//! and priority-based response selection.
3
4use axum::http::{HeaderMap, Method, Uri};
5use openapiv3;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::hash::{Hash, Hasher};
10
11/// Request fingerprint for unique identification
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct RequestFingerprint {
14    /// HTTP method
15    pub method: String,
16    /// Request path
17    pub path: String,
18    /// Query parameters (sorted for consistency)
19    pub query: String,
20    /// Important headers (sorted for consistency)
21    pub headers: HashMap<String, String>,
22    /// Request body hash (if available)
23    pub body_hash: Option<String>,
24}
25
26impl RequestFingerprint {
27    /// Create a new request fingerprint
28    pub fn new(method: Method, uri: &Uri, headers: &HeaderMap, body: Option<&[u8]>) -> Self {
29        let mut query_parts = Vec::new();
30        if let Some(query) = uri.query() {
31            let mut params: Vec<&str> = query.split('&').collect();
32            params.sort(); // Sort for consistency
33            query_parts = params;
34        }
35
36        // Extract important headers (sorted for consistency)
37        let mut important_headers = HashMap::new();
38        let important_header_names = [
39            "authorization",
40            "content-type",
41            "accept",
42            "user-agent",
43            "x-request-id",
44            "x-api-key",
45            "x-auth-token",
46        ];
47
48        for header_name in &important_header_names {
49            if let Some(header_value) = headers.get(*header_name) {
50                if let Ok(value_str) = header_value.to_str() {
51                    important_headers.insert(header_name.to_string(), value_str.to_string());
52                }
53            }
54        }
55
56        // Calculate body hash if body is provided
57        let body_hash = body.map(|b| {
58            use std::collections::hash_map::DefaultHasher;
59            let mut hasher = DefaultHasher::new();
60            b.hash(&mut hasher);
61            format!("{:x}", hasher.finish())
62        });
63
64        Self {
65            method: method.to_string(),
66            path: uri.path().to_string(),
67            query: query_parts.join("&"),
68            headers: important_headers,
69            body_hash,
70        }
71    }
72}
73
74impl fmt::Display for RequestFingerprint {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        let mut parts = Vec::new();
77        parts.push(self.method.clone());
78        parts.push(self.path.clone());
79        parts.push(self.query.clone());
80
81        // Add headers in sorted order
82        let mut sorted_headers: Vec<_> = self.headers.iter().collect();
83        sorted_headers.sort_by_key(|(k, _)| *k);
84        for (key, value) in sorted_headers {
85            parts.push(format!("{}:{}", key, value));
86        }
87
88        if let Some(ref hash) = self.body_hash {
89            parts.push(format!("body:{}", hash));
90        }
91
92        write!(f, "{}", parts.join("|"))
93    }
94}
95
96impl RequestFingerprint {
97    /// Generate a short hash of the fingerprint for use as filename
98    pub fn to_hash(&self) -> String {
99        use std::collections::hash_map::DefaultHasher;
100        let mut hasher = DefaultHasher::new();
101        self.method.hash(&mut hasher);
102        self.path.hash(&mut hasher);
103        self.query.hash(&mut hasher);
104
105        // Sort headers to ensure deterministic hash
106        let mut sorted_headers: Vec<_> = self.headers.iter().collect();
107        sorted_headers.sort_by_key(|(k, _)| *k);
108        for (k, v) in sorted_headers {
109            k.hash(&mut hasher);
110            v.hash(&mut hasher);
111        }
112
113        self.body_hash.hash(&mut hasher);
114        format!("{:x}", hasher.finish())
115    }
116
117    /// Get tags for the request (extracted from path as fallback)
118    pub fn tags(&self) -> Vec<String> {
119        // Extract tags from the path as fallback when OpenAPI spec is not available
120        let mut tags = Vec::new();
121
122        // Extract path segments as potential tags
123        for segment in self.path.split('/').filter(|s| !s.is_empty()) {
124            if !segment.starts_with('{') && !segment.starts_with(':') {
125                tags.push(segment.to_string());
126            }
127        }
128
129        // Add method as a tag
130        tags.push(self.method.to_lowercase());
131
132        tags
133    }
134
135    /// Get tags for the request from OpenAPI operation if available
136    pub fn openapi_tags(&self, spec: &crate::openapi::spec::OpenApiSpec) -> Option<Vec<String>> {
137        // Find the operation that matches this fingerprint
138        if let Some(operation) = self.find_operation(spec) {
139            let mut tags = operation.tags.clone();
140            if let Some(operation_id) = &operation.operation_id {
141                tags.push(operation_id.clone());
142            }
143            Some(tags)
144        } else {
145            None
146        }
147    }
148
149    /// Find the OpenAPI operation that matches this fingerprint
150    fn find_operation<'a>(
151        &self,
152        spec: &'a crate::openapi::spec::OpenApiSpec,
153    ) -> Option<&'a openapiv3::Operation> {
154        // Look for the path in the spec
155        if let Some(path_item) = spec.spec.paths.paths.get(&self.path) {
156            if let Some(item) = path_item.as_item() {
157                // Find the operation for the method
158                let operation = match self.method.as_str() {
159                    "GET" => &item.get,
160                    "POST" => &item.post,
161                    "PUT" => &item.put,
162                    "DELETE" => &item.delete,
163                    "PATCH" => &item.patch,
164                    "HEAD" => &item.head,
165                    "OPTIONS" => &item.options,
166                    "TRACE" => &item.trace,
167                    _ => &None,
168                };
169                operation.as_ref()
170            } else {
171                None
172            }
173        } else {
174            None
175        }
176    }
177}
178
179/// Response priority levels
180#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
181pub enum ResponsePriority {
182    /// Replay from recorded fixtures (highest priority)
183    Replay = 0,
184    /// Fail injection (second priority)
185    Fail = 1,
186    /// Proxy to upstream (third priority)
187    Proxy = 2,
188    /// Mock from OpenAPI spec (fourth priority)
189    Mock = 3,
190    /// Record request for future replay (lowest priority)
191    Record = 4,
192}
193
194/// Response source information
195#[derive(Debug, Clone)]
196pub struct ResponseSource {
197    /// Priority level of this response
198    pub priority: ResponsePriority,
199    /// Source type
200    pub source_type: String,
201    /// Additional metadata
202    pub metadata: HashMap<String, String>,
203}
204
205impl ResponseSource {
206    /// Create a new response source
207    pub fn new(priority: ResponsePriority, source_type: String) -> Self {
208        Self {
209            priority,
210            source_type,
211            metadata: HashMap::new(),
212        }
213    }
214
215    /// Add metadata to the response source
216    pub fn with_metadata(mut self, key: String, value: String) -> Self {
217        self.metadata.insert(key, value);
218        self
219    }
220}
221
222/// Request handler result
223#[derive(Debug, Clone)]
224pub enum RequestHandlerResult {
225    /// Response was handled (stop processing)
226    Handled(ResponseSource),
227    /// Continue to next handler
228    Continue,
229    /// Error occurred
230    Error(String),
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use axum::http::Uri;
237
238    #[test]
239    fn test_request_fingerprint_creation() {
240        let method = Method::GET;
241        let uri = Uri::from_static("/api/users?page=1&limit=10");
242        let mut headers = HeaderMap::new();
243        headers.insert("authorization", "Bearer token123".parse().unwrap());
244        headers.insert("content-type", "application/json".parse().unwrap());
245
246        let fingerprint = RequestFingerprint::new(method, &uri, &headers, None);
247
248        assert_eq!(fingerprint.method, "GET");
249        assert_eq!(fingerprint.path, "/api/users");
250        assert_eq!(fingerprint.query, "limit=10&page=1"); // Sorted
251        assert_eq!(fingerprint.headers.get("authorization"), Some(&"Bearer token123".to_string()));
252        assert_eq!(fingerprint.headers.get("content-type"), Some(&"application/json".to_string()));
253    }
254
255    #[test]
256    fn test_fingerprint_consistency() {
257        let method = Method::POST;
258        let uri = Uri::from_static("/api/users?b=2&a=1");
259        let mut headers = HeaderMap::new();
260        headers.insert("x-api-key", "key123".parse().unwrap());
261        headers.insert("authorization", "Bearer token".parse().unwrap());
262
263        let fingerprint1 = RequestFingerprint::new(method.clone(), &uri, &headers, None);
264        let fingerprint2 = RequestFingerprint::new(method, &uri, &headers, None);
265
266        // String representations should be identical
267        assert_eq!(fingerprint1.to_string(), fingerprint2.to_string());
268
269        // Hashes should be identical within the same run (DefaultHasher is randomized between runs)
270        // The important thing is that the same fingerprint produces the same hash
271        assert_eq!(fingerprint1.to_hash(), fingerprint1.to_hash());
272        assert_eq!(fingerprint2.to_hash(), fingerprint2.to_hash());
273
274        // And that identical fingerprints produce the same hash
275        assert_eq!(fingerprint1.to_hash(), fingerprint2.to_hash());
276
277        // Also test that the actual structure comparison works
278        assert_eq!(fingerprint1, fingerprint2);
279    }
280
281    #[test]
282    fn test_response_priority_ordering() {
283        assert!(ResponsePriority::Replay < ResponsePriority::Fail);
284        assert!(ResponsePriority::Fail < ResponsePriority::Proxy);
285        assert!(ResponsePriority::Proxy < ResponsePriority::Mock);
286        assert!(ResponsePriority::Mock < ResponsePriority::Record);
287    }
288
289    #[test]
290    fn test_openapi_tags() {
291        use crate::openapi::spec::OpenApiSpec;
292
293        let spec_json = r#"
294        {
295            "openapi": "3.0.0",
296            "info": {"title": "Test API", "version": "1.0.0"},
297            "paths": {
298                "/api/users": {
299                    "get": {
300                        "tags": ["users", "admin"],
301                        "operationId": "getUsers",
302                        "responses": {
303                            "200": {
304                                "description": "Success"
305                            }
306                        }
307                    }
308                }
309            }
310        }
311        "#;
312
313        let spec = OpenApiSpec::from_json(serde_json::from_str(spec_json).unwrap()).unwrap();
314        let method = Method::GET;
315        let uri = Uri::from_static("/api/users");
316        let headers = HeaderMap::new();
317
318        let fingerprint = RequestFingerprint::new(method.clone(), &uri, &headers, None);
319
320        let tags = fingerprint.openapi_tags(&spec).unwrap();
321        assert_eq!(tags, vec!["users", "admin", "getUsers"]);
322
323        // Test fallback when no operation found
324        let uri2 = Uri::from_static("/api/posts");
325        let fingerprint2 = RequestFingerprint::new(method, &uri2, &headers, None);
326        let tags2 = fingerprint2.openapi_tags(&spec);
327        assert!(tags2.is_none());
328    }
329}