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        // Check for x-mockforge-response-selection extension
163        if let Some(selection_value) = operation.extensions.get("x-mockforge-response-selection") {
164            // Try to parse as string first
165            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            // Try to parse as object with mode field
176            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        // Default to First mode
194        ResponseSelectionMode::First
195    }
196
197    /// Create an OpenApiRoute from an operation
198    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    /// Create a new OpenApiRoute from an operation with optional persona
208    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    /// Convert OpenAPI path to Axum-compatible path format
219    pub fn axum_path(&self) -> String {
220        // Strip query string if present (some non-standard OpenAPI specs embed query params in path)
221        // Axum v0.7+ uses {param} format, same as OpenAPI
222        let path = self.path.split('?').next().unwrap_or(&self.path);
223
224        // Handle empty function call parens: functionName() → functionName
225        if path.contains("()") {
226            let path = path.replace("()", "");
227            return path;
228        }
229
230        // Handle OData function call syntax: functionName(key='{param}',key2={param2})
231        // Also handles Microsoft Graph style: functionName(key='{param}') where quotes wrap braces
232        // Convert to: functionName/{param}/{param2}
233        // This prevents Axum from panicking on multiple params per segment or invalid chars
234        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                    // Extract params from inside parentheses
241                    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                    // Parse key='{value}' or key={value} pairs
249                    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    /// Returns true if this route's path can be registered with Axum's router.
267    ///
268    /// Paths that contain characters Axum can't handle (e.g., unmatched braces,
269    /// multiple params per segment after conversion) are considered invalid.
270    pub fn is_valid_axum_path(&self) -> bool {
271        let path = self.axum_path();
272        // If parentheses survived conversion, the path is invalid for Axum
273        if path.contains('(') || path.contains(')') {
274            return false;
275        }
276        // Each segment may contain at most one `{param}` capture
277        for segment in path.split('/') {
278            let brace_count = segment.matches('{').count();
279            if brace_count > 1 {
280                return false;
281            }
282            // A segment with a param must be ONLY the param (e.g. `{id}` not `prefix{id}suffix`)
283            // unless it's a wildcard. Axum allows `{*rest}` as a catch-all.
284            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                // Segment has a param mixed with literal text — check if it's truly invalid
294                // Axum 0.8 allows `{param}` as full segment only
295                if !segment.starts_with('{') || !segment.ends_with('}') {
296                    return false;
297                }
298            }
299        }
300        true
301    }
302
303    /// Add metadata to the route
304    pub fn with_metadata(mut self, key: String, value: String) -> Self {
305        self.metadata.insert(key, value);
306        self
307    }
308
309    /// Generate a mock response with status code for this route (async version with AI support)
310    ///
311    /// This method checks if AI response generation is configured and uses it if available,
312    /// otherwise falls back to standard OpenAPI response generation.
313    ///
314    /// # Arguments
315    /// * `context` - The request context for AI prompt expansion
316    /// * `ai_generator` - Optional AI generator implementation for actual LLM calls
317    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        // Find the first available status code from the OpenAPI spec
325        let status_code = self.find_first_available_status_code();
326
327        // Check if AI response generation is configured
328        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                        // Continue to standard generation on error
356                    }
357                }
358            }
359        }
360
361        // Standard OpenAPI-based response generation
362        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        // Use response selection mode for multiple examples
367        let mode = Some(self.response_selection_mode);
368        let selector = Some(self.response_selector.as_ref());
369
370        // Get persona reference for response generation
371        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                // Fallback to simple mock response if schema-based generation fails
401                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    /// Generate a mock response with status code for this route (synchronous version)
412    ///
413    /// Note: This method does not support AI-assisted response generation.
414    /// Use `mock_response_with_status_async` for AI features.
415    pub fn mock_response_with_status(&self) -> (u16, serde_json::Value) {
416        self.mock_response_with_status_and_scenario(None)
417    }
418
419    /// Generate a mock response with status code and scenario selection
420    ///
421    /// # Arguments
422    /// * `scenario` - Optional scenario name to select from the OpenAPI examples
423    ///
424    /// # Example
425    ///
426    /// ```rust,ignore
427    /// // Select the "error" scenario from examples
428    /// let (status, body) = route.mock_response_with_status_and_scenario(Some("error"));
429    /// ```
430    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    /// Generate a mock response with status code, scenario, and optional status override
438    ///
439    /// # Arguments
440    /// * `scenario` - Optional scenario name to select from the OpenAPI examples
441    /// * `status_override` - Optional HTTP status code to use instead of the default
442    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    /// Generate a mock response with status code, scenario selection, and trace collection
453    ///
454    /// Returns a tuple of (status_code, response_body, trace_data)
455    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        // Issue #79 round 13 — detect response-shape mismatches and
468        // record to the conformance buffer with category
469        // `response-shape`. Two failure modes worth surfacing:
470        //  (a) the caller explicitly requested a status via
471        //      `X-Mockforge-Response-Status` / status_override and the
472        //      spec doesn't define a response for it. The mock falls
473        //      back to a default, so the client gets a body that
474        //      doesn't match what the spec promised.
475        //  (b) The chosen status (after fallback) still has no
476        //      defined response and the synthesiser returns `{}`.
477        // Records once per request; useful when cross-checking the
478        // server's view of a spec against a proxy's interpretation.
479        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                        // Round 36 (#876) — response-shape mismatches are
500                        // detected during response synthesis, after the
501                        // inbound request headers are out of scope. The
502                        // fields stay `None`; client correlation isn't
503                        // meaningful here anyway (this is a server-side
504                        // discovery, not a wire-level conformance issue).
505                        client_mockforge_version: None,
506                        client_sent_at: None,
507                    },
508                );
509            }
510        }
511
512        // Use status override if the spec has a response for that code, otherwise default
513        let status_code = status_override
514            .filter(|code| self.has_response_for_status(*code))
515            .unwrap_or_else(|| self.find_first_available_status_code());
516
517        // Check if token expansion should be enabled
518        let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
519            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
520            .unwrap_or(false);
521
522        // Use response selection mode for multiple examples
523        let mode = Some(self.response_selection_mode);
524        let selector = Some(self.response_selector.as_ref());
525
526        // Try to generate with trace collection
527        match response_trace::generate_response_with_trace(
528            &self.spec,
529            &self.operation,
530            status_code,
531            Some("application/json"),
532            expand_tokens,
533            scenario,
534            mode,
535            selector,
536            None, // No persona in basic route
537        ) {
538            Ok((response_body, trace)) => {
539                tracing::debug!(
540                    "ResponseGenerator succeeded for {} {} with status {} and scenario {:?}: {:?}",
541                    self.method,
542                    self.path,
543                    status_code,
544                    scenario,
545                    response_body
546                );
547                (status_code, response_body, trace)
548            }
549            Err(e) => {
550                tracing::debug!(
551                    "ResponseGenerator failed for {} {}: {}, using fallback",
552                    self.method,
553                    self.path,
554                    e
555                );
556                // Fallback to simple mock response if schema-based generation fails
557                let response_body = serde_json::json!({
558                    "message": format!("Mock response for {} {}", self.method, self.path),
559                    "operation_id": self.operation.operation_id,
560                    "status": status_code
561                });
562                // Create a minimal trace for fallback
563                let mut trace = ResponseGenerationTrace::new();
564                trace.set_final_payload(response_body.clone());
565                trace.add_metadata("fallback".to_string(), serde_json::json!(true));
566                trace.add_metadata("error".to_string(), serde_json::json!(e.to_string()));
567                (status_code, response_body, trace)
568            }
569        }
570    }
571
572    /// Check if the operation declares a response for the given HTTP status code
573    pub fn has_response_for_status(&self, code: u16) -> bool {
574        self.operation
575            .responses
576            .responses
577            .iter()
578            .any(|(status, _)| matches!(status, openapiv3::StatusCode::Code(c) if *c == code))
579    }
580
581    /// Pick the status code to respond with for the success path.
582    ///
583    /// OpenAPI does not require responses to be listed in any order, and it's
584    /// common to declare error responses (e.g. `400`, `404`) before `200`.
585    /// Returning the *first listed* code therefore made a normal valid request
586    /// answer with an error status (#756). Prefer, in order: the lowest
587    /// explicit `2xx` code, then a `2XX` range, then the lowest other explicit
588    /// code, then `default`, then `200`.
589    pub fn find_first_available_status_code(&self) -> u16 {
590        let mut lowest_2xx: Option<u16> = None;
591        let mut has_2xx_range = false;
592        let mut lowest_other: Option<u16> = None;
593
594        for (status, _) in &self.operation.responses.responses {
595            match status {
596                openapiv3::StatusCode::Code(code) => {
597                    if (200..300).contains(code) {
598                        lowest_2xx = Some(lowest_2xx.map_or(*code, |c| c.min(*code)));
599                    } else {
600                        lowest_other = Some(lowest_other.map_or(*code, |c| c.min(*code)));
601                    }
602                }
603                openapiv3::StatusCode::Range(2) => has_2xx_range = true,
604                openapiv3::StatusCode::Range(_) => {}
605            }
606        }
607
608        if let Some(code) = lowest_2xx {
609            return code;
610        }
611        if has_2xx_range {
612            return 200;
613        }
614        // No success response declared. A `default` response models success
615        // here too, so prefer 200 before falling back to a declared error code.
616        if self.operation.responses.default.is_some() {
617            return 200;
618        }
619        lowest_other.unwrap_or(200)
620    }
621
622    /// Synthesize response headers declared in `responses.<code>.headers`.
623    ///
624    /// Round 43 (#79) — round 41's cookie chain work surfaced that a spec
625    /// that declares `responses.200.headers.Set-Cookie` returns 200 OK from
626    /// `mockforge serve` with no `Set-Cookie` header on the wire. The
627    /// validator's `validate_response_headers` even knows the shape, but
628    /// the synthesiser was never wired up. Returns a list of
629    /// `(header_name, value)` pairs for the given status code, suitable
630    /// for injecting straight into the axum response. The value is
631    /// schema-aware where the lift is cheap:
632    /// - `Set-Cookie`: a cookie-shaped placeholder (`<sanitized-name>=mockforge-session; Path=/`)
633    /// - `string`, `format: uuid`: a stable nil UUID so downstream parsing succeeds
634    /// - `string`, `format: date-time`: an RFC-3339 zero timestamp
635    /// - `integer` / `number` / `boolean`: minimal valid literals
636    /// - everything else (plain string, no schema, ref): the literal `mockforge-mock-value`
637    ///
638    /// `default` response headers are NOT included; only the explicitly
639    /// matched status's headers are emitted. The caller is expected to
640    /// merge these into the response after status + body.
641    pub fn mock_response_headers_for_status(&self, status_code: u16) -> Vec<(String, String)> {
642        use openapiv3::{
643            ParameterSchemaOrContent, ReferenceOr, SchemaKind, StatusCode, Type,
644            VariantOrUnknownOrEmpty,
645        };
646
647        let response_ref =
648            self.operation.responses.responses.iter().find_map(|(code, r)| match code {
649                StatusCode::Code(c) if *c == status_code => Some(r),
650                _ => None,
651            });
652        let Some(response_ref) = response_ref else {
653            return Vec::new();
654        };
655        let Some(response_item) = response_ref.as_item() else {
656            return Vec::new();
657        };
658
659        let mut out = Vec::new();
660        for (name, header_ref) in &response_item.headers {
661            let Some(header) = header_ref.as_item() else {
662                out.push((name.clone(), "mockforge-mock-value".to_string()));
663                continue;
664            };
665
666            let value = match &header.format {
667                ParameterSchemaOrContent::Schema(ReferenceOr::Item(schema)) => {
668                    match &schema.schema_kind {
669                        SchemaKind::Type(Type::Integer(_)) => "0".to_string(),
670                        SchemaKind::Type(Type::Number(_)) => "0".to_string(),
671                        SchemaKind::Type(Type::Boolean(_)) => "true".to_string(),
672                        SchemaKind::Type(Type::String(s)) => match &s.format {
673                            VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::DateTime) => {
674                                "1970-01-01T00:00:00Z".to_string()
675                            }
676                            VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::Date) => {
677                                "1970-01-01".to_string()
678                            }
679                            VariantOrUnknownOrEmpty::Unknown(fmt) if fmt == "uuid" => {
680                                "00000000-0000-0000-0000-000000000000".to_string()
681                            }
682                            _ => synthesize_string_header_value(name),
683                        },
684                        _ => synthesize_string_header_value(name),
685                    }
686                }
687                _ => synthesize_string_header_value(name),
688            };
689            out.push((name.clone(), value));
690        }
691        out
692    }
693}
694
695/// Pick a placeholder value for a generic string header, with a
696/// cookie-shaped fallback for `Set-Cookie`. Lives at module scope so it
697/// can be shared between `mock_response_headers_for_status` paths and
698/// any future overrides; keeping it free of schema state keeps it cheap.
699fn synthesize_string_header_value(header_name: &str) -> String {
700    if header_name.eq_ignore_ascii_case("set-cookie") {
701        // Use a fixed cookie name + value so downstream chains (the
702        // round-41 `${cookie:NAME}` substitution) can grep for it
703        // deterministically. The `Path=/` keeps it valid across paths.
704        "mockforge_session=mockforge-synthetic; Path=/".to_string()
705    } else {
706        "mockforge-mock-value".to_string()
707    }
708}
709
710/// OpenAPI operation wrapper with path context
711#[derive(Debug, Clone)]
712pub struct OpenApiOperation {
713    /// The HTTP method
714    pub method: String,
715    /// The path this operation belongs to
716    pub path: String,
717    /// The OpenAPI operation
718    pub operation: Operation,
719}
720
721impl OpenApiOperation {
722    /// Create a new OpenApiOperation
723    pub fn new(method: String, path: String, operation: Operation) -> Self {
724        Self {
725            method,
726            path,
727            operation,
728        }
729    }
730}
731
732/// Route generation utilities
733pub struct RouteGenerator;
734
735impl RouteGenerator {
736    /// Generate routes from an OpenAPI path item
737    pub fn generate_routes_from_path(
738        path: &str,
739        path_item: &ReferenceOr<PathItem>,
740        spec: &Arc<OpenApiSpec>,
741    ) -> Result<Vec<OpenApiRoute>> {
742        Self::generate_routes_from_path_with_persona(path, path_item, spec, None)
743    }
744
745    /// Generate routes from an OpenAPI path item with optional persona
746    pub fn generate_routes_from_path_with_persona(
747        path: &str,
748        path_item: &ReferenceOr<PathItem>,
749        spec: &Arc<OpenApiSpec>,
750        persona: Option<Arc<Persona>>,
751    ) -> Result<Vec<OpenApiRoute>> {
752        let mut routes = Vec::new();
753
754        if let Some(item) = path_item.as_item() {
755            // Generate route for each HTTP method
756            if let Some(op) = &item.get {
757                routes.push(OpenApiRoute::new_with_persona(
758                    "GET".to_string(),
759                    path.to_string(),
760                    op.clone(),
761                    spec.clone(),
762                    persona.clone(),
763                ));
764            }
765            if let Some(op) = &item.post {
766                routes.push(OpenApiRoute::new_with_persona(
767                    "POST".to_string(),
768                    path.to_string(),
769                    op.clone(),
770                    spec.clone(),
771                    persona.clone(),
772                ));
773            }
774            if let Some(op) = &item.put {
775                routes.push(OpenApiRoute::new_with_persona(
776                    "PUT".to_string(),
777                    path.to_string(),
778                    op.clone(),
779                    spec.clone(),
780                    persona.clone(),
781                ));
782            }
783            if let Some(op) = &item.delete {
784                routes.push(OpenApiRoute::new_with_persona(
785                    "DELETE".to_string(),
786                    path.to_string(),
787                    op.clone(),
788                    spec.clone(),
789                    persona.clone(),
790                ));
791            }
792            if let Some(op) = &item.patch {
793                routes.push(OpenApiRoute::new_with_persona(
794                    "PATCH".to_string(),
795                    path.to_string(),
796                    op.clone(),
797                    spec.clone(),
798                    persona.clone(),
799                ));
800            }
801            if let Some(op) = &item.head {
802                routes.push(OpenApiRoute::new_with_persona(
803                    "HEAD".to_string(),
804                    path.to_string(),
805                    op.clone(),
806                    spec.clone(),
807                    persona.clone(),
808                ));
809            }
810            if let Some(op) = &item.options {
811                routes.push(OpenApiRoute::new_with_persona(
812                    "OPTIONS".to_string(),
813                    path.to_string(),
814                    op.clone(),
815                    spec.clone(),
816                    persona.clone(),
817                ));
818            }
819            if let Some(op) = &item.trace {
820                routes.push(OpenApiRoute::new_with_persona(
821                    "TRACE".to_string(),
822                    path.to_string(),
823                    op.clone(),
824                    spec.clone(),
825                    persona.clone(),
826                ));
827            }
828        }
829
830        Ok(routes)
831    }
832}
833
834#[cfg(test)]
835mod status_code_selection_tests {
836    use super::OpenApiRoute;
837    use crate::spec::OpenApiSpec;
838    use serde_json::json;
839    use std::sync::Arc;
840
841    fn route_from_responses(responses: serde_json::Value) -> OpenApiRoute {
842        let operation: openapiv3::Operation =
843            serde_json::from_value(json!({ "responses": responses })).expect("valid operation");
844        let spec = OpenApiSpec::from_json(json!({
845            "openapi": "3.0.0",
846            "info": {"title": "t", "version": "1.0.0"},
847            "paths": {}
848        }))
849        .expect("valid spec");
850        OpenApiRoute::new("GET".to_string(), "/x".to_string(), operation, Arc::new(spec))
851    }
852
853    #[test]
854    fn prefers_2xx_over_earlier_listed_error_codes() {
855        // Error responses declared before the success response — must still
856        // return 200, not the first-listed 400 (#756).
857        let r = route_from_responses(json!({
858            "400": {"description": "bad"},
859            "404": {"description": "missing"},
860            "200": {"description": "ok"},
861        }));
862        assert_eq!(r.find_first_available_status_code(), 200);
863    }
864
865    #[test]
866    fn prefers_lowest_2xx() {
867        let r = route_from_responses(json!({
868            "204": {"description": "no content"},
869            "201": {"description": "created"},
870        }));
871        assert_eq!(r.find_first_available_status_code(), 201);
872    }
873
874    #[test]
875    fn uses_2xx_range_when_no_explicit_2xx() {
876        let r = route_from_responses(json!({ "2XX": {"description": "ok-ish"} }));
877        assert_eq!(r.find_first_available_status_code(), 200);
878    }
879
880    #[test]
881    fn default_only_returns_200() {
882        let r = route_from_responses(json!({ "default": {"description": "any"} }));
883        assert_eq!(r.find_first_available_status_code(), 200);
884    }
885
886    #[test]
887    fn error_only_returns_lowest_error() {
888        // No success path declared at all → fall back to the lowest declared code.
889        let r = route_from_responses(json!({
890            "500": {"description": "err"},
891            "404": {"description": "err"},
892        }));
893        assert_eq!(r.find_first_available_status_code(), 404);
894    }
895
896    #[test]
897    fn synthesises_set_cookie_when_declared_in_spec() {
898        // Round 43 (#79) — spec declares `responses.200.headers.Set-Cookie`;
899        // synthesiser must surface a cookie-shaped value instead of skipping
900        // the header. The exact value is opaque from the caller's view; we
901        // only assert it's a `name=value` cookie line so future changes to
902        // the placeholder text don't break the test.
903        let r = route_from_responses(json!({
904            "200": {
905                "description": "ok",
906                "headers": {
907                    "Set-Cookie": {"schema": {"type": "string"}}
908                }
909            }
910        }));
911        let headers = r.mock_response_headers_for_status(200);
912        let (_, value) = headers
913            .iter()
914            .find(|(name, _)| name.eq_ignore_ascii_case("Set-Cookie"))
915            .expect("Set-Cookie header should be synthesised");
916        assert!(
917            value.contains('=') && value.contains("Path="),
918            "expected cookie shape `name=value; Path=...`, got: {value:?}"
919        );
920    }
921
922    #[test]
923    fn synthesises_uuid_format_value_for_string_header() {
924        let r = route_from_responses(json!({
925            "200": {
926                "description": "ok",
927                "headers": {
928                    "X-Request-Id": {"schema": {"type": "string", "format": "uuid"}}
929                }
930            }
931        }));
932        let headers = r.mock_response_headers_for_status(200);
933        let (_, value) = headers
934            .iter()
935            .find(|(name, _)| name.eq_ignore_ascii_case("X-Request-Id"))
936            .expect("X-Request-Id header should be synthesised");
937        assert_eq!(value, "00000000-0000-0000-0000-000000000000");
938    }
939
940    #[test]
941    fn omits_headers_for_unmatched_status() {
942        // Spec declares headers on 200 only; asking for a 404 should produce nothing.
943        let r = route_from_responses(json!({
944            "200": {
945                "description": "ok",
946                "headers": {"Set-Cookie": {"schema": {"type": "string"}}}
947            }
948        }));
949        assert!(r.mock_response_headers_for_status(404).is_empty());
950    }
951}