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