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