Skip to main content

mockforge_http/management/
mod.rs

1/// Management API for MockForge
2///
3/// Provides REST endpoints for controlling mocks, server configuration,
4/// and integration with developer tools (VS Code extension, CI/CD, etc.)
5mod ai_gen;
6mod chaos_admin;
7mod conformance;
8mod health;
9mod import_export;
10mod migration;
11mod mocks;
12mod protocols;
13mod proxy;
14mod rule_explanations;
15mod traffic_to_openapi;
16
17// `ai_gen.rs` was split into four topic files under #656; the route
18// wiring below pulls handlers from each via these glob re-exports.
19pub use ai_gen::*;
20pub use chaos_admin::*;
21pub(crate) use conformance::{clear_conformance_violations, get_conformance_violations};
22pub use health::*;
23pub use import_export::*;
24pub use proxy::{BodyTransformRequest, ProxyRuleRequest, ProxyRuleResponse};
25pub use rule_explanations::*;
26pub use traffic_to_openapi::*;
27
28use axum::{
29    body::Body,
30    extract::State,
31    http::{HeaderName, HeaderValue, Request, StatusCode},
32    response::{IntoResponse, Response},
33    routing::{delete, get, post, put},
34    Router,
35};
36use mockforge_openapi::OpenApiSpec;
37use mockforge_proxy::config::ProxyConfig;
38use serde::{Deserialize, Serialize};
39use std::sync::Arc;
40use tokio::sync::{broadcast, RwLock};
41
42/// Default broadcast channel capacity for message events
43#[cfg(any(feature = "mqtt", feature = "kafka"))]
44const DEFAULT_MESSAGE_BROADCAST_CAPACITY: usize = 1000;
45
46/// Get the broadcast channel capacity from environment or use default
47#[cfg(any(feature = "mqtt", feature = "kafka"))]
48fn get_message_broadcast_capacity() -> usize {
49    std::env::var("MOCKFORGE_MESSAGE_BROADCAST_CAPACITY")
50        .ok()
51        .and_then(|s| s.parse().ok())
52        .unwrap_or(DEFAULT_MESSAGE_BROADCAST_CAPACITY)
53}
54
55/// Message event types for real-time monitoring
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(tag = "protocol", content = "data")]
58#[serde(rename_all = "lowercase")]
59pub enum MessageEvent {
60    #[cfg(feature = "mqtt")]
61    /// MQTT message event
62    Mqtt(MqttMessageEvent),
63    #[cfg(feature = "kafka")]
64    /// Kafka message event
65    Kafka(KafkaMessageEvent),
66}
67
68#[cfg(feature = "mqtt")]
69/// MQTT message event for real-time monitoring
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct MqttMessageEvent {
72    /// MQTT topic name
73    pub topic: String,
74    /// Message payload content
75    pub payload: String,
76    /// Quality of Service level (0, 1, or 2)
77    pub qos: u8,
78    /// Whether the message is retained
79    pub retain: bool,
80    /// RFC3339 formatted timestamp
81    pub timestamp: String,
82}
83
84#[cfg(feature = "kafka")]
85#[allow(missing_docs)]
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct KafkaMessageEvent {
88    pub topic: String,
89    pub key: Option<String>,
90    pub value: String,
91    pub partition: i32,
92    pub offset: i64,
93    pub headers: Option<std::collections::HashMap<String, String>>,
94    pub timestamp: String,
95}
96
97/// Mock configuration representation
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct MockConfig {
100    /// Unique identifier for the mock. Auto-generated by `create_mock` when
101    /// omitted on input — clients can POST without an `id` and the server
102    /// will assign one.
103    #[serde(default, skip_serializing_if = "String::is_empty")]
104    pub id: String,
105    /// Human-readable name for the mock. Optional on input; defaults to an
106    /// empty string (consumers can leave it blank when programmatically
107    /// creating mocks via the management API).
108    #[serde(default)]
109    pub name: String,
110    /// HTTP method (GET, POST, etc.)
111    pub method: String,
112    /// API path pattern to match
113    pub path: String,
114    /// Response configuration
115    pub response: MockResponse,
116    /// Whether this mock is currently enabled
117    #[serde(default = "default_true")]
118    pub enabled: bool,
119    /// Optional latency to inject in milliseconds
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub latency_ms: Option<u64>,
122    /// Optional HTTP status code override
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub status_code: Option<u16>,
125    /// Request matching criteria (headers, query params, body patterns)
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub request_match: Option<RequestMatchCriteria>,
128    /// Priority for mock ordering (higher priority mocks are matched first)
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub priority: Option<i32>,
131    /// Scenario name for stateful mocking
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub scenario: Option<String>,
134    /// Required scenario state for this mock to be active
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub required_scenario_state: Option<String>,
137    /// New scenario state after this mock is matched
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub new_scenario_state: Option<String>,
140}
141
142fn default_true() -> bool {
143    true
144}
145
146/// Mock response configuration
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct MockResponse {
149    /// Response body as JSON
150    pub body: serde_json::Value,
151    /// Optional custom response headers
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub headers: Option<std::collections::HashMap<String, String>>,
154}
155
156/// Request matching criteria for advanced request matching
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct RequestMatchCriteria {
159    /// Headers that must be present and match (case-insensitive header names)
160    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
161    pub headers: std::collections::HashMap<String, String>,
162    /// Query parameters that must be present and match
163    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
164    pub query_params: std::collections::HashMap<String, String>,
165    /// Request body pattern (supports exact match or regex)
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub body_pattern: Option<String>,
168    /// JSONPath expression for JSON body matching
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub json_path: Option<String>,
171    /// XPath expression for XML body matching
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub xpath: Option<String>,
174    /// Custom matcher expression (e.g., "headers.content-type == \"application/json\"")
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub custom_matcher: Option<String>,
177}
178
179/// Check if a request matches the given mock configuration
180///
181/// This function implements comprehensive request matching including:
182/// - Method and path matching
183/// - Header matching (with regex support)
184/// - Query parameter matching
185/// - Body pattern matching (exact, regex, JSONPath, XPath)
186/// - Custom matcher expressions
187pub fn mock_matches_request(
188    mock: &MockConfig,
189    method: &str,
190    path: &str,
191    headers: &std::collections::HashMap<String, String>,
192    query_params: &std::collections::HashMap<String, String>,
193    body: Option<&[u8]>,
194) -> bool {
195    use regex::Regex;
196
197    // Check if mock is enabled
198    if !mock.enabled {
199        return false;
200    }
201
202    // Check method (case-insensitive)
203    if mock.method.to_uppercase() != method.to_uppercase() {
204        return false;
205    }
206
207    // Check path pattern (supports wildcards and path parameters)
208    if !path_matches_pattern(&mock.path, path) {
209        return false;
210    }
211
212    // Check request matching criteria if present
213    if let Some(criteria) = &mock.request_match {
214        // Check headers
215        for (key, expected_value) in &criteria.headers {
216            let header_key_lower = key.to_lowercase();
217            let found = headers.iter().find(|(k, _)| k.to_lowercase() == header_key_lower);
218
219            if let Some((_, actual_value)) = found {
220                // Try regex match first, then exact match
221                if let Ok(re) = Regex::new(expected_value) {
222                    if !re.is_match(actual_value) {
223                        return false;
224                    }
225                } else if actual_value != expected_value {
226                    return false;
227                }
228            } else {
229                return false; // Header not found
230            }
231        }
232
233        // Check query parameters
234        for (key, expected_value) in &criteria.query_params {
235            if let Some(actual_value) = query_params.get(key) {
236                if actual_value != expected_value {
237                    return false;
238                }
239            } else {
240                return false; // Query param not found
241            }
242        }
243
244        // Check body pattern
245        if let Some(pattern) = &criteria.body_pattern {
246            if let Some(body_bytes) = body {
247                let body_str = String::from_utf8_lossy(body_bytes);
248                // Try regex first, then exact match
249                if let Ok(re) = Regex::new(pattern) {
250                    if !re.is_match(&body_str) {
251                        return false;
252                    }
253                } else if body_str.as_ref() != pattern {
254                    return false;
255                }
256            } else {
257                return false; // Body required but not present
258            }
259        }
260
261        // Check JSONPath (simplified implementation)
262        if let Some(json_path) = &criteria.json_path {
263            if let Some(body_bytes) = body {
264                if let Ok(body_str) = std::str::from_utf8(body_bytes) {
265                    if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(body_str) {
266                        // Simple JSONPath check
267                        if !json_path_exists(&json_value, json_path) {
268                            return false;
269                        }
270                    }
271                }
272            }
273        }
274
275        // Check XPath (supports a focused subset)
276        if let Some(xpath) = &criteria.xpath {
277            if let Some(body_bytes) = body {
278                if let Ok(body_str) = std::str::from_utf8(body_bytes) {
279                    if !xml_xpath_exists(body_str, xpath) {
280                        return false;
281                    }
282                } else {
283                    return false;
284                }
285            } else {
286                return false; // Body required but not present
287            }
288        }
289
290        // Check custom matcher
291        if let Some(custom) = &criteria.custom_matcher {
292            if !evaluate_custom_matcher(custom, method, path, headers, query_params, body) {
293                return false;
294            }
295        }
296    }
297
298    true
299}
300
301/// Check if a path matches a pattern (supports wildcards and path parameters)
302fn path_matches_pattern(pattern: &str, path: &str) -> bool {
303    // Exact match
304    if pattern == path {
305        return true;
306    }
307
308    // Wildcard match
309    if pattern == "*" {
310        return true;
311    }
312
313    // Path parameter matching (e.g., /users/{id} matches /users/123)
314    let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
315    let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
316
317    if pattern_parts.len() != path_parts.len() {
318        // Check for wildcard patterns
319        if pattern.contains('*') {
320            return matches_wildcard_pattern(pattern, path);
321        }
322        return false;
323    }
324
325    for (pattern_part, path_part) in pattern_parts.iter().zip(path_parts.iter()) {
326        // Check for path parameters {param}
327        if pattern_part.starts_with('{') && pattern_part.ends_with('}') {
328            continue; // Matches any value
329        }
330
331        if pattern_part != path_part {
332            return false;
333        }
334    }
335
336    true
337}
338
339/// Check if path matches a wildcard pattern
340fn matches_wildcard_pattern(pattern: &str, path: &str) -> bool {
341    use regex::Regex;
342
343    // Convert pattern to regex
344    let regex_pattern = pattern.replace('*', ".*").replace('?', ".?");
345
346    if let Ok(re) = Regex::new(&format!("^{}$", regex_pattern)) {
347        return re.is_match(path);
348    }
349
350    false
351}
352
353/// Check if a JSONPath exists in a JSON value
354///
355/// Supports:
356/// - `$` — root element
357/// - `$.field.subfield` — nested object access
358/// - `$.items[0].name` — array index access
359/// - `$.items[*]` — array wildcard (checks array is non-empty)
360fn json_path_exists(json: &serde_json::Value, json_path: &str) -> bool {
361    let path = if json_path == "$" {
362        return true;
363    } else if let Some(p) = json_path.strip_prefix("$.") {
364        p
365    } else if let Some(p) = json_path.strip_prefix('$') {
366        p.strip_prefix('.').unwrap_or(p)
367    } else {
368        json_path
369    };
370
371    let mut current = json;
372    for segment in split_json_path_segments(path) {
373        match segment {
374            JsonPathSegment::Field(name) => {
375                if let Some(obj) = current.as_object() {
376                    if let Some(value) = obj.get(name) {
377                        current = value;
378                    } else {
379                        return false;
380                    }
381                } else {
382                    return false;
383                }
384            }
385            JsonPathSegment::Index(idx) => {
386                if let Some(arr) = current.as_array() {
387                    if let Some(value) = arr.get(idx) {
388                        current = value;
389                    } else {
390                        return false;
391                    }
392                } else {
393                    return false;
394                }
395            }
396            JsonPathSegment::Wildcard => {
397                if let Some(arr) = current.as_array() {
398                    return !arr.is_empty();
399                }
400                return false;
401            }
402        }
403    }
404    true
405}
406
407enum JsonPathSegment<'a> {
408    Field(&'a str),
409    Index(usize),
410    Wildcard,
411}
412
413/// Split a JSONPath (without the leading `$`) into segments
414fn split_json_path_segments(path: &str) -> Vec<JsonPathSegment<'_>> {
415    let mut segments = Vec::new();
416    for part in path.split('.') {
417        if part.is_empty() {
418            continue;
419        }
420        if let Some(bracket_start) = part.find('[') {
421            let field_name = &part[..bracket_start];
422            if !field_name.is_empty() {
423                segments.push(JsonPathSegment::Field(field_name));
424            }
425            let bracket_content = &part[bracket_start + 1..part.len() - 1];
426            if bracket_content == "*" {
427                segments.push(JsonPathSegment::Wildcard);
428            } else if let Ok(idx) = bracket_content.parse::<usize>() {
429                segments.push(JsonPathSegment::Index(idx));
430            }
431        } else {
432            segments.push(JsonPathSegment::Field(part));
433        }
434    }
435    segments
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
439struct XPathSegment {
440    name: String,
441    text_equals: Option<String>,
442}
443
444fn parse_xpath_segment(segment: &str) -> Option<XPathSegment> {
445    if segment.is_empty() {
446        return None;
447    }
448
449    let trimmed = segment.trim();
450    if let Some(bracket_start) = trimmed.find('[') {
451        if !trimmed.ends_with(']') {
452            return None;
453        }
454
455        let name = trimmed[..bracket_start].trim();
456        let predicate = &trimmed[bracket_start + 1..trimmed.len() - 1];
457        let predicate = predicate.trim();
458
459        // Support simple predicate: [text()="value"] or [text()='value']
460        if let Some(raw) = predicate.strip_prefix("text()=") {
461            let raw = raw.trim();
462            if raw.len() >= 2
463                && ((raw.starts_with('"') && raw.ends_with('"'))
464                    || (raw.starts_with('\'') && raw.ends_with('\'')))
465            {
466                let text = raw[1..raw.len() - 1].to_string();
467                if !name.is_empty() {
468                    return Some(XPathSegment {
469                        name: name.to_string(),
470                        text_equals: Some(text),
471                    });
472                }
473            }
474        }
475
476        None
477    } else {
478        Some(XPathSegment {
479            name: trimmed.to_string(),
480            text_equals: None,
481        })
482    }
483}
484
485fn segment_matches(node: roxmltree::Node<'_, '_>, segment: &XPathSegment) -> bool {
486    if !node.is_element() {
487        return false;
488    }
489    if node.tag_name().name() != segment.name {
490        return false;
491    }
492    match &segment.text_equals {
493        Some(expected) => node.text().map(str::trim).unwrap_or_default() == expected,
494        None => true,
495    }
496}
497
498/// Check if an XPath expression matches an XML body.
499///
500/// Supported subset:
501/// - Absolute paths: `/root/child/item`
502/// - Descendant search: `//item` and `//parent/child`
503/// - Optional text predicate per segment: `item[text()="value"]`
504fn xml_xpath_exists(xml_body: &str, xpath: &str) -> bool {
505    let doc = match roxmltree::Document::parse(xml_body) {
506        Ok(doc) => doc,
507        Err(err) => {
508            tracing::warn!("Failed to parse XML for XPath matching: {}", err);
509            return false;
510        }
511    };
512
513    let expr = xpath.trim();
514    if expr.is_empty() {
515        return false;
516    }
517
518    let (is_descendant, path_str) = if let Some(rest) = expr.strip_prefix("//") {
519        (true, rest)
520    } else if let Some(rest) = expr.strip_prefix('/') {
521        (false, rest)
522    } else {
523        tracing::warn!("Unsupported XPath expression (must start with / or //): {}", expr);
524        return false;
525    };
526
527    let segments: Vec<XPathSegment> = path_str
528        .split('/')
529        .filter(|s| !s.trim().is_empty())
530        .filter_map(parse_xpath_segment)
531        .collect();
532
533    if segments.is_empty() {
534        return false;
535    }
536
537    if is_descendant {
538        let first = &segments[0];
539        for node in doc.descendants().filter(|n| segment_matches(*n, first)) {
540            let mut frontier = vec![node];
541            for segment in &segments[1..] {
542                let mut next_frontier = Vec::new();
543                for parent in &frontier {
544                    for child in parent.children().filter(|n| segment_matches(*n, segment)) {
545                        next_frontier.push(child);
546                    }
547                }
548                if next_frontier.is_empty() {
549                    frontier.clear();
550                    break;
551                }
552                frontier = next_frontier;
553            }
554            if !frontier.is_empty() {
555                return true;
556            }
557        }
558        false
559    } else {
560        let mut frontier = vec![doc.root_element()];
561        for (index, segment) in segments.iter().enumerate() {
562            let mut next_frontier = Vec::new();
563            for parent in &frontier {
564                if index == 0 {
565                    if segment_matches(*parent, segment) {
566                        next_frontier.push(*parent);
567                    }
568                    continue;
569                }
570                for child in parent.children().filter(|n| segment_matches(*n, segment)) {
571                    next_frontier.push(child);
572                }
573            }
574            if next_frontier.is_empty() {
575                return false;
576            }
577            frontier = next_frontier;
578        }
579        !frontier.is_empty()
580    }
581}
582
583/// Evaluate a custom matcher expression
584fn evaluate_custom_matcher(
585    expression: &str,
586    method: &str,
587    path: &str,
588    headers: &std::collections::HashMap<String, String>,
589    query_params: &std::collections::HashMap<String, String>,
590    body: Option<&[u8]>,
591) -> bool {
592    use regex::Regex;
593
594    let expr = expression.trim();
595
596    // Handle equality expressions (field == "value")
597    if expr.contains("==") {
598        let parts: Vec<&str> = expr.split("==").map(|s| s.trim()).collect();
599        if parts.len() != 2 {
600            return false;
601        }
602
603        let field = parts[0];
604        let expected_value = parts[1].trim_matches('"').trim_matches('\'');
605
606        match field {
607            "method" => method == expected_value,
608            "path" => path == expected_value,
609            _ if field.starts_with("headers.") => {
610                let header_name = &field[8..];
611                headers.get(header_name).map(|v| v == expected_value).unwrap_or(false)
612            }
613            _ if field.starts_with("query.") => {
614                let param_name = &field[6..];
615                query_params.get(param_name).map(|v| v == expected_value).unwrap_or(false)
616            }
617            _ => false,
618        }
619    }
620    // Handle regex match expressions (field =~ "pattern")
621    else if expr.contains("=~") {
622        let parts: Vec<&str> = expr.split("=~").map(|s| s.trim()).collect();
623        if parts.len() != 2 {
624            return false;
625        }
626
627        let field = parts[0];
628        let pattern = parts[1].trim_matches('"').trim_matches('\'');
629
630        if let Ok(re) = Regex::new(pattern) {
631            match field {
632                "method" => re.is_match(method),
633                "path" => re.is_match(path),
634                _ if field.starts_with("headers.") => {
635                    let header_name = &field[8..];
636                    headers.get(header_name).map(|v| re.is_match(v)).unwrap_or(false)
637                }
638                _ if field.starts_with("query.") => {
639                    let param_name = &field[6..];
640                    query_params.get(param_name).map(|v| re.is_match(v)).unwrap_or(false)
641                }
642                _ => false,
643            }
644        } else {
645            false
646        }
647    }
648    // Handle contains expressions (field contains "value")
649    else if expr.contains("contains") {
650        let parts: Vec<&str> = expr.split("contains").map(|s| s.trim()).collect();
651        if parts.len() != 2 {
652            return false;
653        }
654
655        let field = parts[0];
656        let search_value = parts[1].trim_matches('"').trim_matches('\'');
657
658        match field {
659            "path" => path.contains(search_value),
660            _ if field.starts_with("headers.") => {
661                let header_name = &field[8..];
662                headers.get(header_name).map(|v| v.contains(search_value)).unwrap_or(false)
663            }
664            _ if field.starts_with("body") => {
665                if let Some(body_bytes) = body {
666                    let body_str = String::from_utf8_lossy(body_bytes);
667                    body_str.contains(search_value)
668                } else {
669                    false
670                }
671            }
672            _ => false,
673        }
674    } else {
675        // Unknown expression format
676        tracing::warn!("Unknown custom matcher expression format: {}", expr);
677        false
678    }
679}
680
681/// Server statistics
682#[derive(Debug, Clone, Serialize, Deserialize)]
683pub struct ServerStats {
684    /// Server uptime in seconds
685    pub uptime_seconds: u64,
686    /// Total number of requests processed
687    pub total_requests: u64,
688    /// Number of active mock configurations
689    pub active_mocks: usize,
690    /// Number of currently enabled mocks
691    pub enabled_mocks: usize,
692    /// Number of registered API routes
693    pub registered_routes: usize,
694}
695
696/// Server configuration info
697#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct ServerConfig {
699    /// MockForge version string
700    pub version: String,
701    /// Server port number
702    pub port: u16,
703    /// Whether an OpenAPI spec is loaded
704    pub has_openapi_spec: bool,
705    /// Optional path to the OpenAPI spec file
706    #[serde(skip_serializing_if = "Option::is_none")]
707    pub spec_path: Option<String>,
708}
709
710/// Shared state for the management API
711#[derive(Clone)]
712pub struct ManagementState {
713    /// Collection of mock configurations
714    pub mocks: Arc<RwLock<Vec<MockConfig>>>,
715    /// Optional OpenAPI specification
716    pub spec: Option<Arc<OpenApiSpec>>,
717    /// Optional path to the OpenAPI spec file
718    pub spec_path: Option<String>,
719    /// Server port number
720    pub port: u16,
721    /// Server start time for uptime calculation
722    pub start_time: std::time::Instant,
723    /// Counter for total requests processed
724    pub request_counter: Arc<RwLock<u64>>,
725    /// Optional proxy configuration for migration pipeline
726    pub proxy_config: Option<Arc<RwLock<ProxyConfig>>>,
727    /// Optional SMTP registry for email mocking
728    #[cfg(feature = "smtp")]
729    pub smtp_registry: Option<Arc<mockforge_smtp::SmtpSpecRegistry>>,
730    /// Optional MQTT broker for message mocking
731    #[cfg(feature = "mqtt")]
732    pub mqtt_broker: Option<Arc<mockforge_mqtt::MqttBroker>>,
733    /// Optional Kafka broker for event streaming
734    #[cfg(feature = "kafka")]
735    pub kafka_broker: Option<Arc<mockforge_kafka::KafkaMockBroker>>,
736    /// Broadcast channel for message events (MQTT & Kafka)
737    #[cfg(any(feature = "mqtt", feature = "kafka"))]
738    pub message_events: Arc<broadcast::Sender<MessageEvent>>,
739    /// State machine manager for scenario state machines
740    pub state_machine_manager:
741        Arc<RwLock<mockforge_scenarios::state_machine::ScenarioStateMachineManager>>,
742    /// Optional WebSocket broadcast channel for real-time updates
743    pub ws_broadcast: Option<Arc<broadcast::Sender<crate::management_ws::MockEvent>>>,
744    /// Lifecycle hook registry for extensibility
745    pub lifecycle_hooks: Option<Arc<mockforge_core::lifecycle::LifecycleHookRegistry>>,
746    /// Rule explanations storage (in-memory for now)
747    pub rule_explanations: Arc<
748        RwLock<
749            std::collections::HashMap<
750                String,
751                mockforge_foundation::intelligent_behavior::rule_types::RuleExplanation,
752            >,
753        >,
754    >,
755    /// Optional chaos API state for chaos config management
756    #[cfg(feature = "chaos")]
757    pub chaos_api_state: Option<Arc<mockforge_chaos::api::ChaosApiState>>,
758    /// Optional server configuration for profile application
759    pub server_config: Option<Arc<RwLock<mockforge_core::config::ServerConfig>>>,
760    /// Conformance testing state
761    #[cfg(feature = "conformance")]
762    pub conformance_state: crate::handlers::conformance::ConformanceState,
763}
764
765impl ManagementState {
766    /// Create a new management state
767    ///
768    /// # Arguments
769    /// * `spec` - Optional OpenAPI specification
770    /// * `spec_path` - Optional path to the OpenAPI spec file
771    /// * `port` - Server port number
772    pub fn new(spec: Option<Arc<OpenApiSpec>>, spec_path: Option<String>, port: u16) -> Self {
773        Self {
774            mocks: Arc::new(RwLock::new(Vec::new())),
775            spec,
776            spec_path,
777            port,
778            start_time: std::time::Instant::now(),
779            request_counter: Arc::new(RwLock::new(0)),
780            proxy_config: None,
781            #[cfg(feature = "smtp")]
782            smtp_registry: None,
783            #[cfg(feature = "mqtt")]
784            mqtt_broker: None,
785            #[cfg(feature = "kafka")]
786            kafka_broker: None,
787            #[cfg(any(feature = "mqtt", feature = "kafka"))]
788            message_events: {
789                let capacity = get_message_broadcast_capacity();
790                let (tx, _) = broadcast::channel(capacity);
791                Arc::new(tx)
792            },
793            state_machine_manager: Arc::new(RwLock::new(
794                mockforge_scenarios::state_machine::ScenarioStateMachineManager::new(),
795            )),
796            ws_broadcast: None,
797            lifecycle_hooks: None,
798            rule_explanations: Arc::new(RwLock::new(std::collections::HashMap::new())),
799            #[cfg(feature = "chaos")]
800            chaos_api_state: None,
801            server_config: None,
802            #[cfg(feature = "conformance")]
803            conformance_state: crate::handlers::conformance::ConformanceState::new(),
804        }
805    }
806
807    /// Add lifecycle hook registry to management state
808    pub fn with_lifecycle_hooks(
809        mut self,
810        hooks: Arc<mockforge_core::lifecycle::LifecycleHookRegistry>,
811    ) -> Self {
812        self.lifecycle_hooks = Some(hooks);
813        self
814    }
815
816    /// Add WebSocket broadcast channel to management state
817    pub fn with_ws_broadcast(
818        mut self,
819        ws_broadcast: Arc<broadcast::Sender<crate::management_ws::MockEvent>>,
820    ) -> Self {
821        self.ws_broadcast = Some(ws_broadcast);
822        self
823    }
824
825    /// Add proxy configuration to management state
826    pub fn with_proxy_config(mut self, proxy_config: Arc<RwLock<ProxyConfig>>) -> Self {
827        self.proxy_config = Some(proxy_config);
828        self
829    }
830
831    #[cfg(feature = "smtp")]
832    /// Add SMTP registry to management state
833    pub fn with_smtp_registry(
834        mut self,
835        smtp_registry: Arc<mockforge_smtp::SmtpSpecRegistry>,
836    ) -> Self {
837        self.smtp_registry = Some(smtp_registry);
838        self
839    }
840
841    #[cfg(feature = "mqtt")]
842    /// Add MQTT broker to management state
843    pub fn with_mqtt_broker(mut self, mqtt_broker: Arc<mockforge_mqtt::MqttBroker>) -> Self {
844        self.mqtt_broker = Some(mqtt_broker);
845        self
846    }
847
848    #[cfg(feature = "kafka")]
849    /// Add Kafka broker to management state
850    pub fn with_kafka_broker(
851        mut self,
852        kafka_broker: Arc<mockforge_kafka::KafkaMockBroker>,
853    ) -> Self {
854        self.kafka_broker = Some(kafka_broker);
855        self
856    }
857
858    #[cfg(feature = "chaos")]
859    /// Add chaos API state to management state
860    pub fn with_chaos_api_state(
861        mut self,
862        chaos_api_state: Arc<mockforge_chaos::api::ChaosApiState>,
863    ) -> Self {
864        self.chaos_api_state = Some(chaos_api_state);
865        self
866    }
867
868    /// Add server configuration to management state
869    pub fn with_server_config(
870        mut self,
871        server_config: Arc<RwLock<mockforge_core::config::ServerConfig>>,
872    ) -> Self {
873        self.server_config = Some(server_config);
874        self
875    }
876}
877
878/// Build the management API router
879pub fn management_router(state: ManagementState) -> Router {
880    let router = Router::new()
881        .route("/capabilities", get(get_capabilities))
882        .route("/health", get(health_check))
883        .route("/stats", get(get_stats))
884        .route("/config", get(get_config))
885        .route("/config/validate", post(validate_config))
886        .route("/config/bulk", post(bulk_update_config))
887        .route("/mocks", get(mocks::list_mocks))
888        .route("/mocks", post(mocks::create_mock))
889        .route("/mocks/{id}", get(mocks::get_mock))
890        .route("/mocks/{id}", put(mocks::update_mock))
891        .route("/mocks/{id}", delete(mocks::delete_mock))
892        .route("/export", get(export_mocks))
893        .route("/import", post(import_mocks))
894        .route("/spec", get(get_openapi_spec))
895        // Issue #79 round 12 — server-side spec violation feed for the
896        // new TUI "Conformance" screen. Backed by the bounded ring
897        // buffer in `mockforge_foundation::conformance_violations` that
898        // the OpenAPI router populates whenever
899        // `validate_request_with_all` rejects an incoming request.
900        .route("/conformance/violations", get(get_conformance_violations))
901        .route("/conformance/violations", delete(clear_conformance_violations));
902
903    #[cfg(feature = "smtp")]
904    let router = router
905        .route("/smtp/mailbox", get(protocols::list_smtp_emails))
906        .route("/smtp/mailbox", delete(protocols::clear_smtp_mailbox))
907        .route("/smtp/mailbox/{id}", get(protocols::get_smtp_email))
908        .route("/smtp/mailbox/export", get(protocols::export_smtp_mailbox))
909        .route("/smtp/mailbox/search", get(protocols::search_smtp_emails));
910
911    #[cfg(not(feature = "smtp"))]
912    let router = router;
913
914    // MQTT routes
915    #[cfg(feature = "mqtt")]
916    let router = router
917        .route("/mqtt/stats", get(protocols::get_mqtt_stats))
918        .route("/mqtt/clients", get(protocols::get_mqtt_clients))
919        .route("/mqtt/topics", get(protocols::get_mqtt_topics))
920        .route("/mqtt/clients/{client_id}", delete(protocols::disconnect_mqtt_client))
921        .route("/mqtt/messages/stream", get(protocols::mqtt_messages_stream))
922        .route("/mqtt/publish", post(protocols::publish_mqtt_message_handler))
923        .route("/mqtt/publish/batch", post(protocols::publish_mqtt_batch_handler));
924
925    #[cfg(not(feature = "mqtt"))]
926    let router = router
927        .route("/mqtt/publish", post(protocols::publish_mqtt_message_handler))
928        .route("/mqtt/publish/batch", post(protocols::publish_mqtt_batch_handler));
929
930    #[cfg(feature = "kafka")]
931    let router = router
932        .route("/kafka/stats", get(protocols::get_kafka_stats))
933        .route("/kafka/topics", get(protocols::get_kafka_topics))
934        .route("/kafka/topics/{topic}", get(protocols::get_kafka_topic))
935        .route("/kafka/groups", get(protocols::get_kafka_groups))
936        .route("/kafka/groups/{group_id}", get(protocols::get_kafka_group))
937        .route("/kafka/produce", post(protocols::produce_kafka_message))
938        .route("/kafka/produce/batch", post(protocols::produce_kafka_batch))
939        .route("/kafka/messages/stream", get(protocols::kafka_messages_stream));
940
941    #[cfg(not(feature = "kafka"))]
942    let router = router;
943
944    // Migration pipeline routes
945    let router = router
946        .route("/migration/routes", get(migration::get_migration_routes))
947        .route("/migration/routes/{pattern}/toggle", post(migration::toggle_route_migration))
948        .route("/migration/routes/{pattern}", put(migration::set_route_migration_mode))
949        .route("/migration/groups/{group}/toggle", post(migration::toggle_group_migration))
950        .route("/migration/groups/{group}", put(migration::set_group_migration_mode))
951        .route("/migration/groups", get(migration::get_migration_groups))
952        .route("/migration/status", get(migration::get_migration_status));
953
954    // Proxy replacement rules routes
955    let router = router
956        .route("/proxy/rules", get(proxy::list_proxy_rules))
957        .route("/proxy/rules", post(proxy::create_proxy_rule))
958        .route("/proxy/rules/{id}", get(proxy::get_proxy_rule))
959        .route("/proxy/rules/{id}", put(proxy::update_proxy_rule))
960        .route("/proxy/rules/{id}", delete(proxy::delete_proxy_rule))
961        .route("/proxy/inspect", get(proxy::get_proxy_inspect));
962
963    // AI-powered features
964    let router = router.route("/ai/generate-spec", post(generate_ai_spec));
965
966    // Snapshot diff endpoints
967    let router = router.nest(
968        "/snapshot-diff",
969        crate::handlers::snapshot_diff::snapshot_diff_router(state.clone()),
970    );
971
972    #[cfg(feature = "behavioral-cloning")]
973    let router = router.route("/mockai/generate-openapi", post(generate_openapi_from_traffic));
974
975    let router = router
976        .route("/mockai/learn", post(learn_from_examples))
977        .route("/mockai/rules/explanations", get(list_rule_explanations))
978        .route("/mockai/rules/{id}/explanation", get(get_rule_explanation))
979        .route("/chaos/config", get(get_chaos_config))
980        .route("/chaos/config", post(update_chaos_config))
981        .route("/network/profiles", get(list_network_profiles))
982        .route("/network/profile/apply", post(apply_network_profile));
983
984    // State machine API routes
985    let router =
986        router.nest("/state-machines", crate::state_machine_api::create_state_machine_routes());
987
988    // Conformance testing API routes
989    #[cfg(feature = "conformance")]
990    let router = router.nest_service(
991        "/conformance",
992        crate::handlers::conformance::conformance_router(state.conformance_state.clone()),
993    );
994    #[cfg(not(feature = "conformance"))]
995    let router = router;
996
997    router.with_state(state)
998}
999
1000/// Build the management API router with UI Builder support
1001pub fn management_router_with_ui_builder(
1002    state: ManagementState,
1003    server_config: mockforge_core::config::ServerConfig,
1004) -> Router {
1005    use crate::ui_builder::{create_ui_builder_router, UIBuilderState};
1006
1007    // Create the base management router
1008    let management = management_router(state);
1009
1010    // Create UI Builder state and router
1011    let ui_builder_state = UIBuilderState::new(server_config);
1012    let ui_builder = create_ui_builder_router(ui_builder_state);
1013
1014    // Nest UI Builder under /ui-builder
1015    management.nest("/ui-builder", ui_builder)
1016}
1017
1018/// Build management router with spec import API
1019pub fn management_router_with_spec_import(state: ManagementState) -> Router {
1020    use crate::spec_import::{spec_import_router, SpecImportState};
1021
1022    // Create base management router
1023    let management = management_router(state);
1024
1025    // Merge with spec import router
1026    Router::new()
1027        .merge(management)
1028        .merge(spec_import_router(SpecImportState::new()))
1029}
1030
1031/// Match an incoming request against mocks registered via the
1032/// `POST /__mockforge/api/mocks` endpoint and return the first match's
1033/// response (ordered by descending priority). Returns `None` if nothing
1034/// matches, so the caller can chain it with its existing 404.
1035///
1036/// This is what lets the `@mockforge-dev/sdk` Node.js SDK register a stub
1037/// dynamically and have subsequent HTTP requests actually hit it — without
1038/// it, stubs live in `ManagementState` but no route dispatches to them, so
1039/// every SDK test would 404.
1040pub async fn serve_dynamic_mock(state: &ManagementState, req: Request<Body>) -> Option<Response> {
1041    let method = req.method().as_str().to_string();
1042    let path = req.uri().path().to_string();
1043
1044    let query_params: std::collections::HashMap<String, String> = req
1045        .uri()
1046        .query()
1047        .map(|q| url::form_urlencoded::parse(q.as_bytes()).into_owned().collect())
1048        .unwrap_or_default();
1049
1050    let headers: std::collections::HashMap<String, String> = req
1051        .headers()
1052        .iter()
1053        .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.as_str().to_string(), v.to_string())))
1054        .collect();
1055
1056    let (_parts, body) = req.into_parts();
1057    let body_bytes = axum::body::to_bytes(body, 1024 * 1024).await.ok()?;
1058    let body_opt: Option<&[u8]> = if body_bytes.is_empty() {
1059        None
1060    } else {
1061        Some(&body_bytes)
1062    };
1063
1064    let mocks = state.mocks.read().await;
1065
1066    let mut candidates: Vec<&MockConfig> = mocks
1067        .iter()
1068        .filter(|m| mock_matches_request(m, &method, &path, &headers, &query_params, body_opt))
1069        .collect();
1070    if candidates.is_empty() {
1071        return None;
1072    }
1073    candidates.sort_by_key(|m| -(m.priority.unwrap_or(0)));
1074    let mock = candidates.first()?;
1075
1076    if let Some(ms) = mock.latency_ms {
1077        if ms > 0 {
1078            tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
1079        }
1080    }
1081
1082    let status = mock
1083        .status_code
1084        .and_then(|c| StatusCode::from_u16(c).ok())
1085        .unwrap_or(StatusCode::OK);
1086
1087    // Honor MOCKFORGE_RESPONSE_TEMPLATE_EXPAND for mocks created via the
1088    // management API. The full templater in mockforge-core handles
1089    // `{{faker.email}}`, `{{uuid}}`, `{{randInt …}}`, etc. — it's gated
1090    // on the env var because it's the same opt-in as everywhere else
1091    // (config-loaded routes, OpenAPI overrides). Run inside spawn_blocking
1092    // because the templater's rng() and faker providers are not Send-safe
1093    // across `.await` points.
1094    let template_expand = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
1095        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1096        .unwrap_or(false);
1097    let body_value = if template_expand {
1098        let body_clone = mock.response.body.clone();
1099        match tokio::task::spawn_blocking(move || {
1100            mockforge_core::templating::expand_tokens(&body_clone)
1101        })
1102        .await
1103        {
1104            Ok(expanded) => expanded,
1105            Err(_) => mock.response.body.clone(),
1106        }
1107    } else {
1108        mock.response.body.clone()
1109    };
1110
1111    let body_bytes_out = serde_json::to_vec(&body_value).unwrap_or_default();
1112    let mut response = Response::builder().status(status);
1113
1114    let mut has_content_type = false;
1115    if let Some(h) = &mock.response.headers {
1116        for (k, v) in h {
1117            if k.eq_ignore_ascii_case("content-type") {
1118                has_content_type = true;
1119            }
1120            if let (Ok(name), Ok(value)) =
1121                (HeaderName::from_bytes(k.as_bytes()), HeaderValue::from_str(v))
1122            {
1123                response = response.header(name, value);
1124            }
1125        }
1126    }
1127    if !has_content_type {
1128        response = response.header("content-type", "application/json");
1129    }
1130
1131    Some(
1132        response
1133            .body(Body::from(body_bytes_out))
1134            .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
1135    )
1136}
1137
1138/// Axum fallback handler for the main router: tries to serve a dynamic mock,
1139/// or returns 404. Only invoked when nothing else in the router matched.
1140///
1141/// Wire via `.fallback_service(axum::routing::any(dynamic_mock_fallback).with_state(state))`
1142/// so the handler's own `State<ManagementState>` extractor is satisfied
1143/// without forcing the parent router to adopt `ManagementState` as its state.
1144pub async fn dynamic_mock_fallback(
1145    State(state): State<ManagementState>,
1146    req: Request<Body>,
1147) -> Response {
1148    // Issue #79 round 13 — capture the request method/path/query
1149    // before the body is consumed so we can record unmatched 404s
1150    // (Srikanth's (a) ask: surface paths the spec doesn't know about).
1151    // Only records when no dynamic mock matches — if a dynamic mock
1152    // serves the request, the path WAS handled, just not by OpenAPI.
1153    let method = req.method().as_str().to_string();
1154    let path = req.uri().path().to_string();
1155    let query = req.uri().query().unwrap_or_default().to_string();
1156
1157    match serve_dynamic_mock(&state, req).await {
1158        Some(resp) => resp,
1159        None => {
1160            // Issue #79 round 14 — shadow mode returns 200 for unknown
1161            // paths (instead of 404) while still recording them, so a
1162            // proxy replay flows through non-blocking. The recorded
1163            // `status` reflects what the client actually saw.
1164            let shadow = mockforge_foundation::unknown_paths::shadow_mode_enabled();
1165            let status = if shadow {
1166                StatusCode::OK
1167            } else {
1168                StatusCode::NOT_FOUND
1169            };
1170            mockforge_foundation::unknown_paths::record(
1171                mockforge_foundation::unknown_paths::UnknownPathRequest {
1172                    timestamp: chrono::Utc::now(),
1173                    method,
1174                    path,
1175                    client_ip: "unknown".to_string(),
1176                    query,
1177                    status: status.as_u16(),
1178                },
1179            );
1180            if shadow {
1181                // Minimal JSON stub so clients expecting a body don't
1182                // choke. Shadow mode is for traffic-replay observability,
1183                // not realistic response shapes.
1184                (
1185                    StatusCode::OK,
1186                    [(axum::http::header::CONTENT_TYPE, "application/json")],
1187                    r#"{"shadow":true,"matched":false}"#,
1188                )
1189                    .into_response()
1190            } else {
1191                StatusCode::NOT_FOUND.into_response()
1192            }
1193        }
1194    }
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199    use super::*;
1200
1201    #[tokio::test]
1202    async fn test_create_and_get_mock() {
1203        let state = ManagementState::new(None, None, 3000);
1204
1205        let mock = MockConfig {
1206            id: "test-1".to_string(),
1207            name: "Test Mock".to_string(),
1208            method: "GET".to_string(),
1209            path: "/test".to_string(),
1210            response: MockResponse {
1211                body: serde_json::json!({"message": "test"}),
1212                headers: None,
1213            },
1214            enabled: true,
1215            latency_ms: None,
1216            status_code: Some(200),
1217            request_match: None,
1218            priority: None,
1219            scenario: None,
1220            required_scenario_state: None,
1221            new_scenario_state: None,
1222        };
1223
1224        // Create mock
1225        {
1226            let mut mocks = state.mocks.write().await;
1227            mocks.push(mock.clone());
1228        }
1229
1230        // Get mock
1231        let mocks = state.mocks.read().await;
1232        let found = mocks.iter().find(|m| m.id == "test-1");
1233        assert!(found.is_some());
1234        assert_eq!(found.unwrap().name, "Test Mock");
1235    }
1236
1237    #[tokio::test]
1238    async fn test_server_stats() {
1239        let state = ManagementState::new(None, None, 3000);
1240
1241        // Add some mocks
1242        {
1243            let mut mocks = state.mocks.write().await;
1244            mocks.push(MockConfig {
1245                id: "1".to_string(),
1246                name: "Mock 1".to_string(),
1247                method: "GET".to_string(),
1248                path: "/test1".to_string(),
1249                response: MockResponse {
1250                    body: serde_json::json!({}),
1251                    headers: None,
1252                },
1253                enabled: true,
1254                latency_ms: None,
1255                status_code: Some(200),
1256                request_match: None,
1257                priority: None,
1258                scenario: None,
1259                required_scenario_state: None,
1260                new_scenario_state: None,
1261            });
1262            mocks.push(MockConfig {
1263                id: "2".to_string(),
1264                name: "Mock 2".to_string(),
1265                method: "POST".to_string(),
1266                path: "/test2".to_string(),
1267                response: MockResponse {
1268                    body: serde_json::json!({}),
1269                    headers: None,
1270                },
1271                enabled: false,
1272                latency_ms: None,
1273                status_code: Some(201),
1274                request_match: None,
1275                priority: None,
1276                scenario: None,
1277                required_scenario_state: None,
1278                new_scenario_state: None,
1279            });
1280        }
1281
1282        let mocks = state.mocks.read().await;
1283        assert_eq!(mocks.len(), 2);
1284        assert_eq!(mocks.iter().filter(|m| m.enabled).count(), 1);
1285    }
1286
1287    #[test]
1288    fn test_mock_matches_request_with_xpath_absolute_path() {
1289        let mock = MockConfig {
1290            id: "xpath-1".to_string(),
1291            name: "XPath Match".to_string(),
1292            method: "POST".to_string(),
1293            path: "/xml".to_string(),
1294            response: MockResponse {
1295                body: serde_json::json!({"ok": true}),
1296                headers: None,
1297            },
1298            enabled: true,
1299            latency_ms: None,
1300            status_code: Some(200),
1301            request_match: Some(RequestMatchCriteria {
1302                xpath: Some("/root/order/id".to_string()),
1303                ..Default::default()
1304            }),
1305            priority: None,
1306            scenario: None,
1307            required_scenario_state: None,
1308            new_scenario_state: None,
1309        };
1310
1311        let body = br#"<root><order><id>123</id></order></root>"#;
1312        let headers = std::collections::HashMap::new();
1313        let query = std::collections::HashMap::new();
1314
1315        assert!(mock_matches_request(&mock, "POST", "/xml", &headers, &query, Some(body)));
1316    }
1317
1318    #[test]
1319    fn test_mock_matches_request_with_xpath_text_predicate() {
1320        let mock = MockConfig {
1321            id: "xpath-2".to_string(),
1322            name: "XPath Predicate Match".to_string(),
1323            method: "POST".to_string(),
1324            path: "/xml".to_string(),
1325            response: MockResponse {
1326                body: serde_json::json!({"ok": true}),
1327                headers: None,
1328            },
1329            enabled: true,
1330            latency_ms: None,
1331            status_code: Some(200),
1332            request_match: Some(RequestMatchCriteria {
1333                xpath: Some("//order/id[text()='123']".to_string()),
1334                ..Default::default()
1335            }),
1336            priority: None,
1337            scenario: None,
1338            required_scenario_state: None,
1339            new_scenario_state: None,
1340        };
1341
1342        let body = br#"<root><order><id>123</id></order></root>"#;
1343        let headers = std::collections::HashMap::new();
1344        let query = std::collections::HashMap::new();
1345
1346        assert!(mock_matches_request(&mock, "POST", "/xml", &headers, &query, Some(body)));
1347    }
1348
1349    #[test]
1350    fn test_mock_matches_request_with_xpath_no_match() {
1351        let mock = MockConfig {
1352            id: "xpath-3".to_string(),
1353            name: "XPath No Match".to_string(),
1354            method: "POST".to_string(),
1355            path: "/xml".to_string(),
1356            response: MockResponse {
1357                body: serde_json::json!({"ok": true}),
1358                headers: None,
1359            },
1360            enabled: true,
1361            latency_ms: None,
1362            status_code: Some(200),
1363            request_match: Some(RequestMatchCriteria {
1364                xpath: Some("//order/id[text()='456']".to_string()),
1365                ..Default::default()
1366            }),
1367            priority: None,
1368            scenario: None,
1369            required_scenario_state: None,
1370            new_scenario_state: None,
1371        };
1372
1373        let body = br#"<root><order><id>123</id></order></root>"#;
1374        let headers = std::collections::HashMap::new();
1375        let query = std::collections::HashMap::new();
1376
1377        assert!(!mock_matches_request(&mock, "POST", "/xml", &headers, &query, Some(body)));
1378    }
1379}