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            && 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        // If no rules match, check prefix logic (only if no rules have conditions)
233        let has_conditional_rules = self.rules.iter().any(|r| r.enabled && r.condition.is_some());
234        if !has_conditional_rules {
235            match &self.prefix {
236                None => true, // No prefix means proxy everything
237                Some(prefix) => path.starts_with(prefix),
238            }
239        } else {
240            false // If we have conditional rules but none matched, don't proxy
241        }
242    }
243
244    /// Check if a route should use shadow mode (proxy + generate mock)
245    pub fn should_shadow(&self, path: &str) -> bool {
246        if !self.migration_enabled {
247            return false;
248        }
249
250        if let Some(mode) = self.get_effective_migration_mode(path) {
251            return mode == MigrationMode::Shadow;
252        }
253
254        false
255    }
256
257    /// Get the upstream URL for a specific path
258    pub fn get_upstream_url(&self, path: &str) -> String {
259        // Check rules first
260        for rule in &self.rules {
261            if rule.enabled && self.path_matches_pattern(&rule.path_pattern, path) {
262                return rule.target_url.clone();
263            }
264        }
265
266        // If no rule matches, use the default target URL
267        if let Some(base_url) = &self.target_url {
268            base_url.clone()
269        } else {
270            path.to_string()
271        }
272    }
273
274    /// Strip the proxy prefix from a path
275    pub fn strip_prefix(&self, path: &str) -> String {
276        match &self.prefix {
277            Some(prefix) => {
278                if path.starts_with(prefix) {
279                    let stripped = path.strip_prefix(prefix).unwrap_or(path);
280                    // Ensure the result starts with a slash
281                    if stripped.starts_with('/') {
282                        stripped.to_string()
283                    } else {
284                        format!("/{}", stripped)
285                    }
286                } else {
287                    path.to_string()
288                }
289            }
290            None => path.to_string(), // No prefix to strip
291        }
292    }
293
294    /// Check if a path matches a pattern (supports wildcards)
295    fn path_matches_pattern(&self, pattern: &str, path: &str) -> bool {
296        if let Some(prefix) = pattern.strip_suffix("/*") {
297            path.starts_with(prefix)
298        } else {
299            path == pattern
300        }
301    }
302
303    /// Update migration mode for a specific route pattern
304    /// Returns true if the rule was found and updated
305    pub fn update_rule_migration_mode(&mut self, pattern: &str, mode: MigrationMode) -> bool {
306        for rule in &mut self.rules {
307            if rule.path_pattern == pattern || rule.pattern == pattern {
308                rule.migration_mode = mode;
309                return true;
310            }
311        }
312        false
313    }
314
315    /// Update migration mode for an entire group
316    /// This affects all routes that belong to the group
317    pub fn update_group_migration_mode(&mut self, group: &str, mode: MigrationMode) {
318        self.migration_groups.insert(group.to_string(), mode);
319    }
320
321    /// Toggle a route's migration mode through the stages: mock → shadow → real → mock
322    /// Returns the new mode if the rule was found
323    pub fn toggle_route_migration(&mut self, pattern: &str) -> Option<MigrationMode> {
324        for rule in &mut self.rules {
325            if rule.path_pattern == pattern || rule.pattern == pattern {
326                rule.migration_mode = match rule.migration_mode {
327                    MigrationMode::Mock => MigrationMode::Shadow,
328                    MigrationMode::Shadow => MigrationMode::Real,
329                    MigrationMode::Real => MigrationMode::Mock,
330                    MigrationMode::Auto => MigrationMode::Mock, // Start migration from auto
331                };
332                return Some(rule.migration_mode);
333            }
334        }
335        None
336    }
337
338    /// Toggle a group's migration mode through the stages: mock → shadow → real → mock
339    /// Returns the new mode
340    pub fn toggle_group_migration(&mut self, group: &str) -> MigrationMode {
341        let current_mode = self.migration_groups.get(group).copied().unwrap_or(MigrationMode::Auto);
342        let new_mode = match current_mode {
343            MigrationMode::Mock => MigrationMode::Shadow,
344            MigrationMode::Shadow => MigrationMode::Real,
345            MigrationMode::Real => MigrationMode::Mock,
346            MigrationMode::Auto => MigrationMode::Mock, // Start migration from auto
347        };
348        self.migration_groups.insert(group.to_string(), new_mode);
349        new_mode
350    }
351
352    /// Get all routes with their migration status
353    pub fn get_migration_routes(&self) -> Vec<MigrationRouteInfo> {
354        self.rules
355            .iter()
356            .map(|rule| {
357                let effective_mode = if let Some(ref group) = rule.migration_group {
358                    self.migration_groups.get(group).copied().unwrap_or(rule.migration_mode)
359                } else {
360                    rule.migration_mode
361                };
362
363                MigrationRouteInfo {
364                    pattern: rule.path_pattern.clone(),
365                    upstream_url: rule.target_url.clone(),
366                    migration_mode: effective_mode,
367                    route_mode: rule.migration_mode,
368                    migration_group: rule.migration_group.clone(),
369                    enabled: rule.enabled,
370                }
371            })
372            .collect()
373    }
374
375    /// Get all migration groups with their status
376    pub fn get_migration_groups(&self) -> HashMap<String, MigrationGroupInfo> {
377        let mut group_info: HashMap<String, MigrationGroupInfo> = HashMap::new();
378
379        // Collect all groups from rules
380        for rule in &self.rules {
381            if let Some(ref group) = rule.migration_group {
382                let entry = group_info.entry(group.clone()).or_insert_with(|| MigrationGroupInfo {
383                    name: group.clone(),
384                    migration_mode: self
385                        .migration_groups
386                        .get(group)
387                        .copied()
388                        .unwrap_or(rule.migration_mode),
389                    route_count: 0,
390                });
391                entry.route_count += 1;
392            }
393        }
394
395        // Add groups that only exist in migration_groups (no routes yet)
396        for (group_name, &mode) in &self.migration_groups {
397            group_info.entry(group_name.clone()).or_insert_with(|| MigrationGroupInfo {
398                name: group_name.clone(),
399                migration_mode: mode,
400                route_count: 0,
401            });
402        }
403
404        group_info
405    }
406}
407
408/// Information about a route's migration status
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct MigrationRouteInfo {
411    /// Route pattern
412    pub pattern: String,
413    /// Upstream URL
414    pub upstream_url: String,
415    /// Effective migration mode (considering group overrides)
416    pub migration_mode: MigrationMode,
417    /// Route-specific migration mode
418    pub route_mode: MigrationMode,
419    /// Migration group this route belongs to (if any)
420    pub migration_group: Option<String>,
421    /// Whether the route is enabled
422    pub enabled: bool,
423}
424
425/// Information about a migration group
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct MigrationGroupInfo {
428    /// Group name
429    pub name: String,
430    /// Current migration mode for the group
431    pub migration_mode: MigrationMode,
432    /// Number of routes in this group
433    pub route_count: usize,
434}
435
436/// Body transformation rule for request/response replacement
437#[derive(Debug, Clone, Serialize, Deserialize)]
438#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
439pub struct BodyTransformRule {
440    /// URL pattern to match (supports wildcards like "/api/users/*")
441    pub pattern: String,
442    /// Optional status code filter for response rules (only applies to responses)
443    #[serde(default)]
444    pub status_codes: Vec<u16>,
445    /// Body transformations to apply
446    pub body_transforms: Vec<BodyTransform>,
447    /// Whether this rule is enabled
448    #[serde(default = "default_true")]
449    pub enabled: bool,
450}
451
452fn default_true() -> bool {
453    true
454}
455
456impl BodyTransformRule {
457    /// Check if this rule matches a URL
458    pub fn matches_url(&self, url: &str) -> bool {
459        if !self.enabled {
460            return false;
461        }
462
463        // Simple pattern matching - supports wildcards
464        if self.pattern.ends_with("/*") {
465            let prefix = &self.pattern[..self.pattern.len() - 2];
466            url.starts_with(prefix)
467        } else {
468            url == self.pattern || url.starts_with(&self.pattern)
469        }
470    }
471
472    /// Check if this rule matches a status code (for response rules)
473    pub fn matches_status_code(&self, status_code: u16) -> bool {
474        if self.status_codes.is_empty() {
475            true // No filter means match all
476        } else {
477            self.status_codes.contains(&status_code)
478        }
479    }
480}
481
482/// Individual body transformation
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
485pub struct BodyTransform {
486    /// JSONPath expression to target (e.g., "$.userId", "$.email")
487    pub path: String,
488    /// Replacement value (supports template expansion like "{{uuid}}", "{{faker.email}}")
489    pub replace: String,
490    /// Operation to perform
491    #[serde(default)]
492    pub operation: TransformOperation,
493}
494
495/// Transform operation type
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
497#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
498#[serde(rename_all = "lowercase")]
499pub enum TransformOperation {
500    /// Replace the value at the path
501    Replace,
502    /// Add a new field at the path
503    Add,
504    /// Remove the field at the path
505    Remove,
506}
507
508impl Default for TransformOperation {
509    fn default() -> Self {
510        Self::Replace
511    }
512}
513
514impl Default for ProxyConfig {
515    fn default() -> Self {
516        Self {
517            enabled: false,
518            target_url: None,
519            timeout_seconds: 30,
520            follow_redirects: true,
521            headers: HashMap::new(),
522            prefix: None,
523            passthrough_by_default: false,
524            rules: Vec::new(),
525            migration_enabled: false,
526            migration_groups: HashMap::new(),
527            request_replacements: Vec::new(),
528            response_replacements: Vec::new(),
529        }
530    }
531}