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::{Value, json};
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() { json!({}) } else { value }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn resolve_defaults_to_internal_without_a_site_id() {
85        let (target, params) = resolve("list_clients", &json!({})).unwrap();
86
87        assert_eq!(target, "clients");
88        assert_eq!(params, json!({}));
89    }
90
91    #[test]
92    fn resolve_prefers_official_when_a_site_id_is_present() {
93        let (target, _) = resolve("list_clients", &json!({ "siteId": "abc" })).unwrap();
94
95        assert_eq!(target, "official_list_clients");
96    }
97
98    #[test]
99    fn resolve_honors_an_explicit_preference_over_the_site_id_heuristic() {
100        let (target, _) = resolve(
101            "list_clients",
102            &json!({ "siteId": "abc", "prefer": "internal" }),
103        )
104        .unwrap();
105
106        assert_eq!(target, "clients");
107    }
108
109    #[test]
110    fn resolve_strips_the_prefer_field_from_the_forwarded_params() {
111        let (_, params) =
112            resolve("list_clients", &json!({ "prefer": "internal", "limit": 5 })).unwrap();
113
114        assert_eq!(params, json!({ "limit": 5 }));
115    }
116
117    #[test]
118    fn resolve_errors_on_an_unknown_preference() {
119        let err = resolve("list_clients", &json!({ "prefer": "both" })).unwrap_err();
120
121        assert!(
122            matches!(err, UnifiError::HybridRouting(msg) if msg.contains("unknown hybrid preference"))
123        );
124    }
125
126    #[test]
127    fn resolve_errors_on_an_unmapped_action() {
128        let err = resolve("not_a_real_action", &json!({})).unwrap_err();
129
130        assert!(
131            matches!(err, UnifiError::HybridRouting(msg) if msg.contains("unknown hybrid action"))
132        );
133    }
134
135    #[test]
136    fn resolve_errors_on_a_non_string_preference() {
137        let err = resolve("list_clients", &json!({ "prefer": 1 })).unwrap_err();
138
139        assert!(matches!(err, UnifiError::HybridRouting(msg) if msg.contains("must be a string")));
140    }
141
142    #[test]
143    fn resolve_treats_a_null_site_id_as_absent() {
144        let (target, _) = resolve("list_clients", &json!({ "siteId": null })).unwrap();
145
146        assert_eq!(target, "clients");
147    }
148}