mockforge_core/
custom_fixture.rs

1//! Custom fixture format support for simple JSON fixtures
2//!
3//! Supports fixtures in the format:
4//! ```json
5//! {
6//!   "method": "GET",
7//!   "path": "/api/v1/endpoint",
8//!   "status": 200,
9//!   "response": { /* response body */ },
10//!   "headers": { /* optional */ },
11//!   "delay_ms": 0 /* optional */
12//! }
13//! ```
14//!
15//! Path matching supports path parameters using curly braces:
16//! - `/api/v1/hives/{hiveId}` matches `/api/v1/hives/hive_001`
17
18use crate::{Error, RequestFingerprint, Result};
19use axum::http::Method;
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use tokio::fs;
24
25/// Custom fixture structure matching the simple JSON format
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CustomFixture {
28    /// HTTP method (GET, POST, PUT, PATCH, DELETE)
29    pub method: String,
30    /// Request path (supports path parameters like {hiveId})
31    pub path: String,
32    /// Response status code
33    pub status: u16,
34    /// Response body (can be any JSON value)
35    #[serde(default)]
36    pub response: serde_json::Value,
37    /// Optional response headers
38    #[serde(default)]
39    pub headers: HashMap<String, String>,
40    /// Optional response delay in milliseconds
41    #[serde(default)]
42    pub delay_ms: u64,
43}
44
45/// Custom fixture loader that scans a directory for fixture files
46pub struct CustomFixtureLoader {
47    /// Directory containing custom fixtures
48    fixtures_dir: PathBuf,
49    /// Whether custom fixtures are enabled
50    enabled: bool,
51    /// Loaded fixtures cache (method -> path pattern -> fixture)
52    fixtures: HashMap<String, HashMap<String, CustomFixture>>,
53}
54
55impl CustomFixtureLoader {
56    /// Create a new custom fixture loader
57    pub fn new(fixtures_dir: PathBuf, enabled: bool) -> Self {
58        Self {
59            fixtures_dir,
60            enabled,
61            fixtures: HashMap::new(),
62        }
63    }
64
65    /// Load all fixtures from the directory
66    pub async fn load_fixtures(&mut self) -> Result<()> {
67        if !self.enabled {
68            return Ok(());
69        }
70
71        if !self.fixtures_dir.exists() {
72            tracing::debug!(
73                "Custom fixtures directory does not exist: {}",
74                self.fixtures_dir.display()
75            );
76            return Ok(());
77        }
78
79        // Scan directory for JSON files
80        let mut entries = fs::read_dir(&self.fixtures_dir).await.map_err(|e| {
81            Error::generic(format!(
82                "Failed to read fixtures directory {}: {}",
83                self.fixtures_dir.display(),
84                e
85            ))
86        })?;
87
88        let mut loaded_count = 0;
89        while let Some(entry) = entries.next_entry().await.map_err(|e| {
90            Error::generic(format!(
91                "Failed to read directory entry: {}",
92                e
93            ))
94        })? {
95            let path = entry.path();
96            if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
97                if let Err(e) = self.load_fixture_file(&path).await {
98                    tracing::warn!(
99                        "Failed to load fixture file {}: {}",
100                        path.display(),
101                        e
102                    );
103                } else {
104                    loaded_count += 1;
105                }
106            }
107        }
108
109        tracing::info!(
110            "Loaded {} custom fixtures from {}",
111            loaded_count,
112            self.fixtures_dir.display()
113        );
114
115        Ok(())
116    }
117
118    /// Load a single fixture file
119    async fn load_fixture_file(&mut self, path: &Path) -> Result<()> {
120        let content = fs::read_to_string(path).await.map_err(|e| {
121            Error::generic(format!("Failed to read fixture file {}: {}", path.display(), e))
122        })?;
123
124        let fixture: CustomFixture = serde_json::from_str(&content).map_err(|e| {
125            Error::generic(format!(
126                "Failed to parse fixture file {}: {}",
127                path.display(),
128                e
129            ))
130        })?;
131
132        // Validate fixture
133        if fixture.method.is_empty() || fixture.path.is_empty() {
134            return Err(Error::generic(format!(
135                "Invalid fixture in {}: method and path are required",
136                path.display()
137            )));
138        }
139
140        // Store fixture by method and path pattern
141        let method = fixture.method.to_uppercase();
142        let fixtures_by_method = self.fixtures.entry(method).or_insert_with(HashMap::new);
143        fixtures_by_method.insert(fixture.path.clone(), fixture);
144
145        Ok(())
146    }
147
148    /// Check if a fixture exists for the given request fingerprint
149    pub fn has_fixture(&self, fingerprint: &RequestFingerprint) -> bool {
150        if !self.enabled {
151            return false;
152        }
153
154        self.find_matching_fixture(fingerprint).is_some()
155    }
156
157    /// Load a fixture for the given request fingerprint
158    pub fn load_fixture(&self, fingerprint: &RequestFingerprint) -> Option<CustomFixture> {
159        if !self.enabled {
160            return None;
161        }
162
163        self.find_matching_fixture(fingerprint).cloned()
164    }
165
166    /// Find a matching fixture for the request fingerprint
167    fn find_matching_fixture(&self, fingerprint: &RequestFingerprint) -> Option<&CustomFixture> {
168        let method = fingerprint.method.to_uppercase();
169        let fixtures_by_method = self.fixtures.get(&method)?;
170
171        let request_path = &fingerprint.path;
172
173        // Try exact match first
174        if let Some(fixture) = fixtures_by_method.get(request_path) {
175            return Some(fixture);
176        }
177
178        // Try pattern matching for path parameters
179        for (pattern, fixture) in fixtures_by_method.iter() {
180            if self.path_matches(pattern, request_path) {
181                return Some(fixture);
182            }
183        }
184
185        None
186    }
187
188    /// Check if a request path matches a fixture path pattern
189    ///
190    /// Supports path parameters using curly braces:
191    /// - Pattern: `/api/v1/hives/{hiveId}`
192    /// - Matches: `/api/v1/hives/hive_001`, `/api/v1/hives/123`, etc.
193    fn path_matches(&self, pattern: &str, request_path: &str) -> bool {
194        // Simple pattern matching without full regex (for performance)
195        // Split both paths into segments
196        let pattern_segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
197        let request_segments: Vec<&str> = request_path.split('/').filter(|s| !s.is_empty()).collect();
198
199        if pattern_segments.len() != request_segments.len() {
200            return false;
201        }
202
203        // Compare segments
204        for (pattern_seg, request_seg) in pattern_segments.iter().zip(request_segments.iter()) {
205            // If pattern segment is a parameter (starts with { and ends with }), it matches anything
206            if pattern_seg.starts_with('{') && pattern_seg.ends_with('}') {
207                continue;
208            }
209            // Otherwise, segments must match exactly
210            if pattern_seg != request_seg {
211                return false;
212            }
213        }
214
215        true
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use axum::http::{HeaderMap, Uri};
223    use tempfile::TempDir;
224
225    fn create_test_fingerprint(method: &str, path: &str) -> RequestFingerprint {
226        let method = Method::from_bytes(method.as_bytes()).unwrap();
227        let uri: Uri = path.parse().unwrap();
228        RequestFingerprint::new(method, &uri, &HeaderMap::new(), None)
229    }
230
231    #[test]
232    fn test_path_matching_exact() {
233        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
234        assert!(loader.path_matches("/api/v1/apiaries", "/api/v1/apiaries"));
235        assert!(!loader.path_matches("/api/v1/apiaries", "/api/v1/hives"));
236    }
237
238    #[test]
239    fn test_path_matching_with_parameters() {
240        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
241        assert!(loader.path_matches(
242            "/api/v1/hives/{hiveId}",
243            "/api/v1/hives/hive_001"
244        ));
245        assert!(loader.path_matches(
246            "/api/v1/hives/{hiveId}",
247            "/api/v1/hives/123"
248        ));
249        assert!(!loader.path_matches(
250            "/api/v1/hives/{hiveId}",
251            "/api/v1/hives/hive_001/inspections"
252        ));
253    }
254
255    #[test]
256    fn test_path_matching_multiple_parameters() {
257        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
258        assert!(loader.path_matches(
259            "/api/v1/apiaries/{apiaryId}/hives/{hiveId}",
260            "/api/v1/apiaries/apiary_001/hives/hive_001"
261        ));
262    }
263
264    #[tokio::test]
265    async fn test_load_fixture() {
266        let temp_dir = TempDir::new().unwrap();
267        let fixtures_dir = temp_dir.path().to_path_buf();
268
269        // Create a test fixture file
270        let fixture_content = r#"{
271  "method": "GET",
272  "path": "/api/v1/apiaries",
273  "status": 200,
274  "response": {
275    "success": true,
276    "data": []
277  }
278}"#;
279
280        let fixture_file = fixtures_dir.join("apiaries-list.json");
281        fs::write(&fixture_file, fixture_content).await.unwrap();
282
283        // Load fixtures
284        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
285        loader.load_fixtures().await.unwrap();
286
287        // Check if fixture is loaded
288        let fingerprint = create_test_fingerprint("GET", "/api/v1/apiaries");
289        assert!(loader.has_fixture(&fingerprint));
290
291        let fixture = loader.load_fixture(&fingerprint).unwrap();
292        assert_eq!(fixture.method, "GET");
293        assert_eq!(fixture.path, "/api/v1/apiaries");
294        assert_eq!(fixture.status, 200);
295    }
296
297    #[tokio::test]
298    async fn test_load_fixture_with_path_parameter() {
299        let temp_dir = TempDir::new().unwrap();
300        let fixtures_dir = temp_dir.path().to_path_buf();
301
302        // Create a test fixture file with path parameter
303        let fixture_content = r#"{
304  "method": "GET",
305  "path": "/api/v1/hives/{hiveId}",
306  "status": 200,
307  "response": {
308    "success": true,
309    "data": {
310      "id": "hive_001"
311    }
312  }
313}"#;
314
315        let fixture_file = fixtures_dir.join("hive-detail.json");
316        fs::write(&fixture_file, fixture_content).await.unwrap();
317
318        // Load fixtures
319        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
320        loader.load_fixtures().await.unwrap();
321
322        // Check if fixture matches with actual path
323        let fingerprint = create_test_fingerprint("GET", "/api/v1/hives/hive_001");
324        assert!(loader.has_fixture(&fingerprint));
325
326        let fixture = loader.load_fixture(&fingerprint).unwrap();
327        assert_eq!(fixture.path, "/api/v1/hives/{hiveId}");
328    }
329
330    #[tokio::test]
331    async fn test_load_multiple_fixtures() {
332        let temp_dir = TempDir::new().unwrap();
333        let fixtures_dir = temp_dir.path().to_path_buf();
334
335        // Create multiple fixture files
336        let fixtures = vec![
337            ("apiaries-list.json", r#"{
338  "method": "GET",
339  "path": "/api/v1/apiaries",
340  "status": 200,
341  "response": {"items": []}
342}"#),
343            ("hive-detail.json", r#"{
344  "method": "GET",
345  "path": "/api/v1/hives/{hiveId}",
346  "status": 200,
347  "response": {"id": "hive_001"}
348}"#),
349            ("user-profile.json", r#"{
350  "method": "GET",
351  "path": "/api/v1/users/me",
352  "status": 200,
353  "response": {"id": "user_001"}
354}"#),
355        ];
356
357        for (filename, content) in fixtures {
358            let fixture_file = fixtures_dir.join(filename);
359            fs::write(&fixture_file, content).await.unwrap();
360        }
361
362        // Load fixtures
363        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
364        loader.load_fixtures().await.unwrap();
365
366        // Verify all fixtures are loaded
367        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/apiaries")));
368        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/hives/hive_001")));
369        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/users/me")));
370    }
371
372    #[tokio::test]
373    async fn test_fixture_with_headers() {
374        let temp_dir = TempDir::new().unwrap();
375        let fixtures_dir = temp_dir.path().to_path_buf();
376
377        let fixture_content = r#"{
378  "method": "GET",
379  "path": "/api/v1/test",
380  "status": 201,
381  "response": {"result": "ok"},
382  "headers": {
383    "content-type": "application/json",
384    "x-custom-header": "test-value"
385  }
386}"#;
387
388        let fixture_file = fixtures_dir.join("test.json");
389        fs::write(&fixture_file, fixture_content).await.unwrap();
390
391        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
392        loader.load_fixtures().await.unwrap();
393
394        let fixture = loader
395            .load_fixture(&create_test_fingerprint("GET", "/api/v1/test"))
396            .unwrap();
397
398        assert_eq!(fixture.status, 201);
399        assert_eq!(fixture.headers.get("content-type"), Some(&"application/json".to_string()));
400        assert_eq!(fixture.headers.get("x-custom-header"), Some(&"test-value".to_string()));
401    }
402
403    #[tokio::test]
404    async fn test_fixture_disabled() {
405        let temp_dir = TempDir::new().unwrap();
406        let fixtures_dir = temp_dir.path().to_path_buf();
407
408        let fixture_file = fixtures_dir.join("test.json");
409        fs::write(&fixture_file, r#"{"method": "GET", "path": "/test", "status": 200, "response": {}}"#)
410            .await
411            .unwrap();
412
413        let mut loader = CustomFixtureLoader::new(fixtures_dir, false);
414        loader.load_fixtures().await.unwrap();
415
416        // Should not find fixture when disabled
417        assert!(!loader.has_fixture(&create_test_fingerprint("GET", "/test")));
418        assert!(loader.load_fixture(&create_test_fingerprint("GET", "/test")).is_none());
419    }
420}