mockforge_core/proxy/
config.rs

1//! Proxy configuration types and settings
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Migration mode for route handling
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9#[serde(rename_all = "lowercase")]
10pub enum MigrationMode {
11    /// Always use mock (ignore proxy even if rule matches)
12    Mock,
13    /// Proxy to real backend AND generate mock response for comparison
14    Shadow,
15    /// Always use real backend (proxy)
16    Real,
17    /// Use existing priority chain (default, backward compatible)
18    Auto,
19}
20
21impl Default for MigrationMode {
22    fn default() -> Self {
23        Self::Auto
24    }
25}
26
27/// Configuration for proxy behavior
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30pub struct ProxyConfig {
31    /// Whether the proxy is enabled
32    pub enabled: bool,
33    /// Target URL to proxy requests to
34    pub target_url: Option<String>,
35    /// Timeout for proxy requests in seconds
36    pub timeout_seconds: u64,
37    /// Whether to follow redirects
38    pub follow_redirects: bool,
39    /// Additional headers to add to proxied requests
40    pub headers: HashMap<String, String>,
41    /// Proxy prefix to strip from paths
42    pub prefix: Option<String>,
43    /// Whether to proxy by default
44    pub passthrough_by_default: bool,
45    /// Proxy rules
46    pub rules: Vec<ProxyRule>,
47    /// Whether migration features are enabled
48    #[serde(default)]
49    pub migration_enabled: bool,
50    /// Group-level migration mode overrides
51    /// Maps group name to migration mode
52    #[serde(default)]
53    pub migration_groups: HashMap<String, MigrationMode>,
54    /// Request body replacement rules for browser proxy mode
55    #[serde(default)]
56    pub request_replacements: Vec<BodyTransformRule>,
57    /// Response body replacement rules for browser proxy mode
58    #[serde(default)]
59    pub response_replacements: Vec<BodyTransformRule>,
60}
61
62/// Proxy routing rule
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
65pub struct ProxyRule {
66    /// Path pattern to match
67    pub path_pattern: String,
68    /// Target URL for this rule
69    pub target_url: String,
70    /// Whether this rule is enabled
71    pub enabled: bool,
72    /// Pattern for matching (alias for path_pattern)
73    pub pattern: String,
74    /// Upstream URL (alias for target_url)
75    pub upstream_url: String,
76    /// Migration mode for this route (mock, shadow, real, auto)
77    #[serde(default)]
78    pub migration_mode: MigrationMode,
79    /// Migration group this route belongs to (optional)
80    #[serde(default)]
81    pub migration_group: Option<String>,
82    /// Conditional expression for proxying (JSONPath, JavaScript-like, or Rhai script)
83    /// If provided, the request will only be proxied if the condition evaluates to true
84    /// Examples:
85    ///   - JSONPath: "$.user.role == 'admin'"
86    ///   - Header check: "header[authorization] != ''"
87    ///   - Query param: "query[env] == 'production'"
88    ///   - Complex: "AND($.user.role == 'admin', header[x-forwarded-for] != '')"
89    #[serde(default)]
90    pub condition: Option<String>,
91}
92
93impl Default for ProxyRule {
94    fn default() -> Self {
95        Self {
96            path_pattern: "/".to_string(),
97            target_url: "http://localhost:9080".to_string(),
98            enabled: true,
99            pattern: "/".to_string(),
100            upstream_url: "http://localhost:9080".to_string(),
101            migration_mode: MigrationMode::Auto,
102            migration_group: None,
103            condition: None,
104        }
105    }
106}
107
108impl ProxyConfig {
109    /// Create a new proxy configuration
110    pub fn new(upstream_url: String) -> Self {
111        Self {
112            enabled: true,
113            target_url: Some(upstream_url),
114            timeout_seconds: 30,
115            follow_redirects: true,
116            headers: HashMap::new(),
117            prefix: Some("/proxy/".to_string()),
118            passthrough_by_default: true,
119            rules: Vec::new(),
120            migration_enabled: false,
121            migration_groups: HashMap::new(),
122            request_replacements: Vec::new(),
123            response_replacements: Vec::new(),
124        }
125    }
126
127    /// Get the effective migration mode for a path
128    /// Checks group overrides first, then route-specific mode
129    pub fn get_effective_migration_mode(&self, path: &str) -> Option<MigrationMode> {
130        if !self.migration_enabled {
131            return None;
132        }
133
134        // Find matching rule
135        for rule in &self.rules {
136            if rule.enabled && self.path_matches_pattern(&rule.path_pattern, path) {
137                // Check group override first
138                if let Some(ref group) = rule.migration_group {
139                    if let Some(&group_mode) = self.migration_groups.get(group) {
140                        return Some(group_mode);
141                    }
142                }
143                // Return route-specific mode
144                return Some(rule.migration_mode);
145            }
146        }
147
148        None
149    }
150
151    /// Check if a request should be proxied
152    /// Respects migration mode: mock forces mock, real forces proxy, shadow forces proxy, auto uses existing logic
153    /// This is a legacy method that doesn't evaluate conditions - use should_proxy_with_condition for conditional proxying
154    pub fn should_proxy(&self, _method: &axum::http::Method, path: &str) -> bool {
155        if !self.enabled {
156            return false;
157        }
158
159        // Check migration mode if enabled
160        if self.migration_enabled {
161            if let Some(mode) = self.get_effective_migration_mode(path) {
162                match mode {
163                    MigrationMode::Mock => return false,  // Force mock
164                    MigrationMode::Shadow => return true, // Force proxy (for shadow mode)
165                    MigrationMode::Real => return true,   // Force proxy
166                    MigrationMode::Auto => {
167                        // Fall through to existing logic
168                    }
169                }
170            }
171        }
172
173        // If there are rules, check if any rule matches (without condition evaluation)
174        for rule in &self.rules {
175            if rule.enabled && self.path_matches_pattern(&rule.path_pattern, path) {
176                // If rule has a condition, we can't evaluate it here (no request context)
177                // So we skip conditional rules in this legacy method
178                if rule.condition.is_none() {
179                    return true;
180                }
181            }
182        }
183
184        // If no rules match, check prefix logic
185        match &self.prefix {
186            None => true, // No prefix means proxy everything
187            Some(prefix) => path.starts_with(prefix),
188        }
189    }
190
191    /// Check if a request should be proxied with conditional evaluation
192    /// This method evaluates conditions in proxy rules using request context
193    pub fn should_proxy_with_condition(
194        &self,
195        method: &axum::http::Method,
196        uri: &axum::http::Uri,
197        headers: &axum::http::HeaderMap,
198        body: Option<&[u8]>,
199    ) -> bool {
200        use crate::proxy::conditional::find_matching_rule;
201
202        if !self.enabled {
203            return false;
204        }
205
206        let path = uri.path();
207
208        // Check migration mode if enabled
209        if self.migration_enabled {
210            if let Some(mode) = self.get_effective_migration_mode(path) {
211                match mode {
212                    MigrationMode::Mock => return false,  // Force mock
213                    MigrationMode::Shadow => return true, // Force proxy (for shadow mode)
214                    MigrationMode::Real => return true,   // Force proxy
215                    MigrationMode::Auto => {
216                        // Fall through to conditional evaluation
217                    }
218                }
219            }
220        }
221
222        // If there are rules, check if any rule matches with condition evaluation
223        if !self.rules.is_empty() {
224            if find_matching_rule(&self.rules, method, uri, headers, body, |pattern, path| {
225                self.path_matches_pattern(pattern, path)
226            })
227            .is_some()
228            {
229                return true;
230            }
231        }
232
233        // If no rules match, check prefix logic (only if no rules have conditions)
234        let has_conditional_rules = self.rules.iter().any(|r| r.enabled && r.condition.is_some());
235        if !has_conditional_rules {
236            match &self.prefix {
237                None => true, // No prefix means proxy everything
238                Some(prefix) => path.starts_with(prefix),
239            }
240        } else {
241            false // If we have conditional rules but none matched, don't proxy
242        }
243    }
244
245    /// Check if a route should use shadow mode (proxy + generate mock)
246    pub fn should_shadow(&self, path: &str) -> bool {
247        if !self.migration_enabled {
248            return false;
249        }
250
251        if let Some(mode) = self.get_effective_migration_mode(path) {
252            return mode == MigrationMode::Shadow;
253        }
254
255        false
256    }
257
258    /// Get the upstream URL for a specific path
259    pub fn get_upstream_url(&self, path: &str) -> String {
260        // Check rules first
261        for rule in &self.rules {
262            if rule.enabled && self.path_matches_pattern(&rule.path_pattern, path) {
263                return rule.target_url.clone();
264            }
265        }
266
267        // If no rule matches, use the default target URL
268        if let Some(base_url) = &self.target_url {
269            base_url.clone()
270        } else {
271            path.to_string()
272        }
273    }
274
275    /// Strip the proxy prefix from a path
276    pub fn strip_prefix(&self, path: &str) -> String {
277        match &self.prefix {
278            Some(prefix) => {
279                if path.starts_with(prefix) {
280                    let stripped = path.strip_prefix(prefix).unwrap_or(path);
281                    // Ensure the result starts with a slash
282                    if stripped.starts_with('/') {
283                        stripped.to_string()
284                    } else {
285                        format!("/{}", stripped)
286                    }
287                } else {
288                    path.to_string()
289                }
290            }
291            None => path.to_string(), // No prefix to strip
292        }
293    }
294
295    /// Check if a path matches a pattern (supports wildcards)
296    fn path_matches_pattern(&self, pattern: &str, path: &str) -> bool {
297        if let Some(prefix) = pattern.strip_suffix("/*") {
298            path.starts_with(prefix)
299        } else {
300            path == pattern
301        }
302    }
303
304    /// Update migration mode for a specific route pattern
305    /// Returns true if the rule was found and updated
306    pub fn update_rule_migration_mode(&mut self, pattern: &str, mode: MigrationMode) -> bool {
307        for rule in &mut self.rules {
308            if rule.path_pattern == pattern || rule.pattern == pattern {
309                rule.migration_mode = mode;
310                return true;
311            }
312        }
313        false
314    }
315
316    /// Update migration mode for an entire group
317    /// This affects all routes that belong to the group
318    pub fn update_group_migration_mode(&mut self, group: &str, mode: MigrationMode) {
319        self.migration_groups.insert(group.to_string(), mode);
320    }
321
322    /// Toggle a route's migration mode through the stages: mock → shadow → real → mock
323    /// Returns the new mode if the rule was found
324    pub fn toggle_route_migration(&mut self, pattern: &str) -> Option<MigrationMode> {
325        for rule in &mut self.rules {
326            if rule.path_pattern == pattern || rule.pattern == pattern {
327                rule.migration_mode = match rule.migration_mode {
328                    MigrationMode::Mock => MigrationMode::Shadow,
329                    MigrationMode::Shadow => MigrationMode::Real,
330                    MigrationMode::Real => MigrationMode::Mock,
331                    MigrationMode::Auto => MigrationMode::Mock, // Start migration from auto
332                };
333                return Some(rule.migration_mode);
334            }
335        }
336        None
337    }
338
339    /// Toggle a group's migration mode through the stages: mock → shadow → real → mock
340    /// Returns the new mode
341    pub fn toggle_group_migration(&mut self, group: &str) -> MigrationMode {
342        let current_mode = self.migration_groups.get(group).copied().unwrap_or(MigrationMode::Auto);
343        let new_mode = match current_mode {
344            MigrationMode::Mock => MigrationMode::Shadow,
345            MigrationMode::Shadow => MigrationMode::Real,
346            MigrationMode::Real => MigrationMode::Mock,
347            MigrationMode::Auto => MigrationMode::Mock, // Start migration from auto
348        };
349        self.migration_groups.insert(group.to_string(), new_mode);
350        new_mode
351    }
352
353    /// Get all routes with their migration status
354    pub fn get_migration_routes(&self) -> Vec<MigrationRouteInfo> {
355        self.rules
356            .iter()
357            .map(|rule| {
358                let effective_mode = if let Some(ref group) = rule.migration_group {
359                    self.migration_groups.get(group).copied().unwrap_or(rule.migration_mode)
360                } else {
361                    rule.migration_mode
362                };
363
364                MigrationRouteInfo {
365                    pattern: rule.path_pattern.clone(),
366                    upstream_url: rule.target_url.clone(),
367                    migration_mode: effective_mode,
368                    route_mode: rule.migration_mode,
369                    migration_group: rule.migration_group.clone(),
370                    enabled: rule.enabled,
371                }
372            })
373            .collect()
374    }
375
376    /// Get all migration groups with their status
377    pub fn get_migration_groups(&self) -> HashMap<String, MigrationGroupInfo> {
378        let mut group_info: HashMap<String, MigrationGroupInfo> = HashMap::new();
379
380        // Collect all groups from rules
381        for rule in &self.rules {
382            if let Some(ref group) = rule.migration_group {
383                let entry = group_info.entry(group.clone()).or_insert_with(|| MigrationGroupInfo {
384                    name: group.clone(),
385                    migration_mode: self
386                        .migration_groups
387                        .get(group)
388                        .copied()
389                        .unwrap_or(rule.migration_mode),
390                    route_count: 0,
391                });
392                entry.route_count += 1;
393            }
394        }
395
396        // Add groups that only exist in migration_groups (no routes yet)
397        for (group_name, &mode) in &self.migration_groups {
398            group_info.entry(group_name.clone()).or_insert_with(|| MigrationGroupInfo {
399                name: group_name.clone(),
400                migration_mode: mode,
401                route_count: 0,
402            });
403        }
404
405        group_info
406    }
407}
408
409/// Information about a route's migration status
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct MigrationRouteInfo {
412    /// Route pattern
413    pub pattern: String,
414    /// Upstream URL
415    pub upstream_url: String,
416    /// Effective migration mode (considering group overrides)
417    pub migration_mode: MigrationMode,
418    /// Route-specific migration mode
419    pub route_mode: MigrationMode,
420    /// Migration group this route belongs to (if any)
421    pub migration_group: Option<String>,
422    /// Whether the route is enabled
423    pub enabled: bool,
424}
425
426/// Information about a migration group
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct MigrationGroupInfo {
429    /// Group name
430    pub name: String,
431    /// Current migration mode for the group
432    pub migration_mode: MigrationMode,
433    /// Number of routes in this group
434    pub route_count: usize,
435}
436
437/// Body transformation rule for request/response replacement
438#[derive(Debug, Clone, Serialize, Deserialize)]
439#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
440pub struct BodyTransformRule {
441    /// URL pattern to match (supports wildcards like "/api/users/*")
442    pub pattern: String,
443    /// Optional status code filter for response rules (only applies to responses)
444    #[serde(default)]
445    pub status_codes: Vec<u16>,
446    /// Body transformations to apply
447    pub body_transforms: Vec<BodyTransform>,
448    /// Whether this rule is enabled
449    #[serde(default = "default_true")]
450    pub enabled: bool,
451}
452
453fn default_true() -> bool {
454    true
455}
456
457impl BodyTransformRule {
458    /// Check if this rule matches a URL
459    pub fn matches_url(&self, url: &str) -> bool {
460        if !self.enabled {
461            return false;
462        }
463
464        // Simple pattern matching - supports wildcards
465        if self.pattern.ends_with("/*") {
466            let prefix = &self.pattern[..self.pattern.len() - 2];
467            url.starts_with(prefix)
468        } else {
469            url == self.pattern || url.starts_with(&self.pattern)
470        }
471    }
472
473    /// Check if this rule matches a status code (for response rules)
474    pub fn matches_status_code(&self, status_code: u16) -> bool {
475        if self.status_codes.is_empty() {
476            true // No filter means match all
477        } else {
478            self.status_codes.contains(&status_code)
479        }
480    }
481}
482
483/// Individual body transformation
484#[derive(Debug, Clone, Serialize, Deserialize)]
485#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
486pub struct BodyTransform {
487    /// JSONPath expression to target (e.g., "$.userId", "$.email")
488    pub path: String,
489    /// Replacement value (supports template expansion like "{{uuid}}", "{{faker.email}}")
490    pub replace: String,
491    /// Operation to perform
492    #[serde(default)]
493    pub operation: TransformOperation,
494}
495
496/// Transform operation type
497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
498#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
499#[serde(rename_all = "lowercase")]
500pub enum TransformOperation {
501    /// Replace the value at the path
502    Replace,
503    /// Add a new field at the path
504    Add,
505    /// Remove the field at the path
506    Remove,
507}
508
509impl Default for TransformOperation {
510    fn default() -> Self {
511        Self::Replace
512    }
513}
514
515impl Default for ProxyConfig {
516    fn default() -> Self {
517        Self {
518            enabled: false,
519            target_url: None,
520            timeout_seconds: 30,
521            follow_redirects: true,
522            headers: HashMap::new(),
523            prefix: None,
524            passthrough_by_default: false,
525            rules: Vec::new(),
526            migration_enabled: false,
527            migration_groups: HashMap::new(),
528            request_replacements: Vec::new(),
529            response_replacements: Vec::new(),
530        }
531    }
532}