Skip to main content

apimock_routing/
rule_set.rs

1use serde::Deserialize;
2
3use std::{fs, path::Path};
4
5mod default_respond;
6mod guard;
7pub mod prefix;
8pub mod rule;
9
10use crate::{
11    error::{RoutingError, RoutingResult},
12    parsed_request::ParsedRequest,
13    strategy::Strategy,
14    util::http::normalize_url_path,
15};
16use default_respond::DefaultRespond;
17use guard::Guard;
18use prefix::Prefix;
19use rule::{Rule, respond::Respond};
20
21/// A named collection of routing rules, loaded from one TOML file.
22///
23/// # Why rule sets, not a single flat rule list
24///
25/// Large mock APIs tend to group related endpoints (e.g. all of `/api/v1`
26/// under one auth scheme). A rule set lets operators share a URL prefix
27/// and a respond-dir prefix across many rules, and to split their config
28/// across multiple files that can be enabled/disabled independently.
29/// Match order across sets is determined by the order in
30/// `service.rule_sets`, so the most specific set can be listed first.
31use std::sync::{
32    Arc,
33    atomic::{AtomicUsize, Ordering},
34};
35
36fn default_counter() -> Arc<AtomicUsize> {
37    Arc::new(AtomicUsize::new(0))
38}
39
40#[derive(Clone, Deserialize, Debug)]
41pub struct RuleSet {
42    pub prefix: Option<Prefix>,
43    pub default: Option<DefaultRespond>,
44    pub guard: Option<Guard>,
45    pub rules: Vec<Rule>,
46    /// Per-rule-set strategy override (RFC 025).
47    /// When `Some`, this strategy is used instead of the service-level one.
48    /// When `None`, the service-level strategy applies.
49    #[serde(default)]
50    pub strategy: Option<Strategy>,
51    #[serde(skip)]
52    pub file_path: String,
53    /// Per-rule-set round-robin counter. Shared across clones via `Arc`.
54    #[serde(skip, default = "default_counter")]
55    pub round_robin_counter: Arc<AtomicUsize>,
56}
57
58impl RuleSet {
59    /// Load a rule set from a TOML file on disk.
60    ///
61    /// # Why errors are typed and not panics
62    ///
63    /// In 4.6.x this used `expect` + `panic!`, so a missing or malformed
64    /// rule set aborted the process. Because rule sets are edited
65    /// frequently during development, those panics were a common papercut.
66    /// Now any failure becomes an `RoutingError::RuleSetRead` / `::RuleSetParse`
67    /// that the caller can surface cleanly.
68    // clippy: RoutingError::RuleSetParse is a public error type (RoutingResult
69    // is part of this crate's stable surface); boxing its large variant would
70    // change that type's shape, which RFC 030 §6 requires escalating rather
71    // than fixing inline. See ESCALATION-002 in the RFC 030 review-request
72    // package for the design request this raises.
73    #[allow(clippy::result_large_err)]
74    pub fn new(
75        rule_set_file_path: &str,
76        current_dir_to_config_dir_relative_path: &str,
77        rule_set_idx: usize,
78    ) -> RoutingResult<Self> {
79        let path = Path::new(rule_set_file_path);
80        let toml_string =
81            fs::read_to_string(rule_set_file_path).map_err(|e| RoutingError::RuleSetRead {
82                path: path.to_path_buf(),
83                source: e,
84            })?;
85
86        let mut ret: Self =
87            toml::from_str(&toml_string).map_err(|e| RoutingError::RuleSetParse {
88                path: path.to_path_buf(),
89                canonical: path.canonicalize().ok(),
90                source: e,
91            })?;
92
93        // - prefix: fill in defaults and normalize
94        let mut prefix = ret.prefix.clone().unwrap_or_default();
95
96        // normalize `url_path` so later matching doesn't have to deal with
97        // leading/trailing slash variations
98        prefix.url_path_prefix = prefix
99            .url_path_prefix
100            .as_deref()
101            .map(|p| normalize_url_path(p, None));
102
103        // respond_dir prefix: default to "." and anchor it under the
104        // config-file directory so relative paths in rule sets are
105        // relative to the rule-set file, not the working directory
106        let respond_dir_prefix = prefix.respond_dir_prefix.as_deref().unwrap_or(".");
107
108        let respond_dir_prefix =
109            Path::new(current_dir_to_config_dir_relative_path).join(respond_dir_prefix);
110        let respond_dir_prefix = respond_dir_prefix.to_str().ok_or_else(|| {
111            RoutingError::RuleSetRead {
112                path: path.to_path_buf(),
113                // We synthesize an io::Error here only because the variant
114                // needs one; the real failure is "path contains non-UTF-8
115                // bytes", which is vanishingly rare but not impossible on
116                // Unix. Using `InvalidData` keeps it distinguishable.
117                source: std::io::Error::new(
118                    std::io::ErrorKind::InvalidData,
119                    format!(
120                        "respond_dir path contains non-UTF-8 bytes: {}",
121                        respond_dir_prefix.to_string_lossy()
122                    ),
123                ),
124            }
125        })?;
126
127        prefix.respond_dir_prefix = Some(respond_dir_prefix.to_owned());
128        ret.prefix = Some(prefix);
129
130        // - rules: compute any derived fields (normalized URL path with
131        //   prefix already applied, resolved status code, etc.) so the
132        //   request-time hot path doesn't have to repeat the work
133        ret.rules = ret
134            .rules
135            .iter()
136            .enumerate()
137            .map(|(rule_idx, rule)| rule.compute_derived_fields(&ret, rule_idx, rule_set_idx))
138            .collect();
139
140        // - file path (kept for log/display only)
141        ret.file_path = rule_set_file_path.to_owned();
142        // - round-robin counter (starts at 0; shared across clones via Arc)
143        ret.round_robin_counter = Arc::new(AtomicUsize::new(0));
144
145        Ok(ret)
146    }
147
148    /// find rule matching request and return its respond content
149    pub fn find_matched(
150        &self,
151        parsed_request: &ParsedRequest,
152        strategy: Option<&Strategy>,
153        rule_set_idx: usize,
154    ) -> Option<Respond> {
155        match self.prefix.as_ref() {
156            Some(prefix)
157                if prefix.url_path_prefix.is_some()
158                    && !parsed_request
159                        .url_path
160                        .starts_with(prefix.url_path_prefix.as_ref().unwrap()) =>
161            {
162                return None;
163            }
164            _ => (),
165        }
166
167        // RFC 025: per-rule-set strategy override.
168        // The rule set's own strategy takes precedence over the service-level one.
169        let effective_strategy = self
170            .strategy
171            .as_ref()
172            .or(strategy)
173            .unwrap_or(&Strategy::FirstMatch);
174        let strategy = effective_strategy;
175
176        match strategy {
177            Strategy::FirstMatch => {
178                for (rule_idx, rule) in self.rules.iter().enumerate() {
179                    if rule.when.is_match(parsed_request, rule_idx, rule_set_idx) {
180                        return Some(rule.respond.clone());
181                    }
182                }
183                None
184            }
185
186            Strategy::UniformRandom { seed } => {
187                // Collect all matching rules, then pick uniformly at random.
188                let matches: Vec<&Rule> = self
189                    .rules
190                    .iter()
191                    .enumerate()
192                    .filter(|(idx, r)| r.when.is_match(parsed_request, *idx, rule_set_idx))
193                    .map(|(_, r)| r)
194                    .collect();
195
196                if matches.is_empty() {
197                    return None;
198                }
199                let mut rng = crate::strategy::make_rng(*seed);
200                let idx = rng.next_index(matches.len());
201                Some(matches[idx].respond.clone())
202            }
203
204            Strategy::WeightedRandom { seed } => {
205                // Collect matching rules with their effective weights.
206                let candidates: Vec<(&Rule, u32)> = self
207                    .rules
208                    .iter()
209                    .enumerate()
210                    .filter(|(idx, r)| r.when.is_match(parsed_request, *idx, rule_set_idx))
211                    .map(|(_, r)| (r, r.weight.unwrap_or(1)))
212                    .filter(|(_, w)| *w > 0)
213                    .collect();
214
215                if candidates.is_empty() {
216                    return None;
217                }
218
219                let total: u32 = candidates.iter().map(|(_, w)| w).sum();
220                let mut rng = crate::strategy::make_rng(*seed);
221                let pick = (rng.next() % total as u64) as u32;
222                let mut acc = 0u32;
223                for (rule, weight) in &candidates {
224                    acc += weight;
225                    if pick < acc {
226                        return Some(rule.respond.clone());
227                    }
228                }
229                // Fallback (rounding edge): return last candidate.
230                candidates.last().map(|(r, _)| r.respond.clone())
231            }
232
233            Strategy::Priority { tiebreaker } => {
234                // Collect matching rules with their priority.
235                let matches: Vec<(&Rule, i32)> = self
236                    .rules
237                    .iter()
238                    .enumerate()
239                    .filter(|(idx, r)| r.when.is_match(parsed_request, *idx, rule_set_idx))
240                    .map(|(_, r)| (r, r.priority.unwrap_or(0)))
241                    .collect();
242
243                if matches.is_empty() {
244                    return None;
245                }
246
247                let max_priority = matches.iter().map(|(_, p)| *p).max().unwrap();
248                let top: Vec<&Rule> = matches
249                    .into_iter()
250                    .filter(|(_, p)| *p == max_priority)
251                    .map(|(r, _)| r)
252                    .collect();
253
254                match tiebreaker {
255                    crate::strategy::PriorityTiebreaker::FirstMatch => {
256                        top.into_iter().next().map(|r| r.respond.clone())
257                    }
258                    crate::strategy::PriorityTiebreaker::UniformRandom => {
259                        let mut rng = crate::strategy::make_rng(None);
260                        let idx = rng.next_index(top.len());
261                        Some(top[idx].respond.clone())
262                    }
263                }
264            }
265
266            Strategy::RoundRobin => {
267                let matches: Vec<&Rule> = self
268                    .rules
269                    .iter()
270                    .enumerate()
271                    .filter(|(idx, r)| r.when.is_match(parsed_request, *idx, rule_set_idx))
272                    .map(|(_, r)| r)
273                    .collect();
274
275                if matches.is_empty() {
276                    return None;
277                }
278
279                // Relaxed ordering: atomicity without sequential consistency
280                // is sufficient for a mock server (slight counter reorder
281                // on concurrent requests is acceptable).
282                let idx = self.round_robin_counter.fetch_add(1, Ordering::Relaxed) % matches.len();
283
284                Some(matches[idx].respond.clone())
285            }
286        }
287    }
288
289    /// validate
290    pub fn validate(&self) -> bool {
291        true
292    }
293
294    /// dir_prefix as string possibly as empty
295    pub fn dir_prefix(&self) -> String {
296        self.prefix
297            .clone()
298            .unwrap_or_default()
299            .respond_dir_prefix
300            .unwrap_or_default()
301    }
302}
303
304impl std::fmt::Display for RuleSet {
305    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        if let Some(x) = self.prefix.as_ref() {
307            let _ = write!(f, "{}", x);
308        }
309        if let Some(x) = self.guard.as_ref() {
310            let _ = write!(f, "{}", x);
311        }
312        if let Some(x) = self.default.as_ref() {
313            let _ = write!(f, "{}", x);
314        }
315        for rule in self.rules.iter() {
316            let _ = write!(f, "{}", rule);
317        }
318        Ok(())
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::parsed_request::ParsedRequest;
326    use crate::rule_set::rule::{
327        Rule,
328        respond::Respond,
329        when::{When, request::Request},
330    };
331    use crate::strategy::Strategy;
332
333    /// Build a minimal `ParsedRequest` matching `url_path`.
334    fn get_req(url_path: &str) -> ParsedRequest {
335        let req = hyper::Request::builder()
336            .method("GET")
337            .uri(url_path)
338            .body(())
339            .unwrap();
340        let (parts, _) = req.into_parts();
341        ParsedRequest {
342            url_path: url_path.to_owned(),
343            component_parts: parts,
344            body_json: None,
345        }
346    }
347
348    /// Build a `RuleSet` with `n` rules, all matching `url_path`,
349    /// responding with `"response_0"`, `"response_1"`, …
350    fn make_round_robin_set(n: usize, url_path: &str) -> RuleSet {
351        use crate::rule_set::rule::when::request::url_path::{UrlPath, UrlPathConfig};
352
353        let rules = (0..n)
354            .map(|i| Rule {
355                when: When {
356                    request: Request {
357                        url_path_config: Some(UrlPathConfig::Simple(url_path.to_owned())),
358                        url_path: Some(UrlPath {
359                            value: url_path.to_owned(),
360                            value_with_prefix: url_path.to_owned(),
361                            op: None,
362                        }),
363                        http_method: None,
364                        headers: None,
365                        body: None,
366                    },
367                },
368                respond: Respond {
369                    text: Some(format!("response_{}", i)),
370                    file_path: None,
371                    csv_records_key: None,
372                    status: None,
373                    status_code: None,
374                    headers: None,
375                    delay_response_milliseconds: None,
376                },
377                weight: None,
378                priority: None,
379            })
380            .collect();
381
382        RuleSet {
383            prefix: None,
384            default: None,
385            guard: None,
386            rules,
387            strategy: None,
388            file_path: String::new(),
389            round_robin_counter: Arc::new(AtomicUsize::new(0)),
390        }
391    }
392
393    #[test]
394    fn round_robin_cycles_through_matching_rules() {
395        let rs = make_round_robin_set(2, "/api");
396        let req = get_req("/api");
397        let strategy = Strategy::RoundRobin;
398
399        let r0 = rs.find_matched(&req, Some(&strategy), 0).expect("match 0");
400        let r1 = rs.find_matched(&req, Some(&strategy), 0).expect("match 1");
401        let r2 = rs.find_matched(&req, Some(&strategy), 0).expect("match 2");
402
403        assert_eq!(r0.text.as_deref(), Some("response_0"));
404        assert_eq!(r1.text.as_deref(), Some("response_1"));
405        assert_eq!(r2.text.as_deref(), Some("response_0"), "cycle back");
406    }
407
408    #[test]
409    fn round_robin_three_rules_full_cycle() {
410        let rs = make_round_robin_set(3, "/api");
411        let req = get_req("/api");
412        let strategy = Strategy::RoundRobin;
413
414        let texts: Vec<String> = (0..6)
415            .map(|_| {
416                rs.find_matched(&req, Some(&strategy), 0)
417                    .unwrap()
418                    .text
419                    .clone()
420                    .unwrap()
421            })
422            .collect();
423
424        assert_eq!(
425            texts,
426            vec![
427                "response_0",
428                "response_1",
429                "response_2",
430                "response_0",
431                "response_1",
432                "response_2",
433            ]
434        );
435    }
436
437    #[test]
438    fn round_robin_no_match_does_not_advance_counter() {
439        let rs = make_round_robin_set(2, "/api");
440        let strategy = Strategy::RoundRobin;
441
442        // Non-matching request must not advance counter.
443        let miss = rs.find_matched(&get_req("/other"), Some(&strategy), 0);
444        assert!(miss.is_none(), "non-matching path should miss");
445
446        // Counter at 0 still — first hit returns response_0.
447        let hit = rs
448            .find_matched(&get_req("/api"), Some(&strategy), 0)
449            .expect("should match");
450        assert_eq!(hit.text.as_deref(), Some("response_0"));
451    }
452
453    #[test]
454    fn round_robin_single_match_always_same() {
455        let rs = make_round_robin_set(1, "/api");
456        let req = get_req("/api");
457        let strategy = Strategy::RoundRobin;
458
459        for _ in 0..5 {
460            let r = rs.find_matched(&req, Some(&strategy), 0).expect("match");
461            assert_eq!(r.text.as_deref(), Some("response_0"));
462        }
463    }
464}