1use crate::{Error, RequestFingerprint, Result};
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40use std::collections::HashMap;
41use std::path::{Path, PathBuf};
42use tokio::fs;
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CustomFixture {
47 pub method: String,
49 pub path: String,
51 pub status: u16,
53 #[serde(default)]
55 pub response: serde_json::Value,
56 #[serde(default)]
58 pub headers: HashMap<String, String>,
59 #[serde(default)]
61 pub delay_ms: u64,
62}
63
64#[derive(Debug, Deserialize)]
66pub struct NestedFixture {
67 pub request: Option<NestedRequest>,
69 pub response: Option<NestedResponse>,
71}
72
73#[derive(Debug, Deserialize)]
75pub struct NestedRequest {
76 pub method: String,
78 pub path: String,
80}
81
82#[derive(Debug, Deserialize)]
84pub struct NestedResponse {
85 pub status: u16,
87 #[serde(default)]
89 pub headers: HashMap<String, String>,
90 pub body: Value,
92}
93
94#[derive(Debug)]
96enum LoadResult {
97 Loaded,
98 Skipped,
99}
100
101pub struct CustomFixtureLoader {
103 fixtures_dir: PathBuf,
105 enabled: bool,
107 fixtures: HashMap<String, HashMap<String, CustomFixture>>,
109 stats: LoadStats,
111}
112
113#[derive(Debug, Default)]
115struct LoadStats {
116 loaded: usize,
117 failed: usize,
118 skipped: usize,
119}
120
121impl CustomFixtureLoader {
122 pub fn new(fixtures_dir: PathBuf, enabled: bool) -> Self {
124 Self {
125 fixtures_dir,
126 enabled,
127 fixtures: HashMap::new(),
128 stats: LoadStats::default(),
129 }
130 }
131
132 pub fn normalize_path(path: &str) -> String {
135 let mut normalized = path.trim().to_string();
136
137 if let Some(query_start) = normalized.find('?') {
139 normalized = normalized[..query_start].to_string();
140 }
141
142 while normalized.contains("//") {
144 normalized = normalized.replace("//", "/");
145 }
146
147 if normalized.len() > 1 && normalized.ends_with('/') {
149 normalized.pop();
150 }
151
152 if !normalized.starts_with('/') {
154 normalized = format!("/{}", normalized);
155 }
156
157 normalized
158 }
159
160 pub fn should_skip_file(content: &str) -> bool {
162 if content.contains("\"_comment\"") || content.contains("\"_usage\"") {
164 return true;
165 }
166
167 if content.contains("\"scenario\"") || content.contains("\"presentation_mode\"") {
169 return true;
170 }
171
172 false
173 }
174
175 pub fn convert_nested_to_flat(nested: NestedFixture) -> Result<CustomFixture> {
177 let request = nested
178 .request
179 .ok_or_else(|| Error::generic("Nested fixture missing 'request' object".to_string()))?;
180
181 let response = nested.response.ok_or_else(|| {
182 Error::generic("Nested fixture missing 'response' object".to_string())
183 })?;
184
185 Ok(CustomFixture {
186 method: request.method,
187 path: Self::normalize_path(&request.path),
188 status: response.status,
189 response: response.body,
190 headers: response.headers,
191 delay_ms: 0,
192 })
193 }
194
195 pub fn validate_fixture(fixture: &CustomFixture, file_path: &Path) -> Result<()> {
197 if fixture.method.is_empty() {
199 return Err(Error::generic(format!(
200 "Invalid fixture in {}: method is required and cannot be empty",
201 file_path.display()
202 )));
203 }
204
205 if fixture.path.is_empty() {
206 return Err(Error::generic(format!(
207 "Invalid fixture in {}: path is required and cannot be empty",
208 file_path.display()
209 )));
210 }
211
212 let method_upper = fixture.method.to_uppercase();
214 let valid_methods = [
215 "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE",
216 ];
217 if !valid_methods.contains(&method_upper.as_str()) {
218 tracing::warn!(
219 "Fixture {} uses non-standard HTTP method: {}",
220 file_path.display(),
221 fixture.method
222 );
223 }
224
225 if fixture.status < 100 || fixture.status >= 600 {
227 return Err(Error::generic(format!(
228 "Invalid fixture in {}: status code {} is not a valid HTTP status code (100-599)",
229 file_path.display(),
230 fixture.status
231 )));
232 }
233
234 Ok(())
235 }
236
237 pub async fn load_fixtures(&mut self) -> Result<()> {
239 if !self.enabled {
240 return Ok(());
241 }
242
243 if !self.fixtures_dir.exists() {
244 tracing::debug!(
245 "Custom fixtures directory does not exist: {}",
246 self.fixtures_dir.display()
247 );
248 return Ok(());
249 }
250
251 self.stats = LoadStats::default();
253
254 let mut entries = fs::read_dir(&self.fixtures_dir).await.map_err(|e| {
256 Error::generic(format!(
257 "Failed to read fixtures directory {}: {}",
258 self.fixtures_dir.display(),
259 e
260 ))
261 })?;
262
263 while let Some(entry) = entries
264 .next_entry()
265 .await
266 .map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?
267 {
268 let path = entry.path();
269 if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
270 match self.load_fixture_file(&path).await {
271 Ok(LoadResult::Loaded) => {
272 self.stats.loaded += 1;
273 }
274 Ok(LoadResult::Skipped) => {
275 self.stats.skipped += 1;
276 }
277 Err(e) => {
278 self.stats.failed += 1;
279 tracing::warn!("Failed to load fixture file {}: {}", path.display(), e);
280 }
281 }
282 }
283 }
284
285 tracing::info!(
287 "Fixture loading complete: {} loaded, {} failed, {} skipped from {}",
288 self.stats.loaded,
289 self.stats.failed,
290 self.stats.skipped,
291 self.fixtures_dir.display()
292 );
293
294 Ok(())
295 }
296
297 async fn load_fixture_file(&mut self, path: &Path) -> Result<LoadResult> {
299 let content = fs::read_to_string(path).await.map_err(|e| {
300 Error::generic(format!("Failed to read fixture file {}: {}", path.display(), e))
301 })?;
302
303 if Self::should_skip_file(&content) {
305 tracing::debug!("Skipping template file: {}", path.display());
306 return Ok(LoadResult::Skipped);
307 }
308
309 let fixture = match serde_json::from_str::<CustomFixture>(&content) {
311 Ok(mut fixture) => {
312 fixture.path = Self::normalize_path(&fixture.path);
314 fixture
315 }
316 Err(_) => {
317 let nested: NestedFixture = serde_json::from_str(&content).map_err(|e| {
319 Error::generic(format!(
320 "Failed to parse fixture file {}: not a valid flat or nested format. Error: {}",
321 path.display(),
322 e
323 ))
324 })?;
325
326 Self::convert_nested_to_flat(nested)?
328 }
329 };
330
331 Self::validate_fixture(&fixture, path)?;
333
334 let method = fixture.method.to_uppercase();
336 let fixtures_by_method = self.fixtures.entry(method.clone()).or_default();
337
338 if fixtures_by_method.contains_key(&fixture.path) {
340 tracing::warn!(
341 "Duplicate fixture path '{}' for method '{}' in file {} (overwriting previous)",
342 fixture.path,
343 method,
344 path.display()
345 );
346 }
347
348 fixtures_by_method.insert(fixture.path.clone(), fixture);
349
350 Ok(LoadResult::Loaded)
351 }
352
353 pub fn has_fixture(&self, fingerprint: &RequestFingerprint) -> bool {
355 if !self.enabled {
356 return false;
357 }
358
359 self.find_matching_fixture(fingerprint).is_some()
360 }
361
362 pub fn load_fixture(&self, fingerprint: &RequestFingerprint) -> Option<CustomFixture> {
364 if !self.enabled {
365 return None;
366 }
367
368 self.find_matching_fixture(fingerprint).cloned()
369 }
370
371 fn find_matching_fixture(&self, fingerprint: &RequestFingerprint) -> Option<&CustomFixture> {
373 let method = fingerprint.method.to_uppercase();
374 let fixtures_by_method = self.fixtures.get(&method)?;
375
376 let request_path = Self::normalize_path(&fingerprint.path);
378
379 tracing::debug!(
381 "Fixture matching: method={}, fingerprint.path='{}', normalized='{}', available fixtures: {:?}",
382 method,
383 fingerprint.path,
384 request_path,
385 fixtures_by_method.keys().collect::<Vec<_>>()
386 );
387
388 if let Some(fixture) = fixtures_by_method.get(&request_path) {
390 tracing::debug!("Found exact fixture match: {} {}", method, request_path);
391 return Some(fixture);
392 }
393
394 for (pattern, fixture) in fixtures_by_method.iter() {
396 if self.path_matches(pattern, &request_path) {
397 tracing::debug!(
398 "Found pattern fixture match: {} {} (pattern: {})",
399 method,
400 request_path,
401 pattern
402 );
403 return Some(fixture);
404 }
405 }
406
407 tracing::debug!("No fixture match found for: {} {}", method, request_path);
408 None
409 }
410
411 fn path_matches(&self, pattern: &str, request_path: &str) -> bool {
417 let normalized_pattern = Self::normalize_path(pattern);
419 let normalized_request = Self::normalize_path(request_path);
420
421 let pattern_segments: Vec<&str> =
424 normalized_pattern.split('/').filter(|s| !s.is_empty()).collect();
425 let request_segments: Vec<&str> =
426 normalized_request.split('/').filter(|s| !s.is_empty()).collect();
427
428 if pattern_segments.len() != request_segments.len() {
429 return false;
430 }
431
432 for (pattern_seg, request_seg) in pattern_segments.iter().zip(request_segments.iter()) {
434 if pattern_seg.starts_with('{') && pattern_seg.ends_with('}') {
436 continue;
437 }
438 if pattern_seg != request_seg {
440 return false;
441 }
442 }
443
444 true
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use axum::http::{HeaderMap, Method, Uri};
452 use tempfile::TempDir;
453
454 fn create_test_fingerprint(method: &str, path: &str) -> RequestFingerprint {
455 let method = Method::from_bytes(method.as_bytes()).unwrap();
456 let uri: Uri = path.parse().unwrap();
457 RequestFingerprint::new(method, &uri, &HeaderMap::new(), None)
458 }
459
460 #[test]
461 fn test_path_matching_exact() {
462 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
463 assert!(loader.path_matches("/api/v1/apiaries", "/api/v1/apiaries"));
464 assert!(!loader.path_matches("/api/v1/apiaries", "/api/v1/hives"));
465 }
466
467 #[test]
468 fn test_path_matching_with_parameters() {
469 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
470 assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001"));
471 assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/123"));
472 assert!(
473 !loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/inspections")
474 );
475 }
476
477 #[test]
478 fn test_path_matching_multiple_parameters() {
479 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
480 assert!(loader.path_matches(
481 "/api/v1/apiaries/{apiaryId}/hives/{hiveId}",
482 "/api/v1/apiaries/apiary_001/hives/hive_001"
483 ));
484 }
485
486 #[tokio::test]
487 async fn test_load_fixture() {
488 let temp_dir = TempDir::new().unwrap();
489 let fixtures_dir = temp_dir.path().to_path_buf();
490
491 let fixture_content = r#"{
493 "method": "GET",
494 "path": "/api/v1/apiaries",
495 "status": 200,
496 "response": {
497 "success": true,
498 "data": []
499 }
500}"#;
501
502 let fixture_file = fixtures_dir.join("apiaries-list.json");
503 fs::write(&fixture_file, fixture_content).await.unwrap();
504
505 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
507 loader.load_fixtures().await.unwrap();
508
509 let fingerprint = create_test_fingerprint("GET", "/api/v1/apiaries");
511 assert!(loader.has_fixture(&fingerprint));
512
513 let fixture = loader.load_fixture(&fingerprint).unwrap();
514 assert_eq!(fixture.method, "GET");
515 assert_eq!(fixture.path, "/api/v1/apiaries");
516 assert_eq!(fixture.status, 200);
517 }
518
519 #[tokio::test]
520 async fn test_load_fixture_with_path_parameter() {
521 let temp_dir = TempDir::new().unwrap();
522 let fixtures_dir = temp_dir.path().to_path_buf();
523
524 let fixture_content = r#"{
526 "method": "GET",
527 "path": "/api/v1/hives/{hiveId}",
528 "status": 200,
529 "response": {
530 "success": true,
531 "data": {
532 "id": "hive_001"
533 }
534 }
535}"#;
536
537 let fixture_file = fixtures_dir.join("hive-detail.json");
538 fs::write(&fixture_file, fixture_content).await.unwrap();
539
540 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
542 loader.load_fixtures().await.unwrap();
543
544 let fingerprint = create_test_fingerprint("GET", "/api/v1/hives/hive_001");
546 assert!(loader.has_fixture(&fingerprint));
547
548 let fixture = loader.load_fixture(&fingerprint).unwrap();
549 assert_eq!(fixture.path, "/api/v1/hives/{hiveId}");
550 }
551
552 #[tokio::test]
553 async fn test_load_multiple_fixtures() {
554 let temp_dir = TempDir::new().unwrap();
555 let fixtures_dir = temp_dir.path().to_path_buf();
556
557 let fixtures = vec![
559 (
560 "apiaries-list.json",
561 r#"{
562 "method": "GET",
563 "path": "/api/v1/apiaries",
564 "status": 200,
565 "response": {"items": []}
566}"#,
567 ),
568 (
569 "hive-detail.json",
570 r#"{
571 "method": "GET",
572 "path": "/api/v1/hives/{hiveId}",
573 "status": 200,
574 "response": {"id": "hive_001"}
575}"#,
576 ),
577 (
578 "user-profile.json",
579 r#"{
580 "method": "GET",
581 "path": "/api/v1/users/me",
582 "status": 200,
583 "response": {"id": "user_001"}
584}"#,
585 ),
586 ];
587
588 for (filename, content) in fixtures {
589 let fixture_file = fixtures_dir.join(filename);
590 fs::write(&fixture_file, content).await.unwrap();
591 }
592
593 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
595 loader.load_fixtures().await.unwrap();
596
597 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/apiaries")));
599 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/hives/hive_001")));
600 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/users/me")));
601 }
602
603 #[tokio::test]
604 async fn test_fixture_with_headers() {
605 let temp_dir = TempDir::new().unwrap();
606 let fixtures_dir = temp_dir.path().to_path_buf();
607
608 let fixture_content = r#"{
609 "method": "GET",
610 "path": "/api/v1/test",
611 "status": 201,
612 "response": {"result": "ok"},
613 "headers": {
614 "content-type": "application/json",
615 "x-custom-header": "test-value"
616 }
617}"#;
618
619 let fixture_file = fixtures_dir.join("test.json");
620 fs::write(&fixture_file, fixture_content).await.unwrap();
621
622 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
623 loader.load_fixtures().await.unwrap();
624
625 let fixture = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/test")).unwrap();
626
627 assert_eq!(fixture.status, 201);
628 assert_eq!(fixture.headers.get("content-type"), Some(&"application/json".to_string()));
629 assert_eq!(fixture.headers.get("x-custom-header"), Some(&"test-value".to_string()));
630 }
631
632 #[tokio::test]
633 async fn test_fixture_disabled() {
634 let temp_dir = TempDir::new().unwrap();
635 let fixtures_dir = temp_dir.path().to_path_buf();
636
637 let fixture_file = fixtures_dir.join("test.json");
638 fs::write(
639 &fixture_file,
640 r#"{"method": "GET", "path": "/test", "status": 200, "response": {}}"#,
641 )
642 .await
643 .unwrap();
644
645 let mut loader = CustomFixtureLoader::new(fixtures_dir, false);
646 loader.load_fixtures().await.unwrap();
647
648 assert!(!loader.has_fixture(&create_test_fingerprint("GET", "/test")));
650 assert!(loader.load_fixture(&create_test_fingerprint("GET", "/test")).is_none());
651 }
652
653 #[tokio::test]
654 async fn test_load_nested_format() {
655 let temp_dir = TempDir::new().unwrap();
656 let fixtures_dir = temp_dir.path().to_path_buf();
657
658 let fixture_content = r#"{
660 "request": {
661 "method": "POST",
662 "path": "/api/auth/login"
663 },
664 "response": {
665 "status": 200,
666 "headers": {
667 "Content-Type": "application/json"
668 },
669 "body": {
670 "access_token": "test_token",
671 "user": {
672 "id": "user_001"
673 }
674 }
675 }
676 }"#;
677
678 let fixture_file = fixtures_dir.join("auth-login.json");
679 fs::write(&fixture_file, fixture_content).await.unwrap();
680
681 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
683 loader.load_fixtures().await.unwrap();
684
685 let fingerprint = create_test_fingerprint("POST", "/api/auth/login");
687 assert!(loader.has_fixture(&fingerprint));
688
689 let fixture = loader.load_fixture(&fingerprint).unwrap();
690 assert_eq!(fixture.method, "POST");
691 assert_eq!(fixture.path, "/api/auth/login");
692 assert_eq!(fixture.status, 200);
693 assert_eq!(fixture.headers.get("Content-Type"), Some(&"application/json".to_string()));
694 assert!(fixture.response.get("access_token").is_some());
695 }
696
697 #[test]
698 fn test_path_normalization() {
699 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
700
701 assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test/"), "/api/v1/test");
703 assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test"), "/api/v1/test");
704
705 assert_eq!(CustomFixtureLoader::normalize_path("/"), "/");
707
708 assert_eq!(CustomFixtureLoader::normalize_path("/api//v1///test"), "/api/v1/test");
710
711 assert_eq!(CustomFixtureLoader::normalize_path("api/v1/test"), "/api/v1/test");
713
714 assert_eq!(CustomFixtureLoader::normalize_path(" /api/v1/test "), "/api/v1/test");
716 }
717
718 #[test]
719 fn test_path_matching_with_normalization() {
720 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
721
722 assert!(loader.path_matches("/api/v1/test", "/api/v1/test/"));
724 assert!(loader.path_matches("/api/v1/test/", "/api/v1/test"));
725
726 assert!(loader.path_matches("/api/v1/test", "/api//v1///test"));
728
729 assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/"));
731 }
732
733 #[tokio::test]
734 async fn test_skip_template_files() {
735 let temp_dir = TempDir::new().unwrap();
736 let fixtures_dir = temp_dir.path().to_path_buf();
737
738 let template_content = r#"{
740 "_comment": "This is a template",
741 "_usage": "Use this for errors",
742 "error": {
743 "code": "ERROR_CODE"
744 }
745 }"#;
746
747 let template_file = fixtures_dir.join("error-template.json");
748 fs::write(&template_file, template_content).await.unwrap();
749
750 let valid_fixture = r#"{
752 "method": "GET",
753 "path": "/api/test",
754 "status": 200,
755 "response": {}
756 }"#;
757 let valid_file = fixtures_dir.join("valid.json");
758 fs::write(&valid_file, valid_fixture).await.unwrap();
759
760 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
762 loader.load_fixtures().await.unwrap();
763
764 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
766
767 }
771
772 #[tokio::test]
773 async fn test_skip_scenario_files() {
774 let temp_dir = TempDir::new().unwrap();
775 let fixtures_dir = temp_dir.path().to_path_buf();
776
777 let scenario_content = r#"{
779 "scenario": "demo",
780 "presentation_mode": true,
781 "apiaries": []
782 }"#;
783
784 let scenario_file = fixtures_dir.join("demo-scenario.json");
785 fs::write(&scenario_file, scenario_content).await.unwrap();
786
787 let valid_fixture = r#"{
789 "method": "GET",
790 "path": "/api/test",
791 "status": 200,
792 "response": {}
793 }"#;
794 let valid_file = fixtures_dir.join("valid.json");
795 fs::write(&valid_file, valid_fixture).await.unwrap();
796
797 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
799 loader.load_fixtures().await.unwrap();
800
801 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
803 }
804
805 #[tokio::test]
806 async fn test_mixed_format_fixtures() {
807 let temp_dir = TempDir::new().unwrap();
808 let fixtures_dir = temp_dir.path().to_path_buf();
809
810 let flat_fixture = r#"{
812 "method": "GET",
813 "path": "/api/v1/flat",
814 "status": 200,
815 "response": {"type": "flat"}
816 }"#;
817
818 let nested_fixture = r#"{
820 "request": {
821 "method": "GET",
822 "path": "/api/v1/nested"
823 },
824 "response": {
825 "status": 200,
826 "body": {"type": "nested"}
827 }
828 }"#;
829
830 fs::write(fixtures_dir.join("flat.json"), flat_fixture).await.unwrap();
831 fs::write(fixtures_dir.join("nested.json"), nested_fixture).await.unwrap();
832
833 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
835 loader.load_fixtures().await.unwrap();
836
837 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/flat")));
839 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/nested")));
840
841 let flat = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/flat")).unwrap();
842 assert_eq!(flat.response.get("type").and_then(|v| v.as_str()), Some("flat"));
843
844 let nested =
845 loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/nested")).unwrap();
846 assert_eq!(nested.response.get("type").and_then(|v| v.as_str()), Some("nested"));
847 }
848
849 #[tokio::test]
850 async fn test_validation_errors() {
851 let temp_dir = TempDir::new().unwrap();
852 let fixtures_dir = temp_dir.path().to_path_buf();
853
854 let no_method = r#"{
856 "path": "/api/test",
857 "status": 200,
858 "response": {}
859 }"#;
860 fs::write(fixtures_dir.join("no-method.json"), no_method).await.unwrap();
861
862 let invalid_status = r#"{
864 "method": "GET",
865 "path": "/api/test",
866 "status": 999,
867 "response": {}
868 }"#;
869 fs::write(fixtures_dir.join("invalid-status.json"), invalid_status)
870 .await
871 .unwrap();
872
873 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
875 let result = loader.load_fixtures().await;
876
877 assert!(result.is_ok());
879 }
880}