Skip to main content

har/analysis/
endpoints.rs

1use crate::filter::Filter;
2use crate::model::{Capture, Entry};
3use ahash::{AHashMap, AHashSet};
4use serde::Serialize;
5use std::collections::BTreeMap;
6
7#[derive(Debug, Serialize)]
8pub struct EndpointsResult {
9    pub endpoints: Vec<EndpointStat>,
10}
11
12#[derive(Debug, Serialize)]
13pub struct EndpointStat {
14    pub host: String,
15    pub method: String,
16    pub norm_path: String,
17    pub count: usize,
18    pub statuses: BTreeMap<String, usize>,
19    pub content_types: Vec<String>,
20    pub sample_query_keys: Vec<String>,
21    pub error_count: usize,
22}
23
24struct Acc<'a> {
25    entries: Vec<&'a Entry>,
26}
27
28/// Build a normalized endpoint inventory, keyed by (method, host, norm_path).
29pub fn compute_endpoints(cap: &Capture, filter: &Filter, top: usize) -> EndpointsResult {
30    let mut by_key: AHashMap<(String, String, String), Acc> = AHashMap::new();
31    for e in cap.entries.iter().filter(|e| filter.matches(e)) {
32        let key = (
33            e.method.to_ascii_uppercase(),
34            e.host.clone(),
35            e.norm_path.clone(),
36        );
37        by_key
38            .entry(key)
39            .or_insert_with(|| Acc {
40                entries: Vec::new(),
41            })
42            .entries
43            .push(e);
44    }
45
46    let mut endpoints: Vec<EndpointStat> = by_key
47        .into_iter()
48        .map(|((method, host, norm_path), acc)| {
49            endpoint_stat(method, host, norm_path, &acc.entries)
50        })
51        .collect();
52
53    endpoints.sort_by(|a, b| {
54        b.count
55            .cmp(&a.count)
56            .then(a.host.cmp(&b.host))
57            .then(a.norm_path.cmp(&b.norm_path))
58            .then(a.method.cmp(&b.method))
59    });
60    endpoints.truncate(top);
61    EndpointsResult { endpoints }
62}
63
64fn endpoint_stat(
65    method: String,
66    host: String,
67    norm_path: String,
68    entries: &[&Entry],
69) -> EndpointStat {
70    let mut statuses: BTreeMap<String, usize> = BTreeMap::new();
71    let mut content_types: AHashSet<String> = AHashSet::new();
72    let mut query_keys: AHashSet<String> = AHashSet::new();
73    let mut error_count = 0usize;
74
75    for e in entries {
76        *statuses.entry(e.status.to_string()).or_default() += 1;
77        if let Some(ct) = &e.content_type {
78            content_types.insert(ct.clone());
79        }
80        for (k, _) in &e.query {
81            query_keys.insert(k.clone());
82        }
83        if e.is_error() {
84            error_count += 1;
85        }
86    }
87
88    let mut content_types: Vec<String> = content_types.into_iter().collect();
89    content_types.sort();
90    let mut sample_query_keys: Vec<String> = query_keys.into_iter().collect();
91    sample_query_keys.sort();
92
93    EndpointStat {
94        host,
95        method,
96        norm_path,
97        count: entries.len(),
98        statuses,
99        content_types,
100        sample_query_keys,
101        error_count,
102    }
103}
104
105/// Render the endpoint inventory as deterministic terminal text.
106pub fn render_endpoints_text(r: &EndpointsResult) -> String {
107    let mut out = String::new();
108    out.push_str("== wiretrail endpoints ==\n");
109    for e in &r.endpoints {
110        let statuses: Vec<String> = e.statuses.iter().map(|(s, c)| format!("{s}:{c}")).collect();
111        out.push_str(&format!(
112            "\n{:>4}  {} {}{}\n",
113            e.count, e.method, e.host, e.norm_path
114        ));
115        out.push_str(&format!("  status: {}\n", statuses.join(" ")));
116        if !e.content_types.is_empty() {
117            out.push_str(&format!(
118                "  content-types: {}\n",
119                e.content_types.join(", ")
120            ));
121        }
122        if !e.sample_query_keys.is_empty() {
123            out.push_str(&format!(
124                "  query keys: {}\n",
125                e.sample_query_keys.join(", ")
126            ));
127        }
128    }
129    out
130}
131
132#[cfg(test)]
133mod tests {
134    use super::compute_endpoints;
135    use crate::filter::Filter;
136    use crate::model::{sample_capture, sample_entry};
137
138    fn cap() -> crate::model::Capture {
139        let mut e0 = sample_entry(0, "api.foo.com", "GET", "/v1/users/{id}", 200);
140        e0.query = vec![("page".into(), "1".into())];
141        let mut e1 = sample_entry(1, "api.foo.com", "GET", "/v1/users/{id}", 404);
142        e1.query = vec![("expand".into(), "true".into())];
143        let e2 = sample_entry(2, "api.foo.com", "POST", "/v1/users/{id}", 200);
144        sample_capture(vec![e0, e1, e2])
145    }
146
147    #[test]
148    fn groups_by_method_host_normpath() {
149        let r = compute_endpoints(&cap(), &Filter::parse(&[]).unwrap(), 10);
150        // GET .../{id} and POST .../{id} are distinct endpoints
151        let get = r
152            .endpoints
153            .iter()
154            .find(|e| e.method == "GET" && e.norm_path == "/v1/users/{id}")
155            .unwrap();
156        assert_eq!(get.count, 2);
157        assert_eq!(get.statuses.get("200"), Some(&1));
158        assert_eq!(get.statuses.get("404"), Some(&1));
159        assert_eq!(get.error_count, 1);
160        // observed query keys are collected and sorted/deduped
161        assert_eq!(
162            get.sample_query_keys,
163            vec!["expand".to_string(), "page".to_string()]
164        );
165        assert!(r.endpoints.iter().any(|e| e.method == "POST"));
166    }
167
168    #[test]
169    fn sorted_by_count_desc() {
170        let r = compute_endpoints(&cap(), &Filter::parse(&[]).unwrap(), 10);
171        assert_eq!(r.endpoints[0].count, 2);
172    }
173}