Skip to main content

alembic_engine/
endpoint.rs

1//! rest-api endpoint path normalization shared by the dcim/ipam adapters.
2
3/// normalize a rest-api endpoint to its canonical `app/resource/` path, dropping
4/// a trailing object-id segment when `is_object_id` matches it. netbox and
5/// nautobot share this and differ only in `is_object_id` (integer vs uuid pks).
6pub fn normalize_endpoint(endpoint: &str, is_object_id: impl Fn(&str) -> bool) -> Option<String> {
7    let trimmed = endpoint.trim();
8    if trimmed.is_empty() {
9        return None;
10    }
11
12    let mut path = trimmed;
13    if let Some(idx) = trimmed.find("/api/") {
14        path = &trimmed[idx + 5..];
15    }
16    let path = path.trim_start_matches('/');
17    let path = path.strip_prefix("api/").unwrap_or(path);
18    let trimmed = path.trim_end_matches('/');
19    let mut segments: Vec<&str> = trimmed.split('/').collect();
20    if let Some(last) = segments.last().copied() {
21        if !last.is_empty() && is_object_id(last) {
22            segments.pop();
23        }
24    }
25    if segments.is_empty() {
26        return None;
27    }
28    let mut normalized = segments.join("/");
29    normalized.push('/');
30    Some(normalized)
31}
32
33#[cfg(test)]
34mod tests {
35    use super::*;
36
37    fn is_digits(s: &str) -> bool {
38        s.chars().all(|c| c.is_ascii_digit())
39    }
40
41    #[test]
42    fn strips_scheme_host_and_api_prefix() {
43        assert_eq!(
44            normalize_endpoint("https://netbox.example.com/api/dcim/sites/", is_digits),
45            Some("dcim/sites/".to_string())
46        );
47        assert_eq!(
48            normalize_endpoint("http://localhost/api/dcim/devices/", is_digits),
49            Some("dcim/devices/".to_string())
50        );
51        assert_eq!(
52            normalize_endpoint("/api/ipam/prefixes/", is_digits),
53            Some("ipam/prefixes/".to_string())
54        );
55        assert_eq!(
56            normalize_endpoint("api/dcim/devices/", is_digits),
57            Some("dcim/devices/".to_string())
58        );
59    }
60
61    #[test]
62    fn adds_trailing_slash_and_trims_whitespace() {
63        assert_eq!(
64            normalize_endpoint("dcim/devices", is_digits),
65            Some("dcim/devices/".to_string())
66        );
67        assert_eq!(
68            normalize_endpoint("  dcim/devices/  ", is_digits),
69            Some("dcim/devices/".to_string())
70        );
71    }
72
73    #[test]
74    fn drops_trailing_id_only_when_predicate_matches() {
75        // same input: stripped when the predicate matches the trailing segment,
76        // kept when it does not.
77        assert_eq!(
78            normalize_endpoint("dcim/sites/5/", is_digits),
79            Some("dcim/sites/".to_string())
80        );
81        assert_eq!(
82            normalize_endpoint("dcim/sites/5/", |_| false),
83            Some("dcim/sites/5/".to_string())
84        );
85    }
86
87    #[test]
88    fn empty_or_id_only_inputs_return_none() {
89        assert_eq!(normalize_endpoint("", is_digits), None);
90        assert_eq!(normalize_endpoint("   ", is_digits), None);
91        // a single segment that is itself an id leaves nothing behind.
92        assert_eq!(normalize_endpoint("/api/5/", is_digits), None);
93    }
94}