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, ¶ms).await
89 }
90 ApiSourceFamily::Internal => {
91 internal::execute(&self.client, target_capability, ¶ms).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}