Skip to main content

unifi/
actions.rs

1pub mod hybrid;
2pub mod internal;
3pub mod official;
4
5use serde_json::Value;
6
7use crate::capabilities::find_capability;
8use crate::error::{Result, UnifiError};
9use crate::{api::ApiSourceFamily, UnifiClient};
10
11/// A dynamically-dispatched UniFi action: an action name matched against
12/// [`crate::capabilities::all_capabilities`], plus its JSON parameters.
13///
14/// `#[non_exhaustive]`: unlike this crate's other public types, this one
15/// *is* meant to be constructed by callers — use [`ActionRequest::new`]
16/// rather than a struct literal, so a future added field (e.g. a per-call
17/// timeout override) can default instead of being a downstream semver
18/// break.
19#[derive(Debug, Clone)]
20#[non_exhaustive]
21pub struct ActionRequest {
22    /// Action name, e.g. `"clients"` or `"official_list_devices"`.
23    pub action: String,
24    /// Action parameters. Shape depends on the action; see its
25    /// [`crate::capabilities::Capability`] for the expected `path`/`query`/`body`.
26    pub params: Value,
27}
28
29impl ActionRequest {
30    /// Builds a request for `action` with `params`.
31    pub fn new(action: impl Into<String>, params: Value) -> Self {
32        Self {
33            action: action.into(),
34            params,
35        }
36    }
37}
38
39/// Looks up an [`ActionRequest`]'s action against the capability catalog and
40/// runs it against the official, internal, or hybrid API as appropriate.
41///
42/// Holds one [`UnifiClient`] (and therefore one pooled `reqwest::Client`) for
43/// its lifetime — construct once per controller and reuse it across calls
44/// rather than rebuilding per [`execute`](Self::execute) call.
45pub struct ActionDispatcher {
46    client: UnifiClient,
47}
48
49impl ActionDispatcher {
50    /// Wraps an already-built [`UnifiClient`].
51    pub fn new(client: UnifiClient) -> Self {
52        Self { client }
53    }
54
55    /// Runs `request` against whichever API family its action belongs to.
56    ///
57    /// # Errors
58    /// Returns [`UnifiError::UnknownAction`] if `request.action` has no
59    /// registered capability; see [`UnifiError`] for the other failure cases
60    /// this can return.
61    pub async fn execute(&self, request: ActionRequest) -> Result<Value> {
62        let Some(capability) = find_capability(&request.action) else {
63            return Err(UnifiError::UnknownAction(request.action));
64        };
65        match capability.source {
66            ApiSourceFamily::Official => {
67                official::execute(&self.client, capability, &request.params).await
68            }
69            ApiSourceFamily::Internal => {
70                internal::execute(&self.client, capability, &request.params).await
71            }
72            ApiSourceFamily::Hybrid => {
73                let (target, params) =
74                    hybrid::resolve(capability.action.as_str(), &request.params)?;
75                // Intentionally InvalidRequest, not UnknownAction: `request.action`
76                // itself was a registered hybrid capability. This branch only
77                // fires if hybrid::resolve's own routing table names a target
78                // action that isn't in the catalog — a bug in this crate's data,
79                // not a caller-supplied bad action name.
80                let Some(target_capability) = find_capability(target) else {
81                    return Err(UnifiError::InvalidRequest {
82                        context: capability.action.clone(),
83                        message: format!("hybrid action resolved to unknown action {target}"),
84                    });
85                };
86                match target_capability.source {
87                    ApiSourceFamily::Official => {
88                        official::execute(&self.client, target_capability, &params).await
89                    }
90                    ApiSourceFamily::Internal => {
91                        internal::execute(&self.client, target_capability, &params).await
92                    }
93                    ApiSourceFamily::Hybrid => Err(UnifiError::HybridRouting(format!(
94                        "{} resolved to another hybrid action",
95                        capability.action
96                    ))),
97                }
98            }
99        }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use serde_json::json;
107
108    #[test]
109    fn new_accepts_a_string_or_a_str_and_sets_both_fields() {
110        let owned = ActionRequest::new("clients".to_string(), json!({ "a": 1 }));
111        let borrowed = ActionRequest::new("clients", json!({ "a": 1 }));
112
113        assert_eq!(owned.action, "clients");
114        assert_eq!(owned.params, json!({ "a": 1 }));
115        assert_eq!(borrowed.action, "clients");
116        assert_eq!(borrowed.params, json!({ "a": 1 }));
117    }
118}