Skip to main content

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