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
90            .next_entry()
91            .await
92            .map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?
93        {
94            let path = entry.path();
95            if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
96                if let Err(e) = self.load_fixture_file(&path).await {
97                    tracing::warn!("Failed to load fixture file {}: {}", path.display(), e);
98                } else {
99                    loaded_count += 1;
100                }
101            }
102        }
103
104        tracing::info!(
105            "Loaded {} custom fixtures from {}",
106            loaded_count,
107            self.fixtures_dir.display()
108        );
109
110        Ok(())
111    }
112
113    /// Load a single fixture file
114    async fn load_fixture_file(&mut self, path: &Path) -> Result<()> {
115        let content = fs::read_to_string(path).await.map_err(|e| {
116            Error::generic(format!("Failed to read fixture file {}: {}", path.display(), e))
117        })?;
118
119        let fixture: CustomFixture = serde_json::from_str(&content).map_err(|e| {
120            Error::generic(format!("Failed to parse fixture file {}: {}", path.display(), e))
121        })?;
122
123        // Validate fixture
124        if fixture.method.is_empty() || fixture.path.is_empty() {
125            return Err(Error::generic(format!(
126                "Invalid fixture in {}: method and path are required",
127                path.display()
128            )));
129        }
130
131        // Store fixture by method and path pattern
132        let method = fixture.method.to_uppercase();
133        let fixtures_by_method = self.fixtures.entry(method).or_insert_with(HashMap::new);
134        fixtures_by_method.insert(fixture.path.clone(), fixture);
135
136        Ok(())
137    }
138
139    /// Check if a fixture exists for the given request fingerprint
140    pub fn has_fixture(&self, fingerprint: &RequestFingerprint) -> bool {
141        if !self.enabled {
142            return false;
143        }
144
145        self.find_matching_fixture(fingerprint).is_some()
146    }
147
148    /// Load a fixture for the given request fingerprint
149    pub fn load_fixture(&self, fingerprint: &RequestFingerprint) -> Option<CustomFixture> {
150        if !self.enabled {
151            return None;
152        }
153
154        self.find_matching_fixture(fingerprint).cloned()
155    }
156
157    /// Find a matching fixture for the request fingerprint
158    fn find_matching_fixture(&self, fingerprint: &RequestFingerprint) -> Option<&CustomFixture> {
159        let method = fingerprint.method.to_uppercase();
160        let fixtures_by_method = self.fixtures.get(&method)?;
161
162        let request_path = &fingerprint.path;
163
164        // Try exact match first
165        if let Some(fixture) = fixtures_by_method.get(request_path) {
166            return Some(fixture);
167        }
168
169        // Try pattern matching for path parameters
170        for (pattern, fixture) in fixtures_by_method.iter() {
171            if self.path_matches(pattern, request_path) {
172                return Some(fixture);
173            }
174        }
175
176        None
177    }
178
179    /// Check if a request path matches a fixture path pattern
180    ///
181    /// Supports path parameters using curly braces:
182    /// - Pattern: `/api/v1/hives/{hiveId}`
183    /// - Matches: `/api/v1/hives/hive_001`, `/api/v1/hives/123`, etc.
184    fn path_matches(&self, pattern: &str, request_path: &str) -> bool {
185        // Simple pattern matching without full regex (for performance)
186        // Split both paths into segments
187        let pattern_segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
188        let request_segments: Vec<&str> =
189            request_path.split('/').filter(|s| !s.is_empty()).collect();
190
191        if pattern_segments.len() != request_segments.len() {
192            return false;
193        }
194
195        // Compare segments
196        for (pattern_seg, request_seg) in pattern_segments.iter().zip(request_segments.iter()) {
197            // If pattern segment is a parameter (starts with { and ends with }), it matches anything
198            if pattern_seg.starts_with('{') && pattern_seg.ends_with('}') {
199                continue;
200            }
201            // Otherwise, segments must match exactly
202            if pattern_seg != request_seg {
203                return false;
204            }
205        }
206
207        true
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use axum::http::{HeaderMap, Uri};
215    use tempfile::TempDir;
216
217    fn create_test_fingerprint(method: &str, path: &str) -> RequestFingerprint {
218        let method = Method::from_bytes(method.as_bytes()).unwrap();
219        let uri: Uri = path.parse().unwrap();
220        RequestFingerprint::new(method, &uri, &HeaderMap::new(), None)
221    }
222
223    #[test]
224    fn test_path_matching_exact() {
225        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
226        assert!(loader.path_matches("/api/v1/apiaries", "/api/v1/apiaries"));
227        assert!(!loader.path_matches("/api/v1/apiaries", "/api/v1/hives"));
228    }
229
230    #[test]
231    fn test_path_matching_with_parameters() {
232        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
233        assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001"));
234        assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/123"));
235        assert!(
236            !loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/inspections")
237        );
238    }
239
240    #[test]
241    fn test_path_matching_multiple_parameters() {
242        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
243        assert!(loader.path_matches(
244            "/api/v1/apiaries/{apiaryId}/hives/{hiveId}",
245            "/api/v1/apiaries/apiary_001/hives/hive_001"
246        ));
247    }
248
249    #[tokio::test]
250    async fn test_load_fixture() {
251        let temp_dir = TempDir::new().unwrap();
252        let fixtures_dir = temp_dir.path().to_path_buf();
253
254        // Create a test fixture file
255        let fixture_content = r#"{
256  "method": "GET",
257  "path": "/api/v1/apiaries",
258  "status": 200,
259  "response": {
260    "success": true,
261    "data": []
262  }
263}"#;
264
265        let fixture_file = fixtures_dir.join("apiaries-list.json");
266        fs::write(&fixture_file, fixture_content).await.unwrap();
267
268        // Load fixtures
269        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
270        loader.load_fixtures().await.unwrap();
271
272        // Check if fixture is loaded
273        let fingerprint = create_test_fingerprint("GET", "/api/v1/apiaries");
274        assert!(loader.has_fixture(&fingerprint));
275
276        let fixture = loader.load_fixture(&fingerprint).unwrap();
277        assert_eq!(fixture.method, "GET");
278        assert_eq!(fixture.path, "/api/v1/apiaries");
279        assert_eq!(fixture.status, 200);
280    }
281
282    #[tokio::test]
283    async fn test_load_fixture_with_path_parameter() {
284        let temp_dir = TempDir::new().unwrap();
285        let fixtures_dir = temp_dir.path().to_path_buf();
286
287        // Create a test fixture file with path parameter
288        let fixture_content = r#"{
289  "method": "GET",
290  "path": "/api/v1/hives/{hiveId}",
291  "status": 200,
292  "response": {
293    "success": true,
294    "data": {
295      "id": "hive_001"
296    }
297  }
298}"#;
299
300        let fixture_file = fixtures_dir.join("hive-detail.json");
301        fs::write(&fixture_file, fixture_content).await.unwrap();
302
303        // Load fixtures
304        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
305        loader.load_fixtures().await.unwrap();
306
307        // Check if fixture matches with actual path
308        let fingerprint = create_test_fingerprint("GET", "/api/v1/hives/hive_001");
309        assert!(loader.has_fixture(&fingerprint));
310
311        let fixture = loader.load_fixture(&fingerprint).unwrap();
312        assert_eq!(fixture.path, "/api/v1/hives/{hiveId}");
313    }
314
315    #[tokio::test]
316    async fn test_load_multiple_fixtures() {
317        let temp_dir = TempDir::new().unwrap();
318        let fixtures_dir = temp_dir.path().to_path_buf();
319
320        // Create multiple fixture files
321        let fixtures = vec![
322            (
323                "apiaries-list.json",
324                r#"{
325  "method": "GET",
326  "path": "/api/v1/apiaries",
327  "status": 200,
328  "response": {"items": []}
329}"#,
330            ),
331            (
332                "hive-detail.json",
333                r#"{
334  "method": "GET",
335  "path": "/api/v1/hives/{hiveId}",
336  "status": 200,
337  "response": {"id": "hive_001"}
338}"#,
339            ),
340            (
341                "user-profile.json",
342                r#"{
343  "method": "GET",
344  "path": "/api/v1/users/me",
345  "status": 200,
346  "response": {"id": "user_001"}
347}"#,
348            ),
349        ];
350
351        for (filename, content) in fixtures {
352            let fixture_file = fixtures_dir.join(filename);
353            fs::write(&fixture_file, content).await.unwrap();
354        }
355
356        // Load fixtures
357        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
358        loader.load_fixtures().await.unwrap();
359
360        // Verify all fixtures are loaded
361        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/apiaries")));
362        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/hives/hive_001")));
363        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/users/me")));
364    }
365
366    #[tokio::test]
367    async fn test_fixture_with_headers() {
368        let temp_dir = TempDir::new().unwrap();
369        let fixtures_dir = temp_dir.path().to_path_buf();
370
371        let fixture_content = r#"{
372  "method": "GET",
373  "path": "/api/v1/test",
374  "status": 201,
375  "response": {"result": "ok"},
376  "headers": {
377    "content-type": "application/json",
378    "x-custom-header": "test-value"
379  }
380}"#;
381
382        let fixture_file = fixtures_dir.join("test.json");
383        fs::write(&fixture_file, fixture_content).await.unwrap();
384
385        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
386        loader.load_fixtures().await.unwrap();
387
388        let fixture = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/test")).unwrap();
389
390        assert_eq!(fixture.status, 201);
391        assert_eq!(fixture.headers.get("content-type"), Some(&"application/json".to_string()));
392        assert_eq!(fixture.headers.get("x-custom-header"), Some(&"test-value".to_string()));
393    }
394
395    #[tokio::test]
396    async fn test_fixture_disabled() {
397        let temp_dir = TempDir::new().unwrap();
398        let fixtures_dir = temp_dir.path().to_path_buf();
399
400        let fixture_file = fixtures_dir.join("test.json");
401        fs::write(
402            &fixture_file,
403            r#"{"method": "GET", "path": "/test", "status": 200, "response": {}}"#,
404        )
405        .await
406        .unwrap();
407
408        let mut loader = CustomFixtureLoader::new(fixtures_dir, false);
409        loader.load_fixtures().await.unwrap();
410
411        // Should not find fixture when disabled
412        assert!(!loader.has_fixture(&create_test_fingerprint("GET", "/test")));
413        assert!(loader.load_fixture(&create_test_fingerprint("GET", "/test")).is_none());
414    }
415}