Skip to main content

unifi/actions/
hybrid.rs

1//! Resolves [`crate::api::ApiSourceFamily::Hybrid`] capabilities to a
2//! concrete official or internal action.
3//!
4//! Callers can force a side with `params.prefer` (`"official"` or
5//! `"internal"`); otherwise the presence of a `siteId` parameter is taken as
6//! a signal the caller wants the official API's site-scoped shape.
7
8use serde_json::{json, Value};
9
10use crate::error::{Result, UnifiError};
11
12/// Resolves `action` to a concrete `(target_action, params)` pair.
13///
14/// # Errors
15/// Returns [`UnifiError::HybridRouting`] if `params.prefer` is present but
16/// not a `"official"`/`"internal"` string, or if `action` has no mapping for
17/// the resolved side.
18pub fn resolve(action: &str, params: &Value) -> Result<(&'static str, Value)> {
19    let prefer = match params.get("prefer") {
20        None => None,
21        Some(Value::String(value)) => Some(value.to_ascii_lowercase()),
22        Some(_) => {
23            return Err(UnifiError::HybridRouting(
24                "hybrid preference must be a string".to_string(),
25            ))
26        }
27    };
28    // A present-but-null siteId is the same as not providing one.
29    let has_site_id = params.get("siteId").is_some_and(|value| !value.is_null());
30    let target = match prefer.as_deref() {
31        Some("official") => official_target(action),
32        Some("internal") => internal_target(action),
33        Some(other) => {
34            return Err(UnifiError::HybridRouting(format!(
35                "unknown hybrid preference: {other}"
36            )))
37        }
38        None if has_site_id => official_target(action),
39        None => internal_target(action),
40    };
41    let Some(target) = target else {
42        return Err(UnifiError::HybridRouting(format!(
43            "unknown hybrid action: {action}"
44        )));
45    };
46    Ok((target, normalize_params(params)))
47}
48
49fn official_target(action: &str) -> Option<&'static str> {
50    match action {
51        "list_clients" => Some("official_list_clients"),
52        "list_devices" => Some("official_list_devices"),
53        "list_networks" => Some("official_list_networks"),
54        "list_wifi" => Some("official_list_wifi"),
55        "get_system_info" => Some("official_get_info"),
56        _ => None,
57    }
58}
59
60fn internal_target(action: &str) -> Option<&'static str> {
61    match action {
62        "list_clients" => Some("clients"),
63        "list_devices" => Some("devices"),
64        "list_networks" => Some("unifi_list_networks"),
65        "list_wifi" => Some("wlans"),
66        "get_system_info" => Some("sysinfo"),
67        _ => None,
68    }
69}
70
71fn normalize_params(params: &Value) -> Value {
72    let mut value = params.clone();
73    if let Some(object) = value.as_object_mut() {
74        object.remove("prefer");
75    }
76    if value.is_null() {
77        json!({})
78    } else {
79        value
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn resolve_defaults_to_internal_without_a_site_id() {
89        let (target, params) = resolve("list_clients", &json!({})).unwrap();
90
91        assert_eq!(target, "clients");
92        assert_eq!(params, json!({}));
93    }
94
95    #[test]
96    fn resolve_prefers_official_when_a_site_id_is_present() {
97        let (target, _) = resolve("list_clients", &json!({ "siteId": "abc" })).unwrap();
98
99        assert_eq!(target, "official_list_clients");
100    }
101
102    #[test]
103    fn resolve_honors_an_explicit_preference_over_the_site_id_heuristic() {
104        let (target, _) = resolve(
105            "list_clients",
106            &json!({ "siteId": "abc", "prefer": "internal" }),
107        )
108        .unwrap();
109
110        assert_eq!(target, "clients");
111    }
112
113    #[test]
114    fn resolve_strips_the_prefer_field_from_the_forwarded_params() {
115        let (_, params) =
116            resolve("list_clients", &json!({ "prefer": "internal", "limit": 5 })).unwrap();
117
118        assert_eq!(params, json!({ "limit": 5 }));
119    }
120
121    #[test]
122    fn resolve_errors_on_an_unknown_preference() {
123        let err = resolve("list_clients", &json!({ "prefer": "both" })).unwrap_err();
124
125        assert!(
126            matches!(err, UnifiError::HybridRouting(msg) if msg.contains("unknown hybrid preference"))
127        );
128    }
129
130    #[test]
131    fn resolve_errors_on_an_unmapped_action() {
132        let err = resolve("not_a_real_action", &json!({})).unwrap_err();
133
134        assert!(
135            matches!(err, UnifiError::HybridRouting(msg) if msg.contains("unknown hybrid action"))
136        );
137    }
138
139    #[test]
140    fn resolve_errors_on_a_non_string_preference() {
141        let err = resolve("list_clients", &json!({ "prefer": 1 })).unwrap_err();
142
143        assert!(matches!(err, UnifiError::HybridRouting(msg) if msg.contains("must be a string")));
144    }
145
146    #[test]
147    fn resolve_treats_a_null_site_id_as_absent() {
148        let (target, _) = resolve("list_clients", &json!({ "siteId": null })).unwrap();
149
150        assert_eq!(target, "clients");
151    }
152}