Skip to main content

mockforge_openapi/
route.rs

1//! OpenAPI route generation from specifications
2//!
3//! This module provides functionality for generating Axum routes
4//! from OpenAPI path definitions.
5
6use 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
15/// Extract path parameters from an OpenAPI path template
16fn 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/// OpenAPI route wrapper with additional metadata
44#[derive(Debug, Clone)]
45pub struct OpenApiRoute {
46    /// The HTTP method
47    pub method: String,
48    /// The path pattern
49    pub path: String,
50    /// The OpenAPI operation
51    pub operation: Operation,
52    /// Route-specific metadata
53    pub metadata: BTreeMap<String, String>,
54    /// Path parameters extracted from the path
55    pub parameters: Vec<String>,
56    /// Reference to the OpenAPI spec for response generation
57    pub spec: Arc<OpenApiSpec>,
58    /// AI response configuration (parsed from x-mockforge-ai extension)
59    pub ai_config: Option<AiResponseConfig>,
60    /// Response selection mode (parsed from x-mockforge-response-selection extension)
61    pub response_selection_mode: ResponseSelectionMode,
62    /// Response selector for sequential/random modes (shared across requests)
63    pub response_selector: Arc<ResponseSelector>,
64    /// Active persona for consistent data generation (optional)
65    pub persona: Option<Arc<Persona>>,
66}
67
68impl OpenApiRoute {
69    /// Create a new OpenApiRoute
70    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    /// Create a new OpenApiRoute with persona
75    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        // Parse AI configuration from x-mockforge-ai vendor extension
85        let ai_config = Self::parse_ai_config(&operation);
86
87        // Parse response selection mode from x-mockforge-response-selection extension
88        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    /// Parse AI configuration from OpenAPI operation's vendor extensions
106    fn parse_ai_config(operation: &Operation) -> Option<AiResponseConfig> {
107        // Check for x-mockforge-ai extension
108        if let Some(ai_config_value) = operation.extensions.get("x-mockforge-ai") {
109            // Try to deserialize the AI config from the extension value
110            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    /// Parse response selection mode from OpenAPI operation's vendor extensions
135    fn parse_response_selection_mode(operation: &Operation) -> ResponseSelectionMode {
136        // Check for environment variable override (per-operation or global)
137        let op_id = operation.operation_id.as_deref().unwrap_or("unknown");
138
139        // Try operation-specific env var first: MOCKFORGE_RESPONSE_SELECTION_<OPERATION_ID>
140        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        // Check global env var: MOCKFORGE_RESPONSE_SELECTION_MODE
155        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        Self::parse_spec_response_selection_mode(operation)
163    }
164
165    fn parse_spec_response_selection_mode(operation: &Operation) -> ResponseSelectionMode {
166        let op_id = operation.operation_id.as_deref().unwrap_or("unknown");
167        // Check for x-mockforge-response-selection extension
168        if let Some(selection_value) = operation.extensions.get("x-mockforge-response-selection") {
169            // Try to parse as string first
170            if let Some(mode_str) = selection_value.as_str() {
171                if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
172                    tracing::debug!(
173                        "Parsed response selection mode for operation {}: {:?}",
174                        op_id,
175                        mode
176                    );
177                    return mode;
178                }
179            }
180            // Try to parse as object with mode field
181            if let Some(obj) = selection_value.as_object() {
182                if let Some(mode_str) = obj.get("mode").and_then(|v| v.as_str()) {
183                    if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
184                        tracing::debug!(
185                            "Parsed response selection mode for operation {}: {:?}",
186                            op_id,
187                            mode
188                        );
189                        return mode;
190                    }
191                }
192            }
193            tracing::warn!(
194                "Failed to parse x-mockforge-response-selection extension for operation {}",
195                op_id
196            );
197        }
198        // Default to First mode
199        ResponseSelectionMode::First
200    }
201
202    /// Create an OpenApiRoute from an operation
203    pub fn from_operation(
204        method: &str,
205        path: String,
206        operation: &Operation,
207        spec: Arc<OpenApiSpec>,
208    ) -> Self {
209        Self::from_operation_with_persona(method, path, operation, spec, None)
210    }
211
212    /// Create a new OpenApiRoute from an operation with optional persona
213    pub fn from_operation_with_persona(
214        method: &str,
215        path: String,
216        operation: &Operation,
217        spec: Arc<OpenApiSpec>,
218        persona: Option<Arc<Persona>>,
219    ) -> Self {
220        Self::new_with_persona(method.to_string(), path, operation.clone(), spec, persona)
221    }
222
223    /// Convert OpenAPI path to Axum-compatible path format
224    pub fn axum_path(&self) -> String {
225        // Strip query string if present (some non-standard OpenAPI specs embed query params in path)
226        // Axum v0.7+ uses {param} format, same as OpenAPI
227        let path = self.path.split('?').next().unwrap_or(&self.path);
228
229        // Handle empty function call parens: functionName() → functionName
230        if path.contains("()") {
231            let path = path.replace("()", "");
232            return path;
233        }
234
235        // Handle OData function call syntax: functionName(key='{param}',key2={param2})
236        // Also handles Microsoft Graph style: functionName(key='{param}') where quotes wrap braces
237        // Convert to: functionName/{param}/{param2}
238        // This prevents Axum from panicking on multiple params per segment or invalid chars
239        if path.contains('(') && path.contains('=') {
240            let mut result = String::with_capacity(path.len());
241            let mut chars = path.chars().peekable();
242
243            while let Some(ch) = chars.next() {
244                if ch == '(' {
245                    // Extract params from inside parentheses
246                    let mut paren_content = String::new();
247                    for c in chars.by_ref() {
248                        if c == ')' {
249                            break;
250                        }
251                        paren_content.push(c);
252                    }
253                    // Parse key='{value}' or key={value} pairs
254                    for part in paren_content.split(',') {
255                        if let Some((_key, value)) = part.split_once('=') {
256                            let param = value.trim_matches(|c| c == '\'' || c == '"');
257                            result.push('/');
258                            result.push_str(param);
259                        }
260                    }
261                } else {
262                    result.push(ch);
263                }
264            }
265            return result;
266        }
267
268        path.to_string()
269    }
270
271    /// Returns true if this route's path can be registered with Axum's router.
272    ///
273    /// Paths that contain characters Axum can't handle (e.g., unmatched braces,
274    /// multiple params per segment after conversion) are considered invalid.
275    pub fn is_valid_axum_path(&self) -> bool {
276        let path = self.axum_path();
277        // If parentheses survived conversion, the path is invalid for Axum
278        if path.contains('(') || path.contains(')') {
279            return false;
280        }
281        // Each segment may contain at most one `{param}` capture
282        for segment in path.split('/') {
283            let brace_count = segment.matches('{').count();
284            if brace_count > 1 {
285                return false;
286            }
287            // A segment with a param must be ONLY the param (e.g. `{id}` not `prefix{id}suffix`)
288            // unless it's a wildcard. Axum allows `{*rest}` as a catch-all.
289            if brace_count == 1
290                && segment
291                    != format!(
292                        "{{{}}}",
293                        segment
294                            .trim_matches(|c: char| c != '{' && c != '}')
295                            .trim_matches(|c| c == '{' || c == '}')
296                    )
297            {
298                // Segment has a param mixed with literal text — check if it's truly invalid
299                // Axum 0.8 allows `{param}` as full segment only
300                if !segment.starts_with('{') || !segment.ends_with('}') {
301                    return false;
302                }
303            }
304        }
305        true
306    }
307
308    /// Add metadata to the route
309    pub fn with_metadata(mut self, key: String, value: String) -> Self {
310        self.metadata.insert(key, value);
311        self
312    }
313
314    /// Generate a mock response with status code for this route (async version with AI support)
315    ///
316    /// This method checks if AI response generation is configured and uses it if available,
317    /// otherwise falls back to standard OpenAPI response generation.
318    ///
319    /// # Arguments
320    /// * `context` - The request context for AI prompt expansion
321    /// * `ai_generator` - Optional AI generator implementation for actual LLM calls
322    pub async fn mock_response_with_status_async(
323        &self,
324        context: &mockforge_foundation::ai_response::RequestContext,
325        ai_generator: Option<&dyn crate::response::AiGenerator>,
326    ) -> (u16, serde_json::Value) {
327        use crate::response::ResponseGenerator;
328
329        // Find the first available status code from the OpenAPI spec
330        let status_code = self.find_first_available_status_code();
331
332        // Check if AI response generation is configured
333        if let Some(ai_config) = &self.ai_config {
334            if ai_config.is_active() {
335                tracing::info!(
336                    "Using AI-assisted response generation for {} {}",
337                    self.method,
338                    self.path
339                );
340
341                match ResponseGenerator::generate_ai_response(ai_config, context, ai_generator)
342                    .await
343                {
344                    Ok(response_body) => {
345                        tracing::debug!(
346                            "AI response generated successfully for {} {}: {:?}",
347                            self.method,
348                            self.path,
349                            response_body
350                        );
351                        return (status_code, response_body);
352                    }
353                    Err(e) => {
354                        tracing::warn!(
355                            "AI response generation failed for {} {}: {}, falling back to standard generation",
356                            self.method,
357                            self.path,
358                            e
359                        );
360                        // Continue to standard generation on error
361                    }
362                }
363            }
364        }
365
366        // Standard OpenAPI-based response generation
367        let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
368            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
369            .unwrap_or(false);
370
371        // Use response selection mode for multiple examples
372        let mode = Some(self.response_selection_mode);
373        let selector = Some(self.response_selector.as_ref());
374
375        // Get persona reference for response generation
376        let persona_ref = self.persona.as_deref();
377
378        match ResponseGenerator::generate_response_with_expansion_and_mode_and_persona(
379            &self.spec,
380            &self.operation,
381            status_code,
382            Some("application/json"),
383            expand_tokens,
384            mode,
385            selector,
386            persona_ref,
387        ) {
388            Ok(response_body) => {
389                tracing::debug!(
390                    "ResponseGenerator succeeded for {} {} with status {}: {:?}",
391                    self.method,
392                    self.path,
393                    status_code,
394                    response_body
395                );
396                (status_code, response_body)
397            }
398            Err(e) => {
399                tracing::debug!(
400                    "ResponseGenerator failed for {} {}: {}, using fallback",
401                    self.method,
402                    self.path,
403                    e
404                );
405                // Fallback to simple mock response if schema-based generation fails
406                let response_body = serde_json::json!({
407                    "message": format!("Mock response for {} {}", self.method, self.path),
408                    "operation_id": self.operation.operation_id,
409                    "status": status_code
410                });
411                (status_code, response_body)
412            }
413        }
414    }
415
416    /// Generate a mock response with status code for this route (synchronous version)
417    ///
418    /// Note: This method does not support AI-assisted response generation.
419    /// Use `mock_response_with_status_async` for AI features.
420    pub fn mock_response_with_status(&self) -> (u16, serde_json::Value) {
421        self.mock_response_with_status_and_scenario(None)
422    }
423
424    /// Generate a mock response with status code and scenario selection
425    ///
426    /// # Arguments
427    /// * `scenario` - Optional scenario name to select from the OpenAPI examples
428    ///
429    /// # Example
430    ///
431    /// ```rust,ignore
432    /// // Select the "error" scenario from examples
433    /// let (status, body) = route.mock_response_with_status_and_scenario(Some("error"));
434    /// ```
435    pub fn mock_response_with_status_and_scenario(
436        &self,
437        scenario: Option<&str>,
438    ) -> (u16, serde_json::Value) {
439        self.mock_response_with_status_and_scenario_and_override(scenario, None)
440    }
441
442    /// Generate tenant responses without process environment or global recording.
443    pub(crate) fn hosted_response(
444        &self,
445        scenario: Option<&str>,
446        status_override: Option<u16>,
447    ) -> (u16, serde_json::Value) {
448        let status = status_override
449            .filter(|code| self.has_response_for_status(*code))
450            .unwrap_or_else(|| self.find_first_available_status_code());
451        let body = crate::response::ResponseGenerator::generate_hosted_response(
452            &self.spec,
453            &self.operation,
454            status,
455            scenario,
456            Self::parse_spec_response_selection_mode(&self.operation),
457            &self.response_selector,
458        )
459        .unwrap_or_else(|_| serde_json::json!({}));
460        (status, body)
461    }
462
463    /// Generate a mock response with status code, scenario, and optional status override
464    ///
465    /// # Arguments
466    /// * `scenario` - Optional scenario name to select from the OpenAPI examples
467    /// * `status_override` - Optional HTTP status code to use instead of the default
468    pub fn mock_response_with_status_and_scenario_and_override(
469        &self,
470        scenario: Option<&str>,
471        status_override: Option<u16>,
472    ) -> (u16, serde_json::Value) {
473        let (status, body, _) =
474            self.mock_response_with_status_and_scenario_and_trace(scenario, status_override);
475        (status, body)
476    }
477
478    /// Generate a mock response with status code, scenario selection, and trace collection
479    ///
480    /// Returns a tuple of (status_code, response_body, trace_data)
481    pub fn mock_response_with_status_and_scenario_and_trace(
482        &self,
483        scenario: Option<&str>,
484        status_override: Option<u16>,
485    ) -> (
486        u16,
487        serde_json::Value,
488        mockforge_foundation::response_generation_trace::ResponseGenerationTrace,
489    ) {
490        use crate::response_trace;
491        use mockforge_foundation::response_generation_trace::ResponseGenerationTrace;
492
493        // Issue #79 round 13 — detect response-shape mismatches and
494        // record to the conformance buffer with category
495        // `response-shape`. Two failure modes worth surfacing:
496        //  (a) the caller explicitly requested a status via
497        //      `X-Mockforge-Response-Status` / status_override and the
498        //      spec doesn't define a response for it. The mock falls
499        //      back to a default, so the client gets a body that
500        //      doesn't match what the spec promised.
501        //  (b) The chosen status (after fallback) still has no
502        //      defined response and the synthesiser returns `{}`.
503        // Records once per request; useful when cross-checking the
504        // server's view of a spec against a proxy's interpretation.
505        if let Some(requested) = status_override {
506            if !self.has_response_for_status(requested) {
507                let available: Vec<String> =
508                    self.operation.responses.responses.keys().map(|k| format!("{:?}", k)).collect();
509                mockforge_foundation::conformance_violations::record(
510                    mockforge_foundation::conformance_violations::ServerConformanceViolation {
511                        timestamp: chrono::Utc::now(),
512                        method: self.method.clone(),
513                        path: self.path.clone(),
514                        client_ip: "unknown".to_string(),
515                        status: requested,
516                        reason: format!(
517                            "spec defines no response for status {} on {} {}; available: {}",
518                            requested,
519                            self.method,
520                            self.path,
521                            available.join(", ")
522                        ),
523                        category: "response-shape".to_string(),
524                        occurrences: 1,
525                        // Round 36 (#876) — response-shape mismatches are
526                        // detected during response synthesis, after the
527                        // inbound request headers are out of scope. The
528                        // fields stay `None`; client correlation isn't
529                        // meaningful here anyway (this is a server-side
530                        // discovery, not a wire-level conformance issue).
531                        client_mockforge_version: None,
532                        client_sent_at: None,
533                        summary: String::new(),
534                        categories: Vec::new(),
535                    },
536                );
537            }
538        }
539
540        // Use status override if the spec has a response for that code, otherwise default
541        let status_code = status_override
542            .filter(|code| self.has_response_for_status(*code))
543            .unwrap_or_else(|| self.find_first_available_status_code());
544
545        // Check if token expansion should be enabled
546        let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
547            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
548            .unwrap_or(false);
549
550        // Use response selection mode for multiple examples
551        let mode = Some(self.response_selection_mode);
552        let selector = Some(self.response_selector.as_ref());
553
554        // Try to generate with trace collection
555        match response_trace::generate_response_with_trace(
556            &self.spec,
557            &self.operation,
558            status_code,
559            Some("application/json"),
560            expand_tokens,
561            scenario,
562            mode,
563            selector,
564            None, // No persona in basic route
565        ) {
566            Ok((response_body, trace)) => {
567                tracing::debug!(
568                    "ResponseGenerator succeeded for {} {} with status {} and scenario {:?}: {:?}",
569                    self.method,
570                    self.path,
571                    status_code,
572                    scenario,
573                    response_body
574                );
575                (status_code, response_body, trace)
576            }
577            Err(e) => {
578                tracing::debug!(
579                    "ResponseGenerator failed for {} {}: {}, using fallback",
580                    self.method,
581                    self.path,
582                    e
583                );
584                // Fallback to simple mock response if schema-based generation fails
585                let response_body = serde_json::json!({
586                    "message": format!("Mock response for {} {}", self.method, self.path),
587                    "operation_id": self.operation.operation_id,
588                    "status": status_code
589                });
590                // Create a minimal trace for fallback
591                let mut trace = ResponseGenerationTrace::new();
592                trace.set_final_payload(response_body.clone());
593                trace.add_metadata("fallback".to_string(), serde_json::json!(true));
594                trace.add_metadata("error".to_string(), serde_json::json!(e.to_string()));
595                (status_code, response_body, trace)
596            }
597        }
598    }
599
600    /// Check if the operation declares a response for the given HTTP status code
601    pub fn has_response_for_status(&self, code: u16) -> bool {
602        self.operation
603            .responses
604            .responses
605            .iter()
606            .any(|(status, _)| matches!(status, openapiv3::StatusCode::Code(c) if *c == code))
607    }
608
609    /// Pick the status code to respond with for the success path.
610    ///
611    /// OpenAPI does not require responses to be listed in any order, and it's
612    /// common to declare error responses (e.g. `400`, `404`) before `200`.
613    /// Returning the *first listed* code therefore made a normal valid request
614    /// answer with an error status (#756). Prefer, in order: the lowest
615    /// explicit `2xx` code, then a `2XX` range, then the lowest other explicit
616    /// code, then `default`, then `200`.
617    pub fn find_first_available_status_code(&self) -> u16 {
618        let mut lowest_2xx: Option<u16> = None;
619        let mut has_2xx_range = false;
620        let mut lowest_other: Option<u16> = None;
621
622        for (status, _) in &self.operation.responses.responses {
623            match status {
624                openapiv3::StatusCode::Code(code) => {
625                    if (200..300).contains(code) {
626                        lowest_2xx = Some(lowest_2xx.map_or(*code, |c| c.min(*code)));
627                    } else {
628                        lowest_other = Some(lowest_other.map_or(*code, |c| c.min(*code)));
629                    }
630                }
631                openapiv3::StatusCode::Range(2) => has_2xx_range = true,
632                openapiv3::StatusCode::Range(_) => {}
633            }
634        }
635
636        if let Some(code) = lowest_2xx {
637            return code;
638        }
639        if has_2xx_range {
640            return 200;
641        }
642        // No success response declared. A `default` response models success
643        // here too, so prefer 200 before falling back to a declared error code.
644        if self.operation.responses.default.is_some() {
645            return 200;
646        }
647        lowest_other.unwrap_or(200)
648    }
649
650    /// Synthesize response headers declared in `responses.<code>.headers`.
651    ///
652    /// Round 43 (#79) — round 41's cookie chain work surfaced that a spec
653    /// that declares `responses.200.headers.Set-Cookie` returns 200 OK from
654    /// `mockforge serve` with no `Set-Cookie` header on the wire. The
655    /// validator's `validate_response_headers` even knows the shape, but
656    /// the synthesiser was never wired up. Returns a list of
657    /// `(header_name, value)` pairs for the given status code, suitable
658    /// for injecting straight into the axum response. The value is
659    /// schema-aware where the lift is cheap:
660    /// - `Set-Cookie`: a cookie-shaped placeholder (`<sanitized-name>=mockforge-session; Path=/`)
661    /// - `string`, `format: uuid`: a stable nil UUID so downstream parsing succeeds
662    /// - `string`, `format: date-time`: an RFC-3339 zero timestamp
663    /// - `integer` / `number` / `boolean`: minimal valid literals
664    /// - everything else (plain string, no schema, ref): the literal `mockforge-mock-value`
665    ///
666    /// `default` response headers are NOT included; only the explicitly
667    /// matched status's headers are emitted. The caller is expected to
668    /// merge these into the response after status + body.
669    pub fn mock_response_headers_for_status(&self, status_code: u16) -> Vec<(String, String)> {
670        use openapiv3::{
671            ParameterSchemaOrContent, ReferenceOr, SchemaKind, StatusCode, Type,
672            VariantOrUnknownOrEmpty,
673        };
674
675        let response_ref =
676            self.operation.responses.responses.iter().find_map(|(code, r)| match code {
677                StatusCode::Code(c) if *c == status_code => Some(r),
678                _ => None,
679            });
680        let Some(response_ref) = response_ref else {
681            return Vec::new();
682        };
683        let Some(response_item) = response_ref.as_item() else {
684            return Vec::new();
685        };
686
687        let mut out = Vec::new();
688        for (name, header_ref) in &response_item.headers {
689            let Some(header) = header_ref.as_item() else {
690                out.push((name.clone(), "mockforge-mock-value".to_string()));
691                continue;
692            };
693
694            let value = match &header.format {
695                ParameterSchemaOrContent::Schema(ReferenceOr::Item(schema)) => {
696                    match &schema.schema_kind {
697                        SchemaKind::Type(Type::Integer(_)) => "0".to_string(),
698                        SchemaKind::Type(Type::Number(_)) => "0".to_string(),
699                        SchemaKind::Type(Type::Boolean(_)) => "true".to_string(),
700                        SchemaKind::Type(Type::String(s)) => match &s.format {
701                            VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::DateTime) => {
702                                "1970-01-01T00:00:00Z".to_string()
703                            }
704                            VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::Date) => {
705                                "1970-01-01".to_string()
706                            }
707                            VariantOrUnknownOrEmpty::Unknown(fmt) if fmt == "uuid" => {
708                                "00000000-0000-0000-0000-000000000000".to_string()
709                            }
710                            _ => synthesize_string_header_value(name),
711                        },
712                        _ => synthesize_string_header_value(name),
713                    }
714                }
715                _ => synthesize_string_header_value(name),
716            };
717            out.push((name.clone(), value));
718        }
719        out
720    }
721}
722
723/// Pick a placeholder value for a generic string header, with a
724/// cookie-shaped fallback for `Set-Cookie`. Lives at module scope so it
725/// can be shared between `mock_response_headers_for_status` paths and
726/// any future overrides; keeping it free of schema state keeps it cheap.
727fn synthesize_string_header_value(header_name: &str) -> String {
728    if header_name.eq_ignore_ascii_case("set-cookie") {
729        // Use a fixed cookie name + value so downstream chains (the
730        // round-41 `${cookie:NAME}` substitution) can grep for it
731        // deterministically. The `Path=/` keeps it valid across paths.
732        "mockforge_session=mockforge-synthetic; Path=/".to_string()
733    } else {
734        "mockforge-mock-value".to_string()
735    }
736}
737
738/// OpenAPI operation wrapper with path context
739#[derive(Debug, Clone)]
740pub struct OpenApiOperation {
741    /// The HTTP method
742    pub method: String,
743    /// The path this operation belongs to
744    pub path: String,
745    /// The OpenAPI operation
746    pub operation: Operation,
747}
748
749impl OpenApiOperation {
750    /// Create a new OpenApiOperation
751    pub fn new(method: String, path: String, operation: Operation) -> Self {
752        Self {
753            method,
754            path,
755            operation,
756        }
757    }
758}
759
760/// Route generation utilities
761pub struct RouteGenerator;
762
763impl RouteGenerator {
764    /// Generate routes from an OpenAPI path item
765    pub fn generate_routes_from_path(
766        path: &str,
767        path_item: &ReferenceOr<PathItem>,
768        spec: &Arc<OpenApiSpec>,
769    ) -> Result<Vec<OpenApiRoute>> {
770        Self::generate_routes_from_path_with_persona(path, path_item, spec, None)
771    }
772
773    /// Generate routes from an OpenAPI path item with optional persona
774    pub fn generate_routes_from_path_with_persona(
775        path: &str,
776        path_item: &ReferenceOr<PathItem>,
777        spec: &Arc<OpenApiSpec>,
778        persona: Option<Arc<Persona>>,
779    ) -> Result<Vec<OpenApiRoute>> {
780        let mut routes = Vec::new();
781
782        if let Some(item) = path_item.as_item() {
783            // Generate route for each HTTP method
784            if let Some(op) = &item.get {
785                routes.push(OpenApiRoute::new_with_persona(
786                    "GET".to_string(),
787                    path.to_string(),
788                    op.clone(),
789                    spec.clone(),
790                    persona.clone(),
791                ));
792            }
793            if let Some(op) = &item.post {
794                routes.push(OpenApiRoute::new_with_persona(
795                    "POST".to_string(),
796                    path.to_string(),
797                    op.clone(),
798                    spec.clone(),
799                    persona.clone(),
800                ));
801            }
802            if let Some(op) = &item.put {
803                routes.push(OpenApiRoute::new_with_persona(
804                    "PUT".to_string(),
805                    path.to_string(),
806                    op.clone(),
807                    spec.clone(),
808                    persona.clone(),
809                ));
810            }
811            if let Some(op) = &item.delete {
812                routes.push(OpenApiRoute::new_with_persona(
813                    "DELETE".to_string(),
814                    path.to_string(),
815                    op.clone(),
816                    spec.clone(),
817                    persona.clone(),
818                ));
819            }
820            if let Some(op) = &item.patch {
821                routes.push(OpenApiRoute::new_with_persona(
822                    "PATCH".to_string(),
823                    path.to_string(),
824                    op.clone(),
825                    spec.clone(),
826                    persona.clone(),
827                ));
828            }
829            if let Some(op) = &item.head {
830                routes.push(OpenApiRoute::new_with_persona(
831                    "HEAD".to_string(),
832                    path.to_string(),
833                    op.clone(),
834                    spec.clone(),
835                    persona.clone(),
836                ));
837            }
838            if let Some(op) = &item.options {
839                routes.push(OpenApiRoute::new_with_persona(
840                    "OPTIONS".to_string(),
841                    path.to_string(),
842                    op.clone(),
843                    spec.clone(),
844                    persona.clone(),
845                ));
846            }
847            if let Some(op) = &item.trace {
848                routes.push(OpenApiRoute::new_with_persona(
849                    "TRACE".to_string(),
850                    path.to_string(),
851                    op.clone(),
852                    spec.clone(),
853                    persona.clone(),
854                ));
855            }
856        }
857
858        Ok(routes)
859    }
860}
861
862#[cfg(test)]
863mod status_code_selection_tests {
864    use super::OpenApiRoute;
865    use crate::spec::OpenApiSpec;
866    use serde_json::json;
867    use std::sync::Arc;
868
869    fn route_from_responses(responses: serde_json::Value) -> OpenApiRoute {
870        let operation: openapiv3::Operation =
871            serde_json::from_value(json!({ "responses": responses })).expect("valid operation");
872        let spec = OpenApiSpec::from_json(json!({
873            "openapi": "3.0.0",
874            "info": {"title": "t", "version": "1.0.0"},
875            "paths": {}
876        }))
877        .expect("valid spec");
878        OpenApiRoute::new("GET".to_string(), "/x".to_string(), operation, Arc::new(spec))
879    }
880
881    #[test]
882    fn prefers_2xx_over_earlier_listed_error_codes() {
883        // Error responses declared before the success response — must still
884        // return 200, not the first-listed 400 (#756).
885        let r = route_from_responses(json!({
886            "400": {"description": "bad"},
887            "404": {"description": "missing"},
888            "200": {"description": "ok"},
889        }));
890        assert_eq!(r.find_first_available_status_code(), 200);
891    }
892
893    #[test]
894    fn prefers_lowest_2xx() {
895        let r = route_from_responses(json!({
896            "204": {"description": "no content"},
897            "201": {"description": "created"},
898        }));
899        assert_eq!(r.find_first_available_status_code(), 201);
900    }
901
902    #[test]
903    fn uses_2xx_range_when_no_explicit_2xx() {
904        let r = route_from_responses(json!({ "2XX": {"description": "ok-ish"} }));
905        assert_eq!(r.find_first_available_status_code(), 200);
906    }
907
908    #[test]
909    fn default_only_returns_200() {
910        let r = route_from_responses(json!({ "default": {"description": "any"} }));
911        assert_eq!(r.find_first_available_status_code(), 200);
912    }
913
914    #[test]
915    fn error_only_returns_lowest_error() {
916        // No success path declared at all → fall back to the lowest declared code.
917        let r = route_from_responses(json!({
918            "500": {"description": "err"},
919            "404": {"description": "err"},
920        }));
921        assert_eq!(r.find_first_available_status_code(), 404);
922    }
923
924    #[test]
925    fn synthesises_set_cookie_when_declared_in_spec() {
926        // Round 43 (#79) — spec declares `responses.200.headers.Set-Cookie`;
927        // synthesiser must surface a cookie-shaped value instead of skipping
928        // the header. The exact value is opaque from the caller's view; we
929        // only assert it's a `name=value` cookie line so future changes to
930        // the placeholder text don't break the test.
931        let r = route_from_responses(json!({
932            "200": {
933                "description": "ok",
934                "headers": {
935                    "Set-Cookie": {"schema": {"type": "string"}}
936                }
937            }
938        }));
939        let headers = r.mock_response_headers_for_status(200);
940        let (_, value) = headers
941            .iter()
942            .find(|(name, _)| name.eq_ignore_ascii_case("Set-Cookie"))
943            .expect("Set-Cookie header should be synthesised");
944        assert!(
945            value.contains('=') && value.contains("Path="),
946            "expected cookie shape `name=value; Path=...`, got: {value:?}"
947        );
948    }
949
950    #[test]
951    fn synthesises_uuid_format_value_for_string_header() {
952        let r = route_from_responses(json!({
953            "200": {
954                "description": "ok",
955                "headers": {
956                    "X-Request-Id": {"schema": {"type": "string", "format": "uuid"}}
957                }
958            }
959        }));
960        let headers = r.mock_response_headers_for_status(200);
961        let (_, value) = headers
962            .iter()
963            .find(|(name, _)| name.eq_ignore_ascii_case("X-Request-Id"))
964            .expect("X-Request-Id header should be synthesised");
965        assert_eq!(value, "00000000-0000-0000-0000-000000000000");
966    }
967
968    #[test]
969    fn omits_headers_for_unmatched_status() {
970        // Spec declares headers on 200 only; asking for a 404 should produce nothing.
971        let r = route_from_responses(json!({
972            "200": {
973                "description": "ok",
974                "headers": {"Set-Cookie": {"schema": {"type": "string"}}}
975            }
976        }));
977        assert!(r.mock_response_headers_for_status(404).is_empty());
978    }
979}