Skip to main content

unifi/actions/
official.rs

1//! Dispatch for [`ApiSourceFamily::Official`] capabilities: UniFi's documented
2//! `/proxy/network/integration` REST API.
3
4use reqwest::Method;
5use serde_json::{json, Value};
6
7use crate::api::{official::OfficialNetworkApi, path, ApiSourceFamily};
8use crate::capabilities::Capability;
9use crate::error::{Result, UnifiError};
10use crate::UnifiClient;
11
12const CONNECTOR_PREFIXES: &[&str] = &["/proxy/network/integration/", "/proxy/protect/integration/"];
13
14/// Runs one official-API `capability` against `client`.
15///
16/// # Errors
17/// Returns [`UnifiError::InvalidRequest`] if `capability` isn't an official-API
18/// capability or has no method/path configured; see [`UnifiError`] for the
19/// request-level failure cases this can return.
20pub async fn execute(
21    client: &UnifiClient,
22    capability: &Capability,
23    params: &Value,
24) -> Result<Value> {
25    if capability.source != ApiSourceFamily::Official {
26        return Err(UnifiError::InvalidRequest {
27            context: capability.action.clone(),
28            message: "not an official API action".to_string(),
29        });
30    }
31    let path_template = capability
32        .path
33        .as_deref()
34        .ok_or_else(|| UnifiError::InvalidRequest {
35            context: capability.action.clone(),
36            message: "official action has no path configured".to_string(),
37        })?;
38    let method = capability
39        .method
40        .as_deref()
41        .unwrap_or("GET")
42        .parse::<Method>()
43        .map_err(|_| UnifiError::InvalidRequest {
44            context: capability.action.clone(),
45            message: format!("invalid HTTP method {:?}", capability.method),
46        })?;
47    let mut effective_params = params.clone();
48    normalize_official_request(capability.action.as_str(), &mut effective_params);
49    let path = path::substitute_path(path_template, &effective_params, CONNECTOR_PREFIXES)?;
50    let api = OfficialNetworkApi::new(&client.url);
51    let full_path = api.path(&path);
52    client
53        .request_json(
54            capability.action.as_str(),
55            method,
56            &full_path,
57            effective_params.get("query"),
58            effective_params.get("body"),
59        )
60        .await
61}
62
63/// The official "firewall policy ordering" endpoint requires the same zone
64/// id as both `sourceFirewallZoneId` and `destinationFirewallZoneId`; fill
65/// those in from the single `firewallZoneId` callers pass.
66// Both `.expect()`s below are guarded immediately above by the exact check
67// they assert (`params.is_object()` is forced true before the first,
68// `query.is_object()` is checked before the second) — logically infallible,
69// not caller-input-dependent.
70#[allow(clippy::expect_used)]
71fn normalize_official_request(action: &str, params: &mut Value) {
72    if action != "official_get_firewall_policy_ordering" {
73        return;
74    }
75    let Some(zone_id) = params
76        .get("query")
77        .and_then(|query| query.get("firewallZoneId"))
78        .cloned()
79    else {
80        return;
81    };
82    if !params.is_object() {
83        *params = json!({});
84    }
85    // `params` is an object as of the line above, so this cannot fail.
86    let object = params
87        .as_object_mut()
88        .expect("params was just made an object");
89    let query = object.entry("query").or_insert_with(|| json!({}));
90    if !query.is_object() {
91        return;
92    }
93    let query = query
94        .as_object_mut()
95        .expect("query.is_object() was just checked");
96    query
97        .entry("sourceFirewallZoneId".to_string())
98        .or_insert_with(|| zone_id.clone());
99    query
100        .entry("destinationFirewallZoneId".to_string())
101        .or_insert(zone_id);
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn normalize_official_request_fills_both_zone_ids_from_query() {
110        let mut params = json!({ "query": { "firewallZoneId": "zone-1" } });
111
112        normalize_official_request("official_get_firewall_policy_ordering", &mut params);
113
114        assert_eq!(
115            params,
116            json!({
117                "query": {
118                    "firewallZoneId": "zone-1",
119                    "sourceFirewallZoneId": "zone-1",
120                    "destinationFirewallZoneId": "zone-1",
121                }
122            })
123        );
124    }
125
126    #[test]
127    fn normalize_official_request_ignores_other_actions() {
128        let mut params = json!({ "query": { "firewallZoneId": "zone-1" } });
129
130        normalize_official_request("official_list_devices", &mut params);
131
132        assert_eq!(params, json!({ "query": { "firewallZoneId": "zone-1" } }));
133    }
134
135    #[test]
136    fn normalize_official_request_is_a_no_op_without_a_zone_id() {
137        let mut params = json!({});
138
139        normalize_official_request("official_get_firewall_policy_ordering", &mut params);
140
141        assert_eq!(params, json!({}));
142    }
143}