1use crate::response_selection::{ResponseSelectionMode, ResponseSelector};
7use crate::spec::OpenApiSpec;
8use mockforge_foundation::ai_response::AiResponseConfig;
9use mockforge_foundation::error::Result;
10use mockforge_foundation::intelligent_behavior::Persona;
11use openapiv3::{Operation, PathItem, ReferenceOr};
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15fn extract_path_parameters(path_template: &str) -> Vec<String> {
17 let mut params = Vec::new();
18 let mut in_param = false;
19 let mut current_param = String::new();
20
21 for ch in path_template.chars() {
22 match ch {
23 '{' => {
24 in_param = true;
25 current_param.clear();
26 }
27 '}' => {
28 if in_param {
29 params.push(current_param.clone());
30 in_param = false;
31 }
32 }
33 ch if in_param => {
34 current_param.push(ch);
35 }
36 _ => {}
37 }
38 }
39
40 params
41}
42
43#[derive(Debug, Clone)]
45pub struct OpenApiRoute {
46 pub method: String,
48 pub path: String,
50 pub operation: Operation,
52 pub metadata: BTreeMap<String, String>,
54 pub parameters: Vec<String>,
56 pub spec: Arc<OpenApiSpec>,
58 pub ai_config: Option<AiResponseConfig>,
60 pub response_selection_mode: ResponseSelectionMode,
62 pub response_selector: Arc<ResponseSelector>,
64 pub persona: Option<Arc<Persona>>,
66}
67
68impl OpenApiRoute {
69 pub fn new(method: String, path: String, operation: Operation, spec: Arc<OpenApiSpec>) -> Self {
71 Self::new_with_persona(method, path, operation, spec, None)
72 }
73
74 pub fn new_with_persona(
76 method: String,
77 path: String,
78 operation: Operation,
79 spec: Arc<OpenApiSpec>,
80 persona: Option<Arc<Persona>>,
81 ) -> Self {
82 let parameters = extract_path_parameters(&path);
83
84 let ai_config = Self::parse_ai_config(&operation);
86
87 let response_selection_mode = Self::parse_response_selection_mode(&operation);
89 let response_selector = Arc::new(ResponseSelector::new(response_selection_mode));
90
91 Self {
92 method,
93 path,
94 operation,
95 metadata: BTreeMap::new(),
96 parameters,
97 spec,
98 ai_config,
99 response_selection_mode,
100 response_selector,
101 persona,
102 }
103 }
104
105 fn parse_ai_config(operation: &Operation) -> Option<AiResponseConfig> {
107 if let Some(ai_config_value) = operation.extensions.get("x-mockforge-ai") {
109 match serde_json::from_value::<AiResponseConfig>(ai_config_value.clone()) {
111 Ok(config) => {
112 if config.is_active() {
113 tracing::debug!(
114 "Parsed AI config for operation {}: mode={:?}, prompt={:?}",
115 operation.operation_id.as_deref().unwrap_or("unknown"),
116 config.mode,
117 config.prompt
118 );
119 return Some(config);
120 }
121 }
122 Err(e) => {
123 tracing::warn!(
124 "Failed to parse x-mockforge-ai extension for operation {}: {}",
125 operation.operation_id.as_deref().unwrap_or("unknown"),
126 e
127 );
128 }
129 }
130 }
131 None
132 }
133
134 fn parse_response_selection_mode(operation: &Operation) -> ResponseSelectionMode {
136 let op_id = operation.operation_id.as_deref().unwrap_or("unknown");
138
139 if let Ok(op_env_var) = std::env::var(format!(
141 "MOCKFORGE_RESPONSE_SELECTION_{}",
142 op_id.to_uppercase().replace('-', "_")
143 )) {
144 if let Some(mode) = ResponseSelectionMode::from_str(&op_env_var) {
145 tracing::debug!(
146 "Using response selection mode from env var for operation {}: {:?}",
147 op_id,
148 mode
149 );
150 return mode;
151 }
152 }
153
154 if let Ok(global_mode_str) = std::env::var("MOCKFORGE_RESPONSE_SELECTION_MODE") {
156 if let Some(mode) = ResponseSelectionMode::from_str(&global_mode_str) {
157 tracing::debug!("Using global response selection mode from env var: {:?}", mode);
158 return mode;
159 }
160 }
161
162 if let Some(selection_value) = operation.extensions.get("x-mockforge-response-selection") {
164 if let Some(mode_str) = selection_value.as_str() {
166 if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
167 tracing::debug!(
168 "Parsed response selection mode for operation {}: {:?}",
169 op_id,
170 mode
171 );
172 return mode;
173 }
174 }
175 if let Some(obj) = selection_value.as_object() {
177 if let Some(mode_str) = obj.get("mode").and_then(|v| v.as_str()) {
178 if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
179 tracing::debug!(
180 "Parsed response selection mode for operation {}: {:?}",
181 op_id,
182 mode
183 );
184 return mode;
185 }
186 }
187 }
188 tracing::warn!(
189 "Failed to parse x-mockforge-response-selection extension for operation {}",
190 op_id
191 );
192 }
193 ResponseSelectionMode::First
195 }
196
197 pub fn from_operation(
199 method: &str,
200 path: String,
201 operation: &Operation,
202 spec: Arc<OpenApiSpec>,
203 ) -> Self {
204 Self::from_operation_with_persona(method, path, operation, spec, None)
205 }
206
207 pub fn from_operation_with_persona(
209 method: &str,
210 path: String,
211 operation: &Operation,
212 spec: Arc<OpenApiSpec>,
213 persona: Option<Arc<Persona>>,
214 ) -> Self {
215 Self::new_with_persona(method.to_string(), path, operation.clone(), spec, persona)
216 }
217
218 pub fn axum_path(&self) -> String {
220 let path = self.path.split('?').next().unwrap_or(&self.path);
223
224 if path.contains("()") {
226 let path = path.replace("()", "");
227 return path;
228 }
229
230 if path.contains('(') && path.contains('=') {
235 let mut result = String::with_capacity(path.len());
236 let mut chars = path.chars().peekable();
237
238 while let Some(ch) = chars.next() {
239 if ch == '(' {
240 let mut paren_content = String::new();
242 for c in chars.by_ref() {
243 if c == ')' {
244 break;
245 }
246 paren_content.push(c);
247 }
248 for part in paren_content.split(',') {
250 if let Some((_key, value)) = part.split_once('=') {
251 let param = value.trim_matches(|c| c == '\'' || c == '"');
252 result.push('/');
253 result.push_str(param);
254 }
255 }
256 } else {
257 result.push(ch);
258 }
259 }
260 return result;
261 }
262
263 path.to_string()
264 }
265
266 pub fn is_valid_axum_path(&self) -> bool {
271 let path = self.axum_path();
272 if path.contains('(') || path.contains(')') {
274 return false;
275 }
276 for segment in path.split('/') {
278 let brace_count = segment.matches('{').count();
279 if brace_count > 1 {
280 return false;
281 }
282 if brace_count == 1
285 && segment
286 != format!(
287 "{{{}}}",
288 segment
289 .trim_matches(|c: char| c != '{' && c != '}')
290 .trim_matches(|c| c == '{' || c == '}')
291 )
292 {
293 if !segment.starts_with('{') || !segment.ends_with('}') {
296 return false;
297 }
298 }
299 }
300 true
301 }
302
303 pub fn with_metadata(mut self, key: String, value: String) -> Self {
305 self.metadata.insert(key, value);
306 self
307 }
308
309 pub async fn mock_response_with_status_async(
318 &self,
319 context: &mockforge_foundation::ai_response::RequestContext,
320 ai_generator: Option<&dyn crate::response::AiGenerator>,
321 ) -> (u16, serde_json::Value) {
322 use crate::response::ResponseGenerator;
323
324 let status_code = self.find_first_available_status_code();
326
327 if let Some(ai_config) = &self.ai_config {
329 if ai_config.is_active() {
330 tracing::info!(
331 "Using AI-assisted response generation for {} {}",
332 self.method,
333 self.path
334 );
335
336 match ResponseGenerator::generate_ai_response(ai_config, context, ai_generator)
337 .await
338 {
339 Ok(response_body) => {
340 tracing::debug!(
341 "AI response generated successfully for {} {}: {:?}",
342 self.method,
343 self.path,
344 response_body
345 );
346 return (status_code, response_body);
347 }
348 Err(e) => {
349 tracing::warn!(
350 "AI response generation failed for {} {}: {}, falling back to standard generation",
351 self.method,
352 self.path,
353 e
354 );
355 }
357 }
358 }
359 }
360
361 let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
363 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
364 .unwrap_or(false);
365
366 let mode = Some(self.response_selection_mode);
368 let selector = Some(self.response_selector.as_ref());
369
370 let persona_ref = self.persona.as_deref();
372
373 match ResponseGenerator::generate_response_with_expansion_and_mode_and_persona(
374 &self.spec,
375 &self.operation,
376 status_code,
377 Some("application/json"),
378 expand_tokens,
379 mode,
380 selector,
381 persona_ref,
382 ) {
383 Ok(response_body) => {
384 tracing::debug!(
385 "ResponseGenerator succeeded for {} {} with status {}: {:?}",
386 self.method,
387 self.path,
388 status_code,
389 response_body
390 );
391 (status_code, response_body)
392 }
393 Err(e) => {
394 tracing::debug!(
395 "ResponseGenerator failed for {} {}: {}, using fallback",
396 self.method,
397 self.path,
398 e
399 );
400 let response_body = serde_json::json!({
402 "message": format!("Mock response for {} {}", self.method, self.path),
403 "operation_id": self.operation.operation_id,
404 "status": status_code
405 });
406 (status_code, response_body)
407 }
408 }
409 }
410
411 pub fn mock_response_with_status(&self) -> (u16, serde_json::Value) {
416 self.mock_response_with_status_and_scenario(None)
417 }
418
419 pub fn mock_response_with_status_and_scenario(
431 &self,
432 scenario: Option<&str>,
433 ) -> (u16, serde_json::Value) {
434 self.mock_response_with_status_and_scenario_and_override(scenario, None)
435 }
436
437 pub fn mock_response_with_status_and_scenario_and_override(
443 &self,
444 scenario: Option<&str>,
445 status_override: Option<u16>,
446 ) -> (u16, serde_json::Value) {
447 let (status, body, _) =
448 self.mock_response_with_status_and_scenario_and_trace(scenario, status_override);
449 (status, body)
450 }
451
452 pub fn mock_response_with_status_and_scenario_and_trace(
456 &self,
457 scenario: Option<&str>,
458 status_override: Option<u16>,
459 ) -> (
460 u16,
461 serde_json::Value,
462 mockforge_foundation::response_generation_trace::ResponseGenerationTrace,
463 ) {
464 use crate::response_trace;
465 use mockforge_foundation::response_generation_trace::ResponseGenerationTrace;
466
467 if let Some(requested) = status_override {
480 if !self.has_response_for_status(requested) {
481 let available: Vec<String> =
482 self.operation.responses.responses.keys().map(|k| format!("{:?}", k)).collect();
483 mockforge_foundation::conformance_violations::record(
484 mockforge_foundation::conformance_violations::ServerConformanceViolation {
485 timestamp: chrono::Utc::now(),
486 method: self.method.clone(),
487 path: self.path.clone(),
488 client_ip: "unknown".to_string(),
489 status: requested,
490 reason: format!(
491 "spec defines no response for status {} on {} {}; available: {}",
492 requested,
493 self.method,
494 self.path,
495 available.join(", ")
496 ),
497 category: "response-shape".to_string(),
498 occurrences: 1,
499 client_mockforge_version: None,
506 client_sent_at: None,
507 summary: String::new(),
508 categories: Vec::new(),
509 },
510 );
511 }
512 }
513
514 let status_code = status_override
516 .filter(|code| self.has_response_for_status(*code))
517 .unwrap_or_else(|| self.find_first_available_status_code());
518
519 let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
521 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
522 .unwrap_or(false);
523
524 let mode = Some(self.response_selection_mode);
526 let selector = Some(self.response_selector.as_ref());
527
528 match response_trace::generate_response_with_trace(
530 &self.spec,
531 &self.operation,
532 status_code,
533 Some("application/json"),
534 expand_tokens,
535 scenario,
536 mode,
537 selector,
538 None, ) {
540 Ok((response_body, trace)) => {
541 tracing::debug!(
542 "ResponseGenerator succeeded for {} {} with status {} and scenario {:?}: {:?}",
543 self.method,
544 self.path,
545 status_code,
546 scenario,
547 response_body
548 );
549 (status_code, response_body, trace)
550 }
551 Err(e) => {
552 tracing::debug!(
553 "ResponseGenerator failed for {} {}: {}, using fallback",
554 self.method,
555 self.path,
556 e
557 );
558 let response_body = serde_json::json!({
560 "message": format!("Mock response for {} {}", self.method, self.path),
561 "operation_id": self.operation.operation_id,
562 "status": status_code
563 });
564 let mut trace = ResponseGenerationTrace::new();
566 trace.set_final_payload(response_body.clone());
567 trace.add_metadata("fallback".to_string(), serde_json::json!(true));
568 trace.add_metadata("error".to_string(), serde_json::json!(e.to_string()));
569 (status_code, response_body, trace)
570 }
571 }
572 }
573
574 pub fn has_response_for_status(&self, code: u16) -> bool {
576 self.operation
577 .responses
578 .responses
579 .iter()
580 .any(|(status, _)| matches!(status, openapiv3::StatusCode::Code(c) if *c == code))
581 }
582
583 pub fn find_first_available_status_code(&self) -> u16 {
592 let mut lowest_2xx: Option<u16> = None;
593 let mut has_2xx_range = false;
594 let mut lowest_other: Option<u16> = None;
595
596 for (status, _) in &self.operation.responses.responses {
597 match status {
598 openapiv3::StatusCode::Code(code) => {
599 if (200..300).contains(code) {
600 lowest_2xx = Some(lowest_2xx.map_or(*code, |c| c.min(*code)));
601 } else {
602 lowest_other = Some(lowest_other.map_or(*code, |c| c.min(*code)));
603 }
604 }
605 openapiv3::StatusCode::Range(2) => has_2xx_range = true,
606 openapiv3::StatusCode::Range(_) => {}
607 }
608 }
609
610 if let Some(code) = lowest_2xx {
611 return code;
612 }
613 if has_2xx_range {
614 return 200;
615 }
616 if self.operation.responses.default.is_some() {
619 return 200;
620 }
621 lowest_other.unwrap_or(200)
622 }
623
624 pub fn mock_response_headers_for_status(&self, status_code: u16) -> Vec<(String, String)> {
644 use openapiv3::{
645 ParameterSchemaOrContent, ReferenceOr, SchemaKind, StatusCode, Type,
646 VariantOrUnknownOrEmpty,
647 };
648
649 let response_ref =
650 self.operation.responses.responses.iter().find_map(|(code, r)| match code {
651 StatusCode::Code(c) if *c == status_code => Some(r),
652 _ => None,
653 });
654 let Some(response_ref) = response_ref else {
655 return Vec::new();
656 };
657 let Some(response_item) = response_ref.as_item() else {
658 return Vec::new();
659 };
660
661 let mut out = Vec::new();
662 for (name, header_ref) in &response_item.headers {
663 let Some(header) = header_ref.as_item() else {
664 out.push((name.clone(), "mockforge-mock-value".to_string()));
665 continue;
666 };
667
668 let value = match &header.format {
669 ParameterSchemaOrContent::Schema(ReferenceOr::Item(schema)) => {
670 match &schema.schema_kind {
671 SchemaKind::Type(Type::Integer(_)) => "0".to_string(),
672 SchemaKind::Type(Type::Number(_)) => "0".to_string(),
673 SchemaKind::Type(Type::Boolean(_)) => "true".to_string(),
674 SchemaKind::Type(Type::String(s)) => match &s.format {
675 VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::DateTime) => {
676 "1970-01-01T00:00:00Z".to_string()
677 }
678 VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::Date) => {
679 "1970-01-01".to_string()
680 }
681 VariantOrUnknownOrEmpty::Unknown(fmt) if fmt == "uuid" => {
682 "00000000-0000-0000-0000-000000000000".to_string()
683 }
684 _ => synthesize_string_header_value(name),
685 },
686 _ => synthesize_string_header_value(name),
687 }
688 }
689 _ => synthesize_string_header_value(name),
690 };
691 out.push((name.clone(), value));
692 }
693 out
694 }
695}
696
697fn synthesize_string_header_value(header_name: &str) -> String {
702 if header_name.eq_ignore_ascii_case("set-cookie") {
703 "mockforge_session=mockforge-synthetic; Path=/".to_string()
707 } else {
708 "mockforge-mock-value".to_string()
709 }
710}
711
712#[derive(Debug, Clone)]
714pub struct OpenApiOperation {
715 pub method: String,
717 pub path: String,
719 pub operation: Operation,
721}
722
723impl OpenApiOperation {
724 pub fn new(method: String, path: String, operation: Operation) -> Self {
726 Self {
727 method,
728 path,
729 operation,
730 }
731 }
732}
733
734pub struct RouteGenerator;
736
737impl RouteGenerator {
738 pub fn generate_routes_from_path(
740 path: &str,
741 path_item: &ReferenceOr<PathItem>,
742 spec: &Arc<OpenApiSpec>,
743 ) -> Result<Vec<OpenApiRoute>> {
744 Self::generate_routes_from_path_with_persona(path, path_item, spec, None)
745 }
746
747 pub fn generate_routes_from_path_with_persona(
749 path: &str,
750 path_item: &ReferenceOr<PathItem>,
751 spec: &Arc<OpenApiSpec>,
752 persona: Option<Arc<Persona>>,
753 ) -> Result<Vec<OpenApiRoute>> {
754 let mut routes = Vec::new();
755
756 if let Some(item) = path_item.as_item() {
757 if let Some(op) = &item.get {
759 routes.push(OpenApiRoute::new_with_persona(
760 "GET".to_string(),
761 path.to_string(),
762 op.clone(),
763 spec.clone(),
764 persona.clone(),
765 ));
766 }
767 if let Some(op) = &item.post {
768 routes.push(OpenApiRoute::new_with_persona(
769 "POST".to_string(),
770 path.to_string(),
771 op.clone(),
772 spec.clone(),
773 persona.clone(),
774 ));
775 }
776 if let Some(op) = &item.put {
777 routes.push(OpenApiRoute::new_with_persona(
778 "PUT".to_string(),
779 path.to_string(),
780 op.clone(),
781 spec.clone(),
782 persona.clone(),
783 ));
784 }
785 if let Some(op) = &item.delete {
786 routes.push(OpenApiRoute::new_with_persona(
787 "DELETE".to_string(),
788 path.to_string(),
789 op.clone(),
790 spec.clone(),
791 persona.clone(),
792 ));
793 }
794 if let Some(op) = &item.patch {
795 routes.push(OpenApiRoute::new_with_persona(
796 "PATCH".to_string(),
797 path.to_string(),
798 op.clone(),
799 spec.clone(),
800 persona.clone(),
801 ));
802 }
803 if let Some(op) = &item.head {
804 routes.push(OpenApiRoute::new_with_persona(
805 "HEAD".to_string(),
806 path.to_string(),
807 op.clone(),
808 spec.clone(),
809 persona.clone(),
810 ));
811 }
812 if let Some(op) = &item.options {
813 routes.push(OpenApiRoute::new_with_persona(
814 "OPTIONS".to_string(),
815 path.to_string(),
816 op.clone(),
817 spec.clone(),
818 persona.clone(),
819 ));
820 }
821 if let Some(op) = &item.trace {
822 routes.push(OpenApiRoute::new_with_persona(
823 "TRACE".to_string(),
824 path.to_string(),
825 op.clone(),
826 spec.clone(),
827 persona.clone(),
828 ));
829 }
830 }
831
832 Ok(routes)
833 }
834}
835
836#[cfg(test)]
837mod status_code_selection_tests {
838 use super::OpenApiRoute;
839 use crate::spec::OpenApiSpec;
840 use serde_json::json;
841 use std::sync::Arc;
842
843 fn route_from_responses(responses: serde_json::Value) -> OpenApiRoute {
844 let operation: openapiv3::Operation =
845 serde_json::from_value(json!({ "responses": responses })).expect("valid operation");
846 let spec = OpenApiSpec::from_json(json!({
847 "openapi": "3.0.0",
848 "info": {"title": "t", "version": "1.0.0"},
849 "paths": {}
850 }))
851 .expect("valid spec");
852 OpenApiRoute::new("GET".to_string(), "/x".to_string(), operation, Arc::new(spec))
853 }
854
855 #[test]
856 fn prefers_2xx_over_earlier_listed_error_codes() {
857 let r = route_from_responses(json!({
860 "400": {"description": "bad"},
861 "404": {"description": "missing"},
862 "200": {"description": "ok"},
863 }));
864 assert_eq!(r.find_first_available_status_code(), 200);
865 }
866
867 #[test]
868 fn prefers_lowest_2xx() {
869 let r = route_from_responses(json!({
870 "204": {"description": "no content"},
871 "201": {"description": "created"},
872 }));
873 assert_eq!(r.find_first_available_status_code(), 201);
874 }
875
876 #[test]
877 fn uses_2xx_range_when_no_explicit_2xx() {
878 let r = route_from_responses(json!({ "2XX": {"description": "ok-ish"} }));
879 assert_eq!(r.find_first_available_status_code(), 200);
880 }
881
882 #[test]
883 fn default_only_returns_200() {
884 let r = route_from_responses(json!({ "default": {"description": "any"} }));
885 assert_eq!(r.find_first_available_status_code(), 200);
886 }
887
888 #[test]
889 fn error_only_returns_lowest_error() {
890 let r = route_from_responses(json!({
892 "500": {"description": "err"},
893 "404": {"description": "err"},
894 }));
895 assert_eq!(r.find_first_available_status_code(), 404);
896 }
897
898 #[test]
899 fn synthesises_set_cookie_when_declared_in_spec() {
900 let r = route_from_responses(json!({
906 "200": {
907 "description": "ok",
908 "headers": {
909 "Set-Cookie": {"schema": {"type": "string"}}
910 }
911 }
912 }));
913 let headers = r.mock_response_headers_for_status(200);
914 let (_, value) = headers
915 .iter()
916 .find(|(name, _)| name.eq_ignore_ascii_case("Set-Cookie"))
917 .expect("Set-Cookie header should be synthesised");
918 assert!(
919 value.contains('=') && value.contains("Path="),
920 "expected cookie shape `name=value; Path=...`, got: {value:?}"
921 );
922 }
923
924 #[test]
925 fn synthesises_uuid_format_value_for_string_header() {
926 let r = route_from_responses(json!({
927 "200": {
928 "description": "ok",
929 "headers": {
930 "X-Request-Id": {"schema": {"type": "string", "format": "uuid"}}
931 }
932 }
933 }));
934 let headers = r.mock_response_headers_for_status(200);
935 let (_, value) = headers
936 .iter()
937 .find(|(name, _)| name.eq_ignore_ascii_case("X-Request-Id"))
938 .expect("X-Request-Id header should be synthesised");
939 assert_eq!(value, "00000000-0000-0000-0000-000000000000");
940 }
941
942 #[test]
943 fn omits_headers_for_unmatched_status() {
944 let r = route_from_responses(json!({
946 "200": {
947 "description": "ok",
948 "headers": {"Set-Cookie": {"schema": {"type": "string"}}}
949 }
950 }));
951 assert!(r.mock_response_headers_for_status(404).is_empty());
952 }
953}