Skip to main content

apimock_routing/
rule_set.rs

1use serde::Deserialize;
2
3use std::{fs, path::Path};
4
5mod default_respond;
6mod guard;
7mod 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.
31#[derive(Clone, Deserialize, Debug)]
32pub struct RuleSet {
33    pub prefix: Option<Prefix>,
34    pub default: Option<DefaultRespond>,
35    pub guard: Option<Guard>,
36    pub rules: Vec<Rule>,
37    #[serde(skip)]
38    pub file_path: String,
39}
40
41impl RuleSet {
42    /// Load a rule set from a TOML file on disk.
43    ///
44    /// # Why errors are typed and not panics
45    ///
46    /// In 4.6.x this used `expect` + `panic!`, so a missing or malformed
47    /// rule set aborted the process. Because rule sets are edited
48    /// frequently during development, those panics were a common papercut.
49    /// Now any failure becomes an `RoutingError::RuleSetRead` / `::RuleSetParse`
50    /// that the caller can surface cleanly.
51    pub fn new(
52        rule_set_file_path: &str,
53        current_dir_to_config_dir_relative_path: &str,
54        rule_set_idx: usize,
55    ) -> RoutingResult<Self> {
56        let path = Path::new(rule_set_file_path);
57        let toml_string = fs::read_to_string(rule_set_file_path).map_err(|e| {
58            RoutingError::RuleSetRead {
59                path: path.to_path_buf(),
60                source: e,
61            }
62        })?;
63
64        let mut ret: Self = toml::from_str(&toml_string).map_err(|e| RoutingError::RuleSetParse {
65            path: path.to_path_buf(),
66            canonical: path.canonicalize().ok(),
67            source: e,
68        })?;
69
70        // - prefix: fill in defaults and normalize
71        let mut prefix = ret.prefix.clone().unwrap_or_default();
72
73        // normalize `url_path` so later matching doesn't have to deal with
74        // leading/trailing slash variations
75        prefix.url_path_prefix = prefix
76            .url_path_prefix
77            .as_deref()
78            .map(|p| normalize_url_path(p, None));
79
80        // respond_dir prefix: default to "." and anchor it under the
81        // config-file directory so relative paths in rule sets are
82        // relative to the rule-set file, not the working directory
83        let respond_dir_prefix = prefix.respond_dir_prefix.as_deref().unwrap_or(".");
84
85        let respond_dir_prefix =
86            Path::new(current_dir_to_config_dir_relative_path).join(respond_dir_prefix);
87        let respond_dir_prefix = respond_dir_prefix.to_str().ok_or_else(|| {
88            RoutingError::RuleSetRead {
89                path: path.to_path_buf(),
90                // We synthesize an io::Error here only because the variant
91                // needs one; the real failure is "path contains non-UTF-8
92                // bytes", which is vanishingly rare but not impossible on
93                // Unix. Using `InvalidData` keeps it distinguishable.
94                source: std::io::Error::new(
95                    std::io::ErrorKind::InvalidData,
96                    format!(
97                        "respond_dir path contains non-UTF-8 bytes: {}",
98                        respond_dir_prefix.to_string_lossy()
99                    ),
100                ),
101            }
102        })?;
103
104        prefix.respond_dir_prefix = Some(respond_dir_prefix.to_owned());
105        ret.prefix = Some(prefix);
106
107        // - rules: compute any derived fields (normalized URL path with
108        //   prefix already applied, resolved status code, etc.) so the
109        //   request-time hot path doesn't have to repeat the work
110        ret.rules = ret
111            .rules
112            .iter()
113            .enumerate()
114            .map(|(rule_idx, rule)| rule.compute_derived_fields(&ret, rule_idx, rule_set_idx))
115            .collect();
116
117        // - file path (kept for log/display only)
118        ret.file_path = rule_set_file_path.to_owned();
119
120        Ok(ret)
121    }
122
123    /// find rule matching request and return its respond content
124    pub fn find_matched(
125        &self,
126        parsed_request: &ParsedRequest,
127        strategy: Option<&Strategy>,
128        rule_set_idx: usize,
129    ) -> Option<Respond> {
130        let _ = match self.prefix.as_ref() {
131            Some(prefix) if prefix.url_path_prefix.is_some() => {
132                if !parsed_request
133                    .url_path
134                    .starts_with(prefix.url_path_prefix.as_ref().unwrap())
135                {
136                    return None;
137                }
138            }
139            _ => (),
140        };
141
142        for (rule_idx, rule) in self.rules.iter().enumerate() {
143            let is_match = rule.when.is_match(parsed_request, rule_idx, rule_set_idx);
144            if is_match {
145                // todo: last match in the future ?
146                match strategy {
147                    Some(&Strategy::FirstMatch) | None => return Some(rule.respond.to_owned()),
148                }
149            }
150        }
151
152        None
153    }
154
155    /// validate
156    pub fn validate(&self) -> bool {
157        true
158    }
159
160    /// dir_prefix as string possibly as empty
161    pub fn dir_prefix(&self) -> String {
162        if let Some(dir_prefix) = self.prefix.clone().unwrap_or_default().respond_dir_prefix {
163            dir_prefix
164        } else {
165            String::new()
166        }
167    }
168}
169
170impl std::fmt::Display for RuleSet {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        if let Some(x) = self.prefix.as_ref() {
173            let _ = write!(f, "{}", x);
174        }
175        if let Some(x) = self.guard.as_ref() {
176            let _ = write!(f, "{}", x);
177        }
178        if let Some(x) = self.default.as_ref() {
179            let _ = write!(f, "{}", x);
180        }
181        for rule in self.rules.iter() {
182            let _ = write!(f, "{}", rule);
183        }
184        Ok(())
185    }
186}