Skip to main content

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