Skip to main content

unifi/
capabilities.rs

1//! The catalog of every action this crate can dispatch, assembled once from
2//! the two `data/*.json` inventories baked into the crate at compile time.
3
4use std::sync::OnceLock;
5
6use crate::api::ApiSourceFamily;
7
8pub mod internal_network;
9pub mod official_network;
10
11/// Who is allowed to call a [`Capability`].
12///
13/// `#[non_exhaustive]`: a future third tier (e.g. a scope narrower than
14/// `Admin` for a specific subsystem) should not be a downstream semver
15/// break. See [`Capability::auth_scope`] for the important caveat that this
16/// crate does not itself enforce this scope.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[non_exhaustive]
19pub enum AuthScope {
20    /// Read-only; safe for any authenticated caller.
21    Read,
22    /// Mutates controller state; callers should gate this behind an
23    /// explicit admin/write permission.
24    Admin,
25}
26
27impl AuthScope {
28    /// The scope's wire/config representation (`"read"` or `"admin"`).
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::Read => "read",
32            Self::Admin => "admin",
33        }
34    }
35}
36
37/// One dispatchable UniFi action: its name, which API serves it, and (for
38/// non-hybrid actions) the method/path template to call.
39///
40/// `#[non_exhaustive]`: instances only ever come from
41/// [`all_capabilities`]/[`find_capability`] — nothing outside this crate
42/// constructs one via struct literal — so a future added field (e.g. a
43/// rate-limit hint) should not be a downstream semver break.
44#[derive(Debug, Clone, PartialEq, Eq)]
45#[non_exhaustive]
46pub struct Capability {
47    /// Action name, as passed to [`crate::ActionRequest::action`].
48    pub action: String,
49    /// Human-readable summary, e.g. for listing available actions in a UI.
50    pub title: String,
51    /// Which API this capability is served from.
52    pub source: ApiSourceFamily,
53    /// HTTP method. `None` only for [`ApiSourceFamily::Hybrid`] entries,
54    /// which resolve to another capability's method at dispatch time.
55    pub method: Option<String>,
56    /// Path template, e.g. `/v1/sites/{siteId}`. `None` only for
57    /// [`ApiSourceFamily::Hybrid`] entries.
58    pub path: Option<String>,
59    /// Whether this action changes controller state.
60    pub mutating: bool,
61    /// Minimum caller permission required. **Not enforced by this crate** —
62    /// see the note on [`AuthScope`].
63    pub auth_scope: AuthScope,
64    /// Provenance/confidence tag from the source inventory (e.g.
65    /// `"contract_ok"`, `"legacy_alias"`); informational only.
66    pub verification_mode: Option<String>,
67}
68
69/// Every capability this crate can dispatch, built once and cached for the
70/// process lifetime.
71///
72/// # Panics
73/// Panics if either bundled `data/*.json` inventory fails to parse. Both
74/// files ship with the crate and are covered by this crate's own tests, so
75/// a panic here means the crate itself was built or edited incorrectly —
76/// never a condition a caller can hit at runtime with valid input.
77pub fn all_capabilities() -> &'static [Capability] {
78    static ALL: OnceLock<Vec<Capability>> = OnceLock::new();
79    ALL.get_or_init(|| {
80        let mut caps = Vec::new();
81        caps.extend(official_network::capabilities());
82        caps.extend(internal_network::capabilities());
83        caps
84    })
85}
86
87/// Looks up a capability by [`Capability::action`] name.
88pub fn find_capability(action: &str) -> Option<&'static Capability> {
89    all_capabilities().iter().find(|cap| cap.action == action)
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn all_capabilities_has_no_duplicate_action_names() {
98        let mut names: Vec<&str> = all_capabilities()
99            .iter()
100            .map(|cap| cap.action.as_str())
101            .collect();
102        let before = names.len();
103        names.sort_unstable();
104        names.dedup();
105
106        assert_eq!(names.len(), before, "duplicate capability action name");
107    }
108
109    #[test]
110    fn find_capability_finds_a_known_action() {
111        assert!(find_capability("clients").is_some());
112    }
113
114    #[test]
115    fn find_capability_returns_none_for_an_unknown_action() {
116        assert!(find_capability("does_not_exist").is_none());
117    }
118}