1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CustomFixture {
28 pub method: String,
30 pub path: String,
32 pub status: u16,
34 #[serde(default)]
36 pub response: serde_json::Value,
37 #[serde(default)]
39 pub headers: HashMap<String, String>,
40 #[serde(default)]
42 pub delay_ms: u64,
43}
44
45pub struct CustomFixtureLoader {
47 fixtures_dir: PathBuf,
49 enabled: bool,
51 fixtures: HashMap<String, HashMap<String, CustomFixture>>,
53}
54
55impl CustomFixtureLoader {
56 pub fn new(fixtures_dir: PathBuf, enabled: bool) -> Self {
58 Self {
59 fixtures_dir,
60 enabled,
61 fixtures: HashMap::new(),
62 }
63 }
64
65 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 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 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 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 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 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 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 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 if let Some(fixture) = fixtures_by_method.get(request_path) {
175 return Some(fixture);
176 }
177
178 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 fn path_matches(&self, pattern: &str, request_path: &str) -> bool {
194 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 for (pattern_seg, request_seg) in pattern_segments.iter().zip(request_segments.iter()) {
205 if pattern_seg.starts_with('{') && pattern_seg.ends_with('}') {
207 continue;
208 }
209 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 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 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
285 loader.load_fixtures().await.unwrap();
286
287 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 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 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
320 loader.load_fixtures().await.unwrap();
321
322 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 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 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
364 loader.load_fixtures().await.unwrap();
365
366 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 assert!(!loader.has_fixture(&create_test_fingerprint("GET", "/test")));
418 assert!(loader.load_fixture(&create_test_fingerprint("GET", "/test")).is_none());
419 }
420}