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.request.ok_or_else(|| {
178 Error::generic("Nested fixture missing 'request' object".to_string())
179 })?;
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 = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"];
215 if !valid_methods.contains(&method_upper.as_str()) {
216 tracing::warn!(
217 "Fixture {} uses non-standard HTTP method: {}",
218 file_path.display(),
219 fixture.method
220 );
221 }
222
223 if fixture.status < 100 || fixture.status >= 600 {
225 return Err(Error::generic(format!(
226 "Invalid fixture in {}: status code {} is not a valid HTTP status code (100-599)",
227 file_path.display(),
228 fixture.status
229 )));
230 }
231
232 Ok(())
233 }
234
235 pub async fn load_fixtures(&mut self) -> Result<()> {
237 if !self.enabled {
238 return Ok(());
239 }
240
241 if !self.fixtures_dir.exists() {
242 tracing::debug!(
243 "Custom fixtures directory does not exist: {}",
244 self.fixtures_dir.display()
245 );
246 return Ok(());
247 }
248
249 self.stats = LoadStats::default();
251
252 let mut entries = fs::read_dir(&self.fixtures_dir).await.map_err(|e| {
254 Error::generic(format!(
255 "Failed to read fixtures directory {}: {}",
256 self.fixtures_dir.display(),
257 e
258 ))
259 })?;
260
261 while let Some(entry) = entries
262 .next_entry()
263 .await
264 .map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?
265 {
266 let path = entry.path();
267 if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
268 match self.load_fixture_file(&path).await {
269 Ok(LoadResult::Loaded) => {
270 self.stats.loaded += 1;
271 }
272 Ok(LoadResult::Skipped) => {
273 self.stats.skipped += 1;
274 }
275 Err(e) => {
276 self.stats.failed += 1;
277 tracing::warn!("Failed to load fixture file {}: {}", path.display(), e);
278 }
279 }
280 }
281 }
282
283 tracing::info!(
285 "Fixture loading complete: {} loaded, {} failed, {} skipped from {}",
286 self.stats.loaded,
287 self.stats.failed,
288 self.stats.skipped,
289 self.fixtures_dir.display()
290 );
291
292 Ok(())
293 }
294
295 async fn load_fixture_file(&mut self, path: &Path) -> Result<LoadResult> {
297 let content = fs::read_to_string(path).await.map_err(|e| {
298 Error::generic(format!("Failed to read fixture file {}: {}", path.display(), e))
299 })?;
300
301 if Self::should_skip_file(&content) {
303 tracing::debug!("Skipping template file: {}", path.display());
304 return Ok(LoadResult::Skipped);
305 }
306
307 let fixture = match serde_json::from_str::<CustomFixture>(&content) {
309 Ok(mut fixture) => {
310 fixture.path = Self::normalize_path(&fixture.path);
312 fixture
313 }
314 Err(_) => {
315 let nested: NestedFixture = serde_json::from_str(&content).map_err(|e| {
317 Error::generic(format!(
318 "Failed to parse fixture file {}: not a valid flat or nested format. Error: {}",
319 path.display(),
320 e
321 ))
322 })?;
323
324 Self::convert_nested_to_flat(nested)?
326 }
327 };
328
329 Self::validate_fixture(&fixture, path)?;
331
332 let method = fixture.method.to_uppercase();
334 let fixtures_by_method = self.fixtures.entry(method.clone()).or_default();
335
336 if fixtures_by_method.contains_key(&fixture.path) {
338 tracing::warn!(
339 "Duplicate fixture path '{}' for method '{}' in file {} (overwriting previous)",
340 fixture.path,
341 method,
342 path.display()
343 );
344 }
345
346 fixtures_by_method.insert(fixture.path.clone(), fixture);
347
348 Ok(LoadResult::Loaded)
349 }
350
351 pub fn has_fixture(&self, fingerprint: &RequestFingerprint) -> bool {
353 if !self.enabled {
354 return false;
355 }
356
357 self.find_matching_fixture(fingerprint).is_some()
358 }
359
360 pub fn load_fixture(&self, fingerprint: &RequestFingerprint) -> Option<CustomFixture> {
362 if !self.enabled {
363 return None;
364 }
365
366 self.find_matching_fixture(fingerprint).cloned()
367 }
368
369 fn find_matching_fixture(&self, fingerprint: &RequestFingerprint) -> Option<&CustomFixture> {
371 let method = fingerprint.method.to_uppercase();
372 let fixtures_by_method = self.fixtures.get(&method)?;
373
374 let request_path = Self::normalize_path(&fingerprint.path);
376
377 if let Some(fixture) = fixtures_by_method.get(&request_path) {
379 tracing::debug!(
380 "Found exact fixture match: {} {}",
381 method,
382 request_path
383 );
384 return Some(fixture);
385 }
386
387 for (pattern, fixture) in fixtures_by_method.iter() {
389 if self.path_matches(pattern, &request_path) {
390 tracing::debug!(
391 "Found pattern fixture match: {} {} (pattern: {})",
392 method,
393 request_path,
394 pattern
395 );
396 return Some(fixture);
397 }
398 }
399
400 tracing::debug!(
401 "No fixture match found for: {} {}",
402 method,
403 request_path
404 );
405 None
406 }
407
408 fn path_matches(&self, pattern: &str, request_path: &str) -> bool {
414 let normalized_pattern = Self::normalize_path(pattern);
416 let normalized_request = Self::normalize_path(request_path);
417
418 let pattern_segments: Vec<&str> = normalized_pattern
421 .split('/')
422 .filter(|s| !s.is_empty())
423 .collect();
424 let request_segments: Vec<&str> = normalized_request
425 .split('/')
426 .filter(|s| !s.is_empty())
427 .collect();
428
429 if pattern_segments.len() != request_segments.len() {
430 return false;
431 }
432
433 for (pattern_seg, request_seg) in pattern_segments.iter().zip(request_segments.iter()) {
435 if pattern_seg.starts_with('{') && pattern_seg.ends_with('}') {
437 continue;
438 }
439 if pattern_seg != request_seg {
441 return false;
442 }
443 }
444
445 true
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use axum::http::{HeaderMap, Method, Uri};
453 use tempfile::TempDir;
454
455 fn create_test_fingerprint(method: &str, path: &str) -> RequestFingerprint {
456 let method = Method::from_bytes(method.as_bytes()).unwrap();
457 let uri: Uri = path.parse().unwrap();
458 RequestFingerprint::new(method, &uri, &HeaderMap::new(), None)
459 }
460
461 #[test]
462 fn test_path_matching_exact() {
463 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
464 assert!(loader.path_matches("/api/v1/apiaries", "/api/v1/apiaries"));
465 assert!(!loader.path_matches("/api/v1/apiaries", "/api/v1/hives"));
466 }
467
468 #[test]
469 fn test_path_matching_with_parameters() {
470 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
471 assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001"));
472 assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/123"));
473 assert!(
474 !loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/inspections")
475 );
476 }
477
478 #[test]
479 fn test_path_matching_multiple_parameters() {
480 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
481 assert!(loader.path_matches(
482 "/api/v1/apiaries/{apiaryId}/hives/{hiveId}",
483 "/api/v1/apiaries/apiary_001/hives/hive_001"
484 ));
485 }
486
487 #[tokio::test]
488 async fn test_load_fixture() {
489 let temp_dir = TempDir::new().unwrap();
490 let fixtures_dir = temp_dir.path().to_path_buf();
491
492 let fixture_content = r#"{
494 "method": "GET",
495 "path": "/api/v1/apiaries",
496 "status": 200,
497 "response": {
498 "success": true,
499 "data": []
500 }
501}"#;
502
503 let fixture_file = fixtures_dir.join("apiaries-list.json");
504 fs::write(&fixture_file, fixture_content).await.unwrap();
505
506 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
508 loader.load_fixtures().await.unwrap();
509
510 let fingerprint = create_test_fingerprint("GET", "/api/v1/apiaries");
512 assert!(loader.has_fixture(&fingerprint));
513
514 let fixture = loader.load_fixture(&fingerprint).unwrap();
515 assert_eq!(fixture.method, "GET");
516 assert_eq!(fixture.path, "/api/v1/apiaries");
517 assert_eq!(fixture.status, 200);
518 }
519
520 #[tokio::test]
521 async fn test_load_fixture_with_path_parameter() {
522 let temp_dir = TempDir::new().unwrap();
523 let fixtures_dir = temp_dir.path().to_path_buf();
524
525 let fixture_content = r#"{
527 "method": "GET",
528 "path": "/api/v1/hives/{hiveId}",
529 "status": 200,
530 "response": {
531 "success": true,
532 "data": {
533 "id": "hive_001"
534 }
535 }
536}"#;
537
538 let fixture_file = fixtures_dir.join("hive-detail.json");
539 fs::write(&fixture_file, fixture_content).await.unwrap();
540
541 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
543 loader.load_fixtures().await.unwrap();
544
545 let fingerprint = create_test_fingerprint("GET", "/api/v1/hives/hive_001");
547 assert!(loader.has_fixture(&fingerprint));
548
549 let fixture = loader.load_fixture(&fingerprint).unwrap();
550 assert_eq!(fixture.path, "/api/v1/hives/{hiveId}");
551 }
552
553 #[tokio::test]
554 async fn test_load_multiple_fixtures() {
555 let temp_dir = TempDir::new().unwrap();
556 let fixtures_dir = temp_dir.path().to_path_buf();
557
558 let fixtures = vec![
560 (
561 "apiaries-list.json",
562 r#"{
563 "method": "GET",
564 "path": "/api/v1/apiaries",
565 "status": 200,
566 "response": {"items": []}
567}"#,
568 ),
569 (
570 "hive-detail.json",
571 r#"{
572 "method": "GET",
573 "path": "/api/v1/hives/{hiveId}",
574 "status": 200,
575 "response": {"id": "hive_001"}
576}"#,
577 ),
578 (
579 "user-profile.json",
580 r#"{
581 "method": "GET",
582 "path": "/api/v1/users/me",
583 "status": 200,
584 "response": {"id": "user_001"}
585}"#,
586 ),
587 ];
588
589 for (filename, content) in fixtures {
590 let fixture_file = fixtures_dir.join(filename);
591 fs::write(&fixture_file, content).await.unwrap();
592 }
593
594 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
596 loader.load_fixtures().await.unwrap();
597
598 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/apiaries")));
600 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/hives/hive_001")));
601 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/users/me")));
602 }
603
604 #[tokio::test]
605 async fn test_fixture_with_headers() {
606 let temp_dir = TempDir::new().unwrap();
607 let fixtures_dir = temp_dir.path().to_path_buf();
608
609 let fixture_content = r#"{
610 "method": "GET",
611 "path": "/api/v1/test",
612 "status": 201,
613 "response": {"result": "ok"},
614 "headers": {
615 "content-type": "application/json",
616 "x-custom-header": "test-value"
617 }
618}"#;
619
620 let fixture_file = fixtures_dir.join("test.json");
621 fs::write(&fixture_file, fixture_content).await.unwrap();
622
623 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
624 loader.load_fixtures().await.unwrap();
625
626 let fixture = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/test")).unwrap();
627
628 assert_eq!(fixture.status, 201);
629 assert_eq!(fixture.headers.get("content-type"), Some(&"application/json".to_string()));
630 assert_eq!(fixture.headers.get("x-custom-header"), Some(&"test-value".to_string()));
631 }
632
633 #[tokio::test]
634 async fn test_fixture_disabled() {
635 let temp_dir = TempDir::new().unwrap();
636 let fixtures_dir = temp_dir.path().to_path_buf();
637
638 let fixture_file = fixtures_dir.join("test.json");
639 fs::write(
640 &fixture_file,
641 r#"{"method": "GET", "path": "/test", "status": 200, "response": {}}"#,
642 )
643 .await
644 .unwrap();
645
646 let mut loader = CustomFixtureLoader::new(fixtures_dir, false);
647 loader.load_fixtures().await.unwrap();
648
649 assert!(!loader.has_fixture(&create_test_fingerprint("GET", "/test")));
651 assert!(loader.load_fixture(&create_test_fingerprint("GET", "/test")).is_none());
652 }
653
654 #[tokio::test]
655 async fn test_load_nested_format() {
656 let temp_dir = TempDir::new().unwrap();
657 let fixtures_dir = temp_dir.path().to_path_buf();
658
659 let fixture_content = r#"{
661 "request": {
662 "method": "POST",
663 "path": "/api/auth/login"
664 },
665 "response": {
666 "status": 200,
667 "headers": {
668 "Content-Type": "application/json"
669 },
670 "body": {
671 "access_token": "test_token",
672 "user": {
673 "id": "user_001"
674 }
675 }
676 }
677 }"#;
678
679 let fixture_file = fixtures_dir.join("auth-login.json");
680 fs::write(&fixture_file, fixture_content).await.unwrap();
681
682 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
684 loader.load_fixtures().await.unwrap();
685
686 let fingerprint = create_test_fingerprint("POST", "/api/auth/login");
688 assert!(loader.has_fixture(&fingerprint));
689
690 let fixture = loader.load_fixture(&fingerprint).unwrap();
691 assert_eq!(fixture.method, "POST");
692 assert_eq!(fixture.path, "/api/auth/login");
693 assert_eq!(fixture.status, 200);
694 assert_eq!(
695 fixture.headers.get("Content-Type"),
696 Some(&"application/json".to_string())
697 );
698 assert!(fixture.response.get("access_token").is_some());
699 }
700
701 #[test]
702 fn test_path_normalization() {
703 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
704
705 assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test/"), "/api/v1/test");
707 assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test"), "/api/v1/test");
708
709 assert_eq!(CustomFixtureLoader::normalize_path("/"), "/");
711
712 assert_eq!(CustomFixtureLoader::normalize_path("/api//v1///test"), "/api/v1/test");
714
715 assert_eq!(CustomFixtureLoader::normalize_path("api/v1/test"), "/api/v1/test");
717
718 assert_eq!(CustomFixtureLoader::normalize_path(" /api/v1/test "), "/api/v1/test");
720 }
721
722 #[test]
723 fn test_path_matching_with_normalization() {
724 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
725
726 assert!(loader.path_matches("/api/v1/test", "/api/v1/test/"));
728 assert!(loader.path_matches("/api/v1/test/", "/api/v1/test"));
729
730 assert!(loader.path_matches("/api/v1/test", "/api//v1///test"));
732
733 assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/"));
735 }
736
737 #[tokio::test]
738 async fn test_skip_template_files() {
739 let temp_dir = TempDir::new().unwrap();
740 let fixtures_dir = temp_dir.path().to_path_buf();
741
742 let template_content = r#"{
744 "_comment": "This is a template",
745 "_usage": "Use this for errors",
746 "error": {
747 "code": "ERROR_CODE"
748 }
749 }"#;
750
751 let template_file = fixtures_dir.join("error-template.json");
752 fs::write(&template_file, template_content).await.unwrap();
753
754 let valid_fixture = r#"{
756 "method": "GET",
757 "path": "/api/test",
758 "status": 200,
759 "response": {}
760 }"#;
761 let valid_file = fixtures_dir.join("valid.json");
762 fs::write(&valid_file, valid_fixture).await.unwrap();
763
764 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
766 loader.load_fixtures().await.unwrap();
767
768 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
770
771 }
775
776 #[tokio::test]
777 async fn test_skip_scenario_files() {
778 let temp_dir = TempDir::new().unwrap();
779 let fixtures_dir = temp_dir.path().to_path_buf();
780
781 let scenario_content = r#"{
783 "scenario": "demo",
784 "presentation_mode": true,
785 "apiaries": []
786 }"#;
787
788 let scenario_file = fixtures_dir.join("demo-scenario.json");
789 fs::write(&scenario_file, scenario_content).await.unwrap();
790
791 let valid_fixture = r#"{
793 "method": "GET",
794 "path": "/api/test",
795 "status": 200,
796 "response": {}
797 }"#;
798 let valid_file = fixtures_dir.join("valid.json");
799 fs::write(&valid_file, valid_fixture).await.unwrap();
800
801 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
803 loader.load_fixtures().await.unwrap();
804
805 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
807 }
808
809 #[tokio::test]
810 async fn test_mixed_format_fixtures() {
811 let temp_dir = TempDir::new().unwrap();
812 let fixtures_dir = temp_dir.path().to_path_buf();
813
814 let flat_fixture = r#"{
816 "method": "GET",
817 "path": "/api/v1/flat",
818 "status": 200,
819 "response": {"type": "flat"}
820 }"#;
821
822 let nested_fixture = r#"{
824 "request": {
825 "method": "GET",
826 "path": "/api/v1/nested"
827 },
828 "response": {
829 "status": 200,
830 "body": {"type": "nested"}
831 }
832 }"#;
833
834 fs::write(fixtures_dir.join("flat.json"), flat_fixture).await.unwrap();
835 fs::write(fixtures_dir.join("nested.json"), nested_fixture).await.unwrap();
836
837 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
839 loader.load_fixtures().await.unwrap();
840
841 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/flat")));
843 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/nested")).is_some());
844
845 let flat = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/flat")).unwrap();
846 assert_eq!(flat.response.get("type").and_then(|v| v.as_str()), Some("flat"));
847
848 let nested = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/nested")).unwrap();
849 assert_eq!(nested.response.get("type").and_then(|v| v.as_str()), Some("nested"));
850 }
851
852 #[tokio::test]
853 async fn test_validation_errors() {
854 let temp_dir = TempDir::new().unwrap();
855 let fixtures_dir = temp_dir.path().to_path_buf();
856
857 let no_method = r#"{
859 "path": "/api/test",
860 "status": 200,
861 "response": {}
862 }"#;
863 fs::write(fixtures_dir.join("no-method.json"), no_method).await.unwrap();
864
865 let invalid_status = r#"{
867 "method": "GET",
868 "path": "/api/test",
869 "status": 999,
870 "response": {}
871 }"#;
872 fs::write(fixtures_dir.join("invalid-status.json"), invalid_status).await.unwrap();
873
874 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
876 let result = loader.load_fixtures().await;
877
878 assert!(result.is_ok());
880 }
881}