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: 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::config("Nested fixture missing 'request' object"))?;
180
181 let response = nested
182 .response
183 .ok_or_else(|| Error::config("Nested fixture missing 'response' object"))?;
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::validation(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::validation(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::validation(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::io_with_context(
257 format!("reading fixtures directory {}", self.fixtures_dir.display()),
258 e.to_string(),
259 )
260 })?;
261
262 while let Some(entry) = entries
263 .next_entry()
264 .await
265 .map_err(|e| Error::io_with_context("reading directory entry", e.to_string()))?
266 {
267 let path = entry.path();
268 if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
269 match self.load_fixture_file(&path).await {
270 Ok(LoadResult::Loaded) => {
271 self.stats.loaded += 1;
272 }
273 Ok(LoadResult::Skipped) => {
274 self.stats.skipped += 1;
275 }
276 Err(e) => {
277 self.stats.failed += 1;
278 tracing::warn!("Failed to load fixture file {}: {}", path.display(), e);
279 }
280 }
281 }
282 }
283
284 tracing::info!(
286 "Fixture loading complete: {} loaded, {} failed, {} skipped from {}",
287 self.stats.loaded,
288 self.stats.failed,
289 self.stats.skipped,
290 self.fixtures_dir.display()
291 );
292
293 Ok(())
294 }
295
296 async fn load_fixture_file(&mut self, path: &Path) -> Result<LoadResult> {
298 let content = fs::read_to_string(path).await.map_err(|e| {
299 Error::io_with_context(
300 format!("reading fixture file {}", path.display()),
301 e.to_string(),
302 )
303 })?;
304
305 if Self::should_skip_file(&content) {
307 tracing::debug!("Skipping template file: {}", path.display());
308 return Ok(LoadResult::Skipped);
309 }
310
311 let fixture = match serde_json::from_str::<CustomFixture>(&content) {
313 Ok(mut fixture) => {
314 fixture.path = Self::normalize_path(&fixture.path);
316 fixture
317 }
318 Err(_) => {
319 let nested: NestedFixture = serde_json::from_str(&content).map_err(|e| {
321 Error::config(format!(
322 "Failed to parse fixture file {}: not a valid flat or nested format. Error: {}",
323 path.display(),
324 e
325 ))
326 })?;
327
328 Self::convert_nested_to_flat(nested)?
330 }
331 };
332
333 Self::validate_fixture(&fixture, path)?;
335
336 let method = fixture.method.to_uppercase();
338 let fixtures_by_method = self.fixtures.entry(method.clone()).or_default();
339
340 if fixtures_by_method.contains_key(&fixture.path) {
342 tracing::warn!(
343 "Duplicate fixture path '{}' for method '{}' in file {} (overwriting previous)",
344 fixture.path,
345 method,
346 path.display()
347 );
348 }
349
350 fixtures_by_method.insert(fixture.path.clone(), fixture);
351
352 Ok(LoadResult::Loaded)
353 }
354
355 pub fn has_fixture(&self, fingerprint: &RequestFingerprint) -> bool {
357 if !self.enabled {
358 return false;
359 }
360
361 self.find_matching_fixture(fingerprint).is_some()
362 }
363
364 pub fn load_fixture(&self, fingerprint: &RequestFingerprint) -> Option<CustomFixture> {
366 if !self.enabled {
367 return None;
368 }
369
370 self.find_matching_fixture(fingerprint).cloned()
371 }
372
373 fn find_matching_fixture(&self, fingerprint: &RequestFingerprint) -> Option<&CustomFixture> {
375 let method = fingerprint.method.to_uppercase();
376 let fixtures_by_method = self.fixtures.get(&method)?;
377
378 let request_path = Self::normalize_path(&fingerprint.path);
380
381 tracing::debug!(
383 "Fixture matching: method={}, fingerprint.path='{}', normalized='{}', available fixtures: {:?}",
384 method,
385 fingerprint.path,
386 request_path,
387 fixtures_by_method.keys().collect::<Vec<_>>()
388 );
389
390 if let Some(fixture) = fixtures_by_method.get(&request_path) {
392 tracing::debug!("Found exact fixture match: {} {}", method, request_path);
393 return Some(fixture);
394 }
395
396 for (pattern, fixture) in fixtures_by_method.iter() {
398 if self.path_matches(pattern, &request_path) {
399 tracing::debug!(
400 "Found pattern fixture match: {} {} (pattern: {})",
401 method,
402 request_path,
403 pattern
404 );
405 return Some(fixture);
406 }
407 }
408
409 tracing::debug!("No fixture match found for: {} {}", method, request_path);
410 None
411 }
412
413 fn path_matches(&self, pattern: &str, request_path: &str) -> bool {
419 let normalized_pattern = Self::normalize_path(pattern);
421 let normalized_request = Self::normalize_path(request_path);
422
423 let pattern_segments: Vec<&str> =
426 normalized_pattern.split('/').filter(|s| !s.is_empty()).collect();
427 let request_segments: Vec<&str> =
428 normalized_request.split('/').filter(|s| !s.is_empty()).collect();
429
430 if pattern_segments.len() != request_segments.len() {
431 return false;
432 }
433
434 for (pattern_seg, request_seg) in pattern_segments.iter().zip(request_segments.iter()) {
436 if pattern_seg.starts_with('{') && pattern_seg.ends_with('}') {
438 continue;
439 }
440 if pattern_seg != request_seg {
442 return false;
443 }
444 }
445
446 true
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use axum::http::{HeaderMap, Method, Uri};
454 use tempfile::TempDir;
455
456 fn create_test_fingerprint(method: &str, path: &str) -> RequestFingerprint {
457 let method = Method::from_bytes(method.as_bytes()).unwrap();
458 let uri: Uri = path.parse().unwrap();
459 RequestFingerprint::new(method, &uri, &HeaderMap::new(), None)
460 }
461
462 #[test]
463 fn test_path_matching_exact() {
464 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
465 assert!(loader.path_matches("/api/v1/apiaries", "/api/v1/apiaries"));
466 assert!(!loader.path_matches("/api/v1/apiaries", "/api/v1/hives"));
467 }
468
469 #[test]
470 fn test_path_matching_with_parameters() {
471 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
472 assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001"));
473 assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/123"));
474 assert!(
475 !loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/inspections")
476 );
477 }
478
479 #[test]
480 fn test_path_matching_multiple_parameters() {
481 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
482 assert!(loader.path_matches(
483 "/api/v1/apiaries/{apiaryId}/hives/{hiveId}",
484 "/api/v1/apiaries/apiary_001/hives/hive_001"
485 ));
486 }
487
488 #[tokio::test]
489 async fn test_load_fixture() {
490 let temp_dir = TempDir::new().unwrap();
491 let fixtures_dir = temp_dir.path().to_path_buf();
492
493 let fixture_content = r#"{
495 "method": "GET",
496 "path": "/api/v1/apiaries",
497 "status": 200,
498 "response": {
499 "success": true,
500 "data": []
501 }
502}"#;
503
504 let fixture_file = fixtures_dir.join("apiaries-list.json");
505 fs::write(&fixture_file, fixture_content).await.unwrap();
506
507 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
509 loader.load_fixtures().await.unwrap();
510
511 let fingerprint = create_test_fingerprint("GET", "/api/v1/apiaries");
513 assert!(loader.has_fixture(&fingerprint));
514
515 let fixture = loader.load_fixture(&fingerprint).unwrap();
516 assert_eq!(fixture.method, "GET");
517 assert_eq!(fixture.path, "/api/v1/apiaries");
518 assert_eq!(fixture.status, 200);
519 }
520
521 #[tokio::test]
522 async fn test_load_fixture_with_path_parameter() {
523 let temp_dir = TempDir::new().unwrap();
524 let fixtures_dir = temp_dir.path().to_path_buf();
525
526 let fixture_content = r#"{
528 "method": "GET",
529 "path": "/api/v1/hives/{hiveId}",
530 "status": 200,
531 "response": {
532 "success": true,
533 "data": {
534 "id": "hive_001"
535 }
536 }
537}"#;
538
539 let fixture_file = fixtures_dir.join("hive-detail.json");
540 fs::write(&fixture_file, fixture_content).await.unwrap();
541
542 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
544 loader.load_fixtures().await.unwrap();
545
546 let fingerprint = create_test_fingerprint("GET", "/api/v1/hives/hive_001");
548 assert!(loader.has_fixture(&fingerprint));
549
550 let fixture = loader.load_fixture(&fingerprint).unwrap();
551 assert_eq!(fixture.path, "/api/v1/hives/{hiveId}");
552 }
553
554 #[tokio::test]
555 async fn test_load_multiple_fixtures() {
556 let temp_dir = TempDir::new().unwrap();
557 let fixtures_dir = temp_dir.path().to_path_buf();
558
559 let fixtures = vec![
561 (
562 "apiaries-list.json",
563 r#"{
564 "method": "GET",
565 "path": "/api/v1/apiaries",
566 "status": 200,
567 "response": {"items": []}
568}"#,
569 ),
570 (
571 "hive-detail.json",
572 r#"{
573 "method": "GET",
574 "path": "/api/v1/hives/{hiveId}",
575 "status": 200,
576 "response": {"id": "hive_001"}
577}"#,
578 ),
579 (
580 "user-profile.json",
581 r#"{
582 "method": "GET",
583 "path": "/api/v1/users/me",
584 "status": 200,
585 "response": {"id": "user_001"}
586}"#,
587 ),
588 ];
589
590 for (filename, content) in fixtures {
591 let fixture_file = fixtures_dir.join(filename);
592 fs::write(&fixture_file, content).await.unwrap();
593 }
594
595 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
597 loader.load_fixtures().await.unwrap();
598
599 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/apiaries")));
601 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/hives/hive_001")));
602 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/users/me")));
603 }
604
605 #[tokio::test]
606 async fn test_fixture_with_headers() {
607 let temp_dir = TempDir::new().unwrap();
608 let fixtures_dir = temp_dir.path().to_path_buf();
609
610 let fixture_content = r#"{
611 "method": "GET",
612 "path": "/api/v1/test",
613 "status": 201,
614 "response": {"result": "ok"},
615 "headers": {
616 "content-type": "application/json",
617 "x-custom-header": "test-value"
618 }
619}"#;
620
621 let fixture_file = fixtures_dir.join("test.json");
622 fs::write(&fixture_file, fixture_content).await.unwrap();
623
624 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
625 loader.load_fixtures().await.unwrap();
626
627 let fixture = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/test")).unwrap();
628
629 assert_eq!(fixture.status, 201);
630 assert_eq!(fixture.headers.get("content-type"), Some(&"application/json".to_string()));
631 assert_eq!(fixture.headers.get("x-custom-header"), Some(&"test-value".to_string()));
632 }
633
634 #[tokio::test]
635 async fn test_fixture_disabled() {
636 let temp_dir = TempDir::new().unwrap();
637 let fixtures_dir = temp_dir.path().to_path_buf();
638
639 let fixture_file = fixtures_dir.join("test.json");
640 fs::write(
641 &fixture_file,
642 r#"{"method": "GET", "path": "/test", "status": 200, "response": {}}"#,
643 )
644 .await
645 .unwrap();
646
647 let mut loader = CustomFixtureLoader::new(fixtures_dir, false);
648 loader.load_fixtures().await.unwrap();
649
650 assert!(!loader.has_fixture(&create_test_fingerprint("GET", "/test")));
652 assert!(loader.load_fixture(&create_test_fingerprint("GET", "/test")).is_none());
653 }
654
655 #[tokio::test]
656 async fn test_load_nested_format() {
657 let temp_dir = TempDir::new().unwrap();
658 let fixtures_dir = temp_dir.path().to_path_buf();
659
660 let fixture_content = r#"{
662 "request": {
663 "method": "POST",
664 "path": "/api/auth/login"
665 },
666 "response": {
667 "status": 200,
668 "headers": {
669 "Content-Type": "application/json"
670 },
671 "body": {
672 "access_token": "test_token",
673 "user": {
674 "id": "user_001"
675 }
676 }
677 }
678 }"#;
679
680 let fixture_file = fixtures_dir.join("auth-login.json");
681 fs::write(&fixture_file, fixture_content).await.unwrap();
682
683 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
685 loader.load_fixtures().await.unwrap();
686
687 let fingerprint = create_test_fingerprint("POST", "/api/auth/login");
689 assert!(loader.has_fixture(&fingerprint));
690
691 let fixture = loader.load_fixture(&fingerprint).unwrap();
692 assert_eq!(fixture.method, "POST");
693 assert_eq!(fixture.path, "/api/auth/login");
694 assert_eq!(fixture.status, 200);
695 assert_eq!(fixture.headers.get("Content-Type"), Some(&"application/json".to_string()));
696 assert!(fixture.response.get("access_token").is_some());
697 }
698
699 #[test]
700 fn test_path_normalization() {
701 let _loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
702
703 assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test/"), "/api/v1/test");
705 assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test"), "/api/v1/test");
706
707 assert_eq!(CustomFixtureLoader::normalize_path("/"), "/");
709
710 assert_eq!(CustomFixtureLoader::normalize_path("/api//v1///test"), "/api/v1/test");
712
713 assert_eq!(CustomFixtureLoader::normalize_path("api/v1/test"), "/api/v1/test");
715
716 assert_eq!(CustomFixtureLoader::normalize_path(" /api/v1/test "), "/api/v1/test");
718 }
719
720 #[test]
721 fn test_path_matching_with_normalization() {
722 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
723
724 assert!(loader.path_matches("/api/v1/test", "/api/v1/test/"));
726 assert!(loader.path_matches("/api/v1/test/", "/api/v1/test"));
727
728 assert!(loader.path_matches("/api/v1/test", "/api//v1///test"));
730
731 assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/"));
733 }
734
735 #[tokio::test]
736 async fn test_skip_template_files() {
737 let temp_dir = TempDir::new().unwrap();
738 let fixtures_dir = temp_dir.path().to_path_buf();
739
740 let template_content = r#"{
742 "_comment": "This is a template",
743 "_usage": "Use this for errors",
744 "error": {
745 "code": "ERROR_CODE"
746 }
747 }"#;
748
749 let template_file = fixtures_dir.join("error-template.json");
750 fs::write(&template_file, template_content).await.unwrap();
751
752 let valid_fixture = r#"{
754 "method": "GET",
755 "path": "/api/test",
756 "status": 200,
757 "response": {}
758 }"#;
759 let valid_file = fixtures_dir.join("valid.json");
760 fs::write(&valid_file, valid_fixture).await.unwrap();
761
762 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
764 loader.load_fixtures().await.unwrap();
765
766 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
768
769 }
773
774 #[tokio::test]
775 async fn test_skip_scenario_files() {
776 let temp_dir = TempDir::new().unwrap();
777 let fixtures_dir = temp_dir.path().to_path_buf();
778
779 let scenario_content = r#"{
781 "scenario": "demo",
782 "presentation_mode": true,
783 "apiaries": []
784 }"#;
785
786 let scenario_file = fixtures_dir.join("demo-scenario.json");
787 fs::write(&scenario_file, scenario_content).await.unwrap();
788
789 let valid_fixture = r#"{
791 "method": "GET",
792 "path": "/api/test",
793 "status": 200,
794 "response": {}
795 }"#;
796 let valid_file = fixtures_dir.join("valid.json");
797 fs::write(&valid_file, valid_fixture).await.unwrap();
798
799 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
801 loader.load_fixtures().await.unwrap();
802
803 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
805 }
806
807 #[tokio::test]
808 async fn test_mixed_format_fixtures() {
809 let temp_dir = TempDir::new().unwrap();
810 let fixtures_dir = temp_dir.path().to_path_buf();
811
812 let flat_fixture = r#"{
814 "method": "GET",
815 "path": "/api/v1/flat",
816 "status": 200,
817 "response": {"type": "flat"}
818 }"#;
819
820 let nested_fixture = r#"{
822 "request": {
823 "method": "GET",
824 "path": "/api/v1/nested"
825 },
826 "response": {
827 "status": 200,
828 "body": {"type": "nested"}
829 }
830 }"#;
831
832 fs::write(fixtures_dir.join("flat.json"), flat_fixture).await.unwrap();
833 fs::write(fixtures_dir.join("nested.json"), nested_fixture).await.unwrap();
834
835 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
837 loader.load_fixtures().await.unwrap();
838
839 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/flat")));
841 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/nested")));
842
843 let flat = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/flat")).unwrap();
844 assert_eq!(flat.response.get("type").and_then(|v| v.as_str()), Some("flat"));
845
846 let nested =
847 loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/nested")).unwrap();
848 assert_eq!(nested.response.get("type").and_then(|v| v.as_str()), Some("nested"));
849 }
850
851 #[tokio::test]
852 async fn test_validation_errors() {
853 let temp_dir = TempDir::new().unwrap();
854 let fixtures_dir = temp_dir.path().to_path_buf();
855
856 let no_method = r#"{
858 "path": "/api/test",
859 "status": 200,
860 "response": {}
861 }"#;
862 fs::write(fixtures_dir.join("no-method.json"), no_method).await.unwrap();
863
864 let invalid_status = r#"{
866 "method": "GET",
867 "path": "/api/test",
868 "status": 999,
869 "response": {}
870 }"#;
871 fs::write(fixtures_dir.join("invalid-status.json"), invalid_status)
872 .await
873 .unwrap();
874
875 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
877 let result = loader.load_fixtures().await;
878
879 assert!(result.is_ok());
881 }
882}