Skip to main content

har/
filter.rs

1use crate::glob::glob_match;
2use crate::model::Entry;
3
4#[derive(Debug)]
5enum Cmp {
6    Ge,
7    Le,
8    Gt,
9    Lt,
10    Eq,
11}
12
13#[derive(Debug)]
14enum Clause {
15    Host(String),
16    Method(String),
17    Path(String),
18    Status(Cmp, i64),
19    Time(Cmp, f64),
20    Has(String),
21}
22
23#[derive(Debug)]
24pub struct Filter {
25    clauses: Vec<Clause>,
26}
27
28impl Filter {
29    /// Parse clauses like `host:api.foo.com status:>=400 path:*login* time:>5ms`.
30    pub fn parse(exprs: &[String]) -> Result<Filter, String> {
31        let mut clauses = Vec::new();
32        for raw in exprs {
33            for token in raw.split_whitespace() {
34                clauses.push(parse_clause(token)?);
35            }
36        }
37        Ok(Filter { clauses })
38    }
39
40    pub fn matches(&self, e: &Entry) -> bool {
41        self.clauses.iter().all(|c| clause_matches(c, e))
42    }
43}
44
45fn parse_clause(token: &str) -> Result<Clause, String> {
46    let (key, val) = token
47        .split_once(':')
48        .ok_or_else(|| format!("invalid filter clause: {token}"))?;
49    match key {
50        "host" => Ok(Clause::Host(val.to_string())),
51        "method" => Ok(Clause::Method(val.to_ascii_uppercase())),
52        "path" => Ok(Clause::Path(val.to_string())),
53        "status" => {
54            let (cmp, n) = parse_cmp_int(val)?;
55            Ok(Clause::Status(cmp, n))
56        }
57        "time" => {
58            let v = val.trim_end_matches("ms");
59            let (cmp, n) = parse_cmp_float(v)?;
60            Ok(Clause::Time(cmp, n))
61        }
62        "has" => Ok(Clause::Has(val.to_ascii_lowercase())),
63        other => Err(format!("unknown filter key: {other}")),
64    }
65}
66
67fn parse_cmp_int(s: &str) -> Result<(Cmp, i64), String> {
68    let (cmp, rest) = split_cmp(s);
69    let n = rest
70        .parse::<i64>()
71        .map_err(|_| format!("invalid number: {rest}"))?;
72    Ok((cmp, n))
73}
74
75fn parse_cmp_float(s: &str) -> Result<(Cmp, f64), String> {
76    let (cmp, rest) = split_cmp(s);
77    let n = rest
78        .parse::<f64>()
79        .map_err(|_| format!("invalid number: {rest}"))?;
80    Ok((cmp, n))
81}
82
83fn split_cmp(s: &str) -> (Cmp, &str) {
84    if let Some(rest) = s.strip_prefix(">=") {
85        (Cmp::Ge, rest)
86    } else if let Some(rest) = s.strip_prefix("<=") {
87        (Cmp::Le, rest)
88    } else if let Some(rest) = s.strip_prefix('>') {
89        (Cmp::Gt, rest)
90    } else if let Some(rest) = s.strip_prefix('<') {
91        (Cmp::Lt, rest)
92    } else if let Some(rest) = s.strip_prefix('=') {
93        (Cmp::Eq, rest)
94    } else {
95        (Cmp::Eq, s)
96    }
97}
98
99fn cmp_i(cmp: &Cmp, a: i64, b: i64) -> bool {
100    match cmp {
101        Cmp::Ge => a >= b,
102        Cmp::Le => a <= b,
103        Cmp::Gt => a > b,
104        Cmp::Lt => a < b,
105        Cmp::Eq => a == b,
106    }
107}
108
109fn cmp_f(cmp: &Cmp, a: f64, b: f64) -> bool {
110    match cmp {
111        Cmp::Ge => a >= b,
112        Cmp::Le => a <= b,
113        Cmp::Gt => a > b,
114        Cmp::Lt => a < b,
115        Cmp::Eq => a == b,
116    }
117}
118
119fn clause_matches(c: &Clause, e: &Entry) -> bool {
120    match c {
121        Clause::Host(h) => glob_match(h, &e.host),
122        Clause::Method(m) => e.method.eq_ignore_ascii_case(m),
123        Clause::Path(p) => glob_match(p, &e.path),
124        Clause::Status(cmp, n) => cmp_i(cmp, e.status, *n),
125        Clause::Time(cmp, n) => cmp_f(cmp, e.duration_ms, *n),
126        Clause::Has(field) => has_field(field, e),
127    }
128}
129
130fn has_field(field: &str, e: &Entry) -> bool {
131    // Supported forms: req.header.<name>, resp.header.<name>, req.body, resp.body
132    if let Some(name) = field.strip_prefix("req.header.") {
133        return e
134            .req_headers
135            .iter()
136            .any(|(n, _)| n.eq_ignore_ascii_case(name));
137    }
138    if let Some(name) = field.strip_prefix("resp.header.") {
139        return e
140            .resp_headers
141            .iter()
142            .any(|(n, _)| n.eq_ignore_ascii_case(name));
143    }
144    match field {
145        "req.body" => e.req_body.as_ref().is_some_and(|b| !b.is_empty()),
146        "resp.body" => e.resp_body.as_ref().is_some_and(|b| !b.is_empty()),
147        _ => false,
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::Filter;
154    use crate::classify::ResourceType;
155    use crate::model::{Entry, Phases, Sizes};
156
157    fn entry(host: &str, status: i64, method: &str, path: &str, dur: f64) -> Entry {
158        Entry {
159            id: "e000000".into(),
160            index: 0,
161            started_offset_ms: 0.0,
162            duration_ms: dur,
163            method: method.into(),
164            url: format!("https://{host}{path}"),
165            host: host.into(),
166            path: path.into(),
167            norm_path: path.into(),
168            query: vec![],
169            status,
170            status_text: String::new(),
171            resource_type: ResourceType::Api,
172            content_type: None,
173            req_headers: vec![("authorization".into(), "x".into())],
174            resp_headers: vec![],
175            req_body: None,
176            resp_body: None,
177            timings: Phases::default(),
178            sizes: Sizes::default(),
179            server_ip: None,
180            http_version: "HTTP/2".into(),
181            redirect_url: None,
182            correlation: vec![],
183        }
184    }
185
186    #[test]
187    fn matches_host_and_status() {
188        let f = Filter::parse(&["host:api.foo.com".into(), "status:>=400".into()]).unwrap();
189        assert!(f.matches(&entry("api.foo.com", 500, "GET", "/x", 10.0)));
190        assert!(!f.matches(&entry("api.foo.com", 200, "GET", "/x", 10.0)));
191        assert!(!f.matches(&entry("other.com", 500, "GET", "/x", 10.0)));
192    }
193
194    #[test]
195    fn matches_method_and_path_glob_and_time() {
196        let f = Filter::parse(&[
197            "method:POST".into(),
198            "path:*login*".into(),
199            "time:>5ms".into(),
200        ])
201        .unwrap();
202        assert!(f.matches(&entry("h", 200, "POST", "/v1/login/start", 10.0)));
203        assert!(!f.matches(&entry("h", 200, "POST", "/v1/login/start", 1.0)));
204        assert!(!f.matches(&entry("h", 200, "GET", "/v1/login/start", 10.0)));
205    }
206
207    #[test]
208    fn matches_has_header() {
209        let f = Filter::parse(&["has:req.header.authorization".into()]).unwrap();
210        assert!(f.matches(&entry("h", 200, "GET", "/x", 1.0)));
211    }
212
213    #[test]
214    fn empty_filter_matches_all() {
215        let f = Filter::parse(&[]).unwrap();
216        assert!(f.matches(&entry("h", 200, "GET", "/x", 1.0)));
217    }
218}