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    /// Stateful response based on state machine (second priority)
185    Stateful = 1,
186    /// Fail injection (third priority)
187    Fail = 2,
188    /// Proxy to upstream (fourth priority)
189    Proxy = 3,
190    /// Mock from OpenAPI spec (fifth priority)
191    Mock = 4,
192    /// Record request for future replay (lowest priority)
193    Record = 5,
194}
195
196/// Response source information
197#[derive(Debug, Clone)]
198pub struct ResponseSource {
199    /// Priority level of this response
200    pub priority: ResponsePriority,
201    /// Source type
202    pub source_type: String,
203    /// Additional metadata
204    pub metadata: HashMap<String, String>,
205}
206
207impl ResponseSource {
208    /// Create a new response source
209    pub fn new(priority: ResponsePriority, source_type: String) -> Self {
210        Self {
211            priority,
212            source_type,
213            metadata: HashMap::new(),
214        }
215    }
216
217    /// Add metadata to the response source
218    pub fn with_metadata(mut self, key: String, value: String) -> Self {
219        self.metadata.insert(key, value);
220        self
221    }
222}
223
224/// Request handler result
225#[derive(Debug, Clone)]
226pub enum RequestHandlerResult {
227    /// Response was handled (stop processing)
228    Handled(ResponseSource),
229    /// Continue to next handler
230    Continue,
231    /// Error occurred
232    Error(String),
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use axum::http::Uri;
239
240    #[test]
241    fn test_request_fingerprint_creation() {
242        let method = Method::GET;
243        let uri = Uri::from_static("/api/users?page=1&limit=10");
244        let mut headers = HeaderMap::new();
245        headers.insert("authorization", "Bearer token123".parse().unwrap());
246        headers.insert("content-type", "application/json".parse().unwrap());
247
248        let fingerprint = RequestFingerprint::new(method, &uri, &headers, None);
249
250        assert_eq!(fingerprint.method, "GET");
251        assert_eq!(fingerprint.path, "/api/users");
252        assert_eq!(fingerprint.query, "limit=10&page=1"); // Sorted
253        assert_eq!(fingerprint.headers.get("authorization"), Some(&"Bearer token123".to_string()));
254        assert_eq!(fingerprint.headers.get("content-type"), Some(&"application/json".to_string()));
255    }
256
257    #[test]
258    fn test_fingerprint_consistency() {
259        let method = Method::POST;
260        let uri = Uri::from_static("/api/users?b=2&a=1");
261        let mut headers = HeaderMap::new();
262        headers.insert("x-api-key", "key123".parse().unwrap());
263        headers.insert("authorization", "Bearer token".parse().unwrap());
264
265        let fingerprint1 = RequestFingerprint::new(method.clone(), &uri, &headers, None);
266        let fingerprint2 = RequestFingerprint::new(method, &uri, &headers, None);
267
268        // String representations should be identical
269        assert_eq!(fingerprint1.to_string(), fingerprint2.to_string());
270
271        // Hashes should be identical within the same run (DefaultHasher is randomized between runs)
272        // The important thing is that the same fingerprint produces the same hash
273        assert_eq!(fingerprint1.to_hash(), fingerprint1.to_hash());
274        assert_eq!(fingerprint2.to_hash(), fingerprint2.to_hash());
275
276        // And that identical fingerprints produce the same hash
277        assert_eq!(fingerprint1.to_hash(), fingerprint2.to_hash());
278
279        // Also test that the actual structure comparison works
280        assert_eq!(fingerprint1, fingerprint2);
281    }
282
283    #[test]
284    fn test_response_priority_ordering() {
285        assert!(ResponsePriority::Replay < ResponsePriority::Stateful);
286        assert!(ResponsePriority::Stateful < ResponsePriority::Fail);
287        assert!(ResponsePriority::Fail < ResponsePriority::Proxy);
288        assert!(ResponsePriority::Proxy < ResponsePriority::Mock);
289        assert!(ResponsePriority::Mock < ResponsePriority::Record);
290    }
291
292    #[test]
293    fn test_openapi_tags() {
294        use crate::openapi::spec::OpenApiSpec;
295
296        let spec_json = r#"
297        {
298            "openapi": "3.0.0",
299            "info": {"title": "Test API", "version": "1.0.0"},
300            "paths": {
301                "/api/users": {
302                    "get": {
303                        "tags": ["users", "admin"],
304                        "operationId": "getUsers",
305                        "responses": {
306                            "200": {
307                                "description": "Success"
308                            }
309                        }
310                    }
311                }
312            }
313        }
314        "#;
315
316        let spec = OpenApiSpec::from_json(serde_json::from_str(spec_json).unwrap()).unwrap();
317        let method = Method::GET;
318        let uri = Uri::from_static("/api/users");
319        let headers = HeaderMap::new();
320
321        let fingerprint = RequestFingerprint::new(method.clone(), &uri, &headers, None);
322
323        let tags = fingerprint.openapi_tags(&spec).unwrap();
324        assert_eq!(tags, vec!["users", "admin", "getUsers"]);
325
326        // Test fallback when no operation found
327        let uri2 = Uri::from_static("/api/posts");
328        let fingerprint2 = RequestFingerprint::new(method, &uri2, &headers, None);
329        let tags2 = fingerprint2.openapi_tags(&spec);
330        assert!(tags2.is_none());
331    }
332}