Skip to main content

unifly_api/integration/
client.rs

1// Hand-crafted async HTTP client for the UniFi Network Integration API (v10.1.84).
2//
3// Base path: /integration/v1/
4// Auth: X-API-KEY header
5
6use std::future::Future;
7
8use reqwest::header::{HeaderMap, HeaderValue};
9use secrecy::ExposeSecret;
10use serde::Serialize;
11use serde::de::DeserializeOwned;
12use tracing::debug;
13use url::Url;
14use uuid::Uuid;
15
16use super::types;
17use crate::Error;
18
19// ── Error response shape from the Integration API ────────────────────
20
21#[derive(serde::Deserialize)]
22struct ErrorResponse {
23    #[serde(default)]
24    message: Option<String>,
25    #[serde(default)]
26    code: Option<String>,
27}
28
29// ── Client ───────────────────────────────────────────────────────────
30
31/// Async client for the UniFi Integration API.
32///
33/// Uses API-key authentication and communicates via JSON REST endpoints
34/// under `/integration/v1/`.
35pub struct IntegrationClient {
36    http: reqwest::Client,
37    base_url: Url,
38}
39
40impl IntegrationClient {
41    // ── Constructors ─────────────────────────────────────────────────
42
43    /// Build from an API key, transport config, and detected platform.
44    ///
45    /// Injects `X-API-KEY` as a default header on every request.
46    /// On UniFi OS the base path is `/proxy/network/integration/`;
47    /// on standalone controllers it's just `/integration/`.
48    pub fn from_api_key(
49        base_url: &str,
50        api_key: &secrecy::SecretString,
51        transport: &crate::TransportConfig,
52        platform: crate::ControllerPlatform,
53    ) -> Result<Self, Error> {
54        let mut headers = HeaderMap::new();
55        let mut key_value =
56            HeaderValue::from_str(api_key.expose_secret()).map_err(|e| Error::Authentication {
57                message: format!("invalid API key header value: {e}"),
58            })?;
59        key_value.set_sensitive(true);
60        headers.insert("X-API-KEY", key_value);
61
62        let http = transport.build_client_with_headers(headers)?;
63        let base_url = Self::normalize_base_url(base_url, platform)?;
64
65        Ok(Self { http, base_url })
66    }
67
68    /// Wrap an existing `reqwest::Client` (caller manages auth headers).
69    pub fn from_reqwest(
70        base_url: &str,
71        http: reqwest::Client,
72        platform: crate::ControllerPlatform,
73    ) -> Result<Self, Error> {
74        let base_url = Self::normalize_base_url(base_url, platform)?;
75        Ok(Self { http, base_url })
76    }
77
78    /// Build the base URL with correct platform prefix + `/integration/`.
79    ///
80    /// UniFi OS: `https://host/proxy/network/integration/`
81    /// Standalone: `https://host/integration/`
82    fn normalize_base_url(raw: &str, platform: crate::ControllerPlatform) -> Result<Url, Error> {
83        let mut url = Url::parse(raw)?;
84
85        // Strip trailing slash for uniform handling
86        let path = url.path().trim_end_matches('/').to_owned();
87
88        if path.ends_with("/integration") {
89            url.set_path(&format!("{path}/"));
90        } else {
91            let prefix = platform.integration_prefix();
92            url.set_path(&format!("{path}{prefix}/"));
93        }
94
95        Ok(url)
96    }
97
98    // ── URL builder ──────────────────────────────────────────────────
99
100    /// Join a relative path (e.g. `"v1/sites"`) onto the base URL.
101    fn url(&self, path: &str) -> Url {
102        // base_url always ends with `/integration/`, so joining `v1/…` works.
103        self.base_url
104            .join(path)
105            .expect("path should be valid relative URL")
106    }
107
108    // ── HTTP verbs ───────────────────────────────────────────────────
109
110    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
111        let url = self.url(path);
112        debug!("GET {url}");
113
114        let resp = self.http.get(url).send().await?;
115        self.handle_response(resp).await
116    }
117
118    async fn get_with_params<T: DeserializeOwned>(
119        &self,
120        path: &str,
121        params: &[(&str, String)],
122    ) -> Result<T, Error> {
123        let url = self.url(path);
124        debug!("GET {url} params={params:?}");
125
126        let resp = self.http.get(url).query(params).send().await?;
127        self.handle_response(resp).await
128    }
129
130    async fn post<T: DeserializeOwned, B: Serialize + Sync>(
131        &self,
132        path: &str,
133        body: &B,
134    ) -> Result<T, Error> {
135        let url = self.url(path);
136        debug!("POST {url}");
137
138        let resp = self.http.post(url).json(body).send().await?;
139        self.handle_response(resp).await
140    }
141
142    async fn post_no_response<B: Serialize + Sync>(
143        &self,
144        path: &str,
145        body: &B,
146    ) -> Result<(), Error> {
147        let url = self.url(path);
148        debug!("POST {url}");
149
150        let resp = self.http.post(url).json(body).send().await?;
151        self.handle_empty(resp).await
152    }
153
154    async fn put<T: DeserializeOwned, B: Serialize + Sync>(
155        &self,
156        path: &str,
157        body: &B,
158    ) -> Result<T, Error> {
159        let url = self.url(path);
160        debug!("PUT {url}");
161
162        let resp = self.http.put(url).json(body).send().await?;
163        self.handle_response(resp).await
164    }
165
166    async fn patch<T: DeserializeOwned, B: Serialize + Sync>(
167        &self,
168        path: &str,
169        body: &B,
170    ) -> Result<T, Error> {
171        let url = self.url(path);
172        debug!("PATCH {url}");
173
174        let resp = self.http.patch(url).json(body).send().await?;
175        self.handle_response(resp).await
176    }
177
178    async fn delete(&self, path: &str) -> Result<(), Error> {
179        let url = self.url(path);
180        debug!("DELETE {url}");
181
182        let resp = self.http.delete(url).send().await?;
183        self.handle_empty(resp).await
184    }
185
186    async fn delete_with_response<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
187        let url = self.url(path);
188        debug!("DELETE {url}");
189
190        let resp = self.http.delete(url).send().await?;
191        self.handle_response(resp).await
192    }
193
194    async fn delete_with_params<T: DeserializeOwned>(
195        &self,
196        path: &str,
197        params: &[(&str, String)],
198    ) -> Result<T, Error> {
199        let url = self.url(path);
200        debug!("DELETE {url} params={params:?}");
201
202        let resp = self.http.delete(url).query(params).send().await?;
203        self.handle_response(resp).await
204    }
205
206    // ── Response handling ────────────────────────────────────────────
207
208    async fn handle_response<T: DeserializeOwned>(
209        &self,
210        resp: reqwest::Response,
211    ) -> Result<T, Error> {
212        let status = resp.status();
213        if status.is_success() {
214            let body = resp.text().await?;
215            serde_json::from_str(&body).map_err(|e| {
216                let preview = &body[..body.len().min(200)];
217                Error::Deserialization {
218                    message: format!("{e} (body preview: {preview:?})"),
219                    body,
220                }
221            })
222        } else {
223            Err(self.parse_error(status, resp).await)
224        }
225    }
226
227    async fn handle_empty(&self, resp: reqwest::Response) -> Result<(), Error> {
228        let status = resp.status();
229        if status.is_success() {
230            Ok(())
231        } else {
232            Err(self.parse_error(status, resp).await)
233        }
234    }
235
236    async fn parse_error(&self, status: reqwest::StatusCode, resp: reqwest::Response) -> Error {
237        if status == reqwest::StatusCode::UNAUTHORIZED {
238            return Error::InvalidApiKey;
239        }
240
241        let raw = resp.text().await.unwrap_or_default();
242
243        if let Ok(err) = serde_json::from_str::<ErrorResponse>(&raw) {
244            Error::Integration {
245                status: status.as_u16(),
246                message: err.message.unwrap_or_else(|| status.to_string()),
247                code: err.code,
248            }
249        } else {
250            Error::Integration {
251                status: status.as_u16(),
252                message: if raw.is_empty() {
253                    status.to_string()
254                } else {
255                    raw
256                },
257                code: None,
258            }
259        }
260    }
261
262    // ── Pagination helper ────────────────────────────────────────────
263
264    /// Collect all pages into a single `Vec<T>`.
265    pub async fn paginate_all<T, F, Fut>(&self, limit: i32, fetch: F) -> Result<Vec<T>, Error>
266    where
267        F: Fn(i64, i32) -> Fut,
268        Fut: Future<Output = Result<types::Page<T>, Error>>,
269    {
270        let mut all = Vec::new();
271        let mut offset: i64 = 0;
272
273        loop {
274            let page = fetch(offset, limit).await?;
275            let received = page.data.len();
276            all.extend(page.data);
277
278            let limit_usize = usize::try_from(limit).unwrap_or(0);
279            if received < limit_usize
280                || i64::try_from(all.len()).unwrap_or(i64::MAX) >= page.total_count
281            {
282                break;
283            }
284
285            offset += i64::try_from(received).unwrap_or(i64::MAX);
286        }
287
288        Ok(all)
289    }
290
291    // ━━ Public API ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
292
293    // ── System Info ──────────────────────────────────────────────────
294
295    pub async fn get_info(&self) -> Result<types::ApplicationInfoResponse, Error> {
296        self.get("v1/info").await
297    }
298
299    // ── Sites ────────────────────────────────────────────────────────
300
301    pub async fn list_sites(
302        &self,
303        offset: i64,
304        limit: i32,
305    ) -> Result<types::Page<types::SiteResponse>, Error> {
306        self.get_with_params(
307            "v1/sites",
308            &[("offset", offset.to_string()), ("limit", limit.to_string())],
309        )
310        .await
311    }
312
313    // ── Devices ──────────────────────────────────────────────────────
314
315    pub async fn list_devices(
316        &self,
317        site_id: &Uuid,
318        offset: i64,
319        limit: i32,
320    ) -> Result<types::Page<types::DeviceResponse>, Error> {
321        self.get_with_params(
322            &format!("v1/sites/{site_id}/devices"),
323            &[("offset", offset.to_string()), ("limit", limit.to_string())],
324        )
325        .await
326    }
327
328    pub async fn get_device(
329        &self,
330        site_id: &Uuid,
331        device_id: &Uuid,
332    ) -> Result<types::DeviceDetailsResponse, Error> {
333        self.get(&format!("v1/sites/{site_id}/devices/{device_id}"))
334            .await
335    }
336
337    pub async fn get_device_statistics(
338        &self,
339        site_id: &Uuid,
340        device_id: &Uuid,
341    ) -> Result<types::DeviceStatisticsResponse, Error> {
342        self.get(&format!(
343            "v1/sites/{site_id}/devices/{device_id}/statistics/latest"
344        ))
345        .await
346    }
347
348    pub async fn adopt_device(
349        &self,
350        site_id: &Uuid,
351        mac: &str,
352        ignore_device_limit: bool,
353    ) -> Result<types::DeviceDetailsResponse, Error> {
354        #[derive(Serialize)]
355        #[serde(rename_all = "camelCase")]
356        struct Body<'a> {
357            mac_address: &'a str,
358            ignore_device_limit: bool,
359        }
360
361        self.post(
362            &format!("v1/sites/{site_id}/devices"),
363            &Body {
364                mac_address: mac,
365                ignore_device_limit,
366            },
367        )
368        .await
369    }
370
371    pub async fn remove_device(&self, site_id: &Uuid, device_id: &Uuid) -> Result<(), Error> {
372        self.delete(&format!("v1/sites/{site_id}/devices/{device_id}"))
373            .await
374    }
375
376    pub async fn device_action(
377        &self,
378        site_id: &Uuid,
379        device_id: &Uuid,
380        action: &str,
381    ) -> Result<(), Error> {
382        #[derive(Serialize)]
383        struct Body<'a> {
384            action: &'a str,
385        }
386
387        self.post_no_response(
388            &format!("v1/sites/{site_id}/devices/{device_id}/actions"),
389            &Body { action },
390        )
391        .await
392    }
393
394    pub async fn port_action(
395        &self,
396        site_id: &Uuid,
397        device_id: &Uuid,
398        port_idx: u32,
399        action: &str,
400    ) -> Result<(), Error> {
401        #[derive(Serialize)]
402        struct Body<'a> {
403            action: &'a str,
404        }
405
406        self.post_no_response(
407            &format!("v1/sites/{site_id}/devices/{device_id}/interfaces/ports/{port_idx}/actions"),
408            &Body { action },
409        )
410        .await
411    }
412
413    pub async fn list_pending_devices(
414        &self,
415        site_id: &Uuid,
416        offset: i64,
417        limit: i32,
418    ) -> Result<types::Page<types::PendingDeviceResponse>, Error> {
419        self.get_with_params(
420            &format!("v1/sites/{site_id}/devices/pending"),
421            &[("offset", offset.to_string()), ("limit", limit.to_string())],
422        )
423        .await
424    }
425
426    pub async fn list_device_tags(
427        &self,
428        site_id: &Uuid,
429        offset: i64,
430        limit: i32,
431    ) -> Result<types::Page<types::DeviceTagResponse>, Error> {
432        self.get_with_params(
433            &format!("v1/sites/{site_id}/devices/tags"),
434            &[("offset", offset.to_string()), ("limit", limit.to_string())],
435        )
436        .await
437    }
438
439    // ── Clients ──────────────────────────────────────────────────────
440
441    pub async fn list_clients(
442        &self,
443        site_id: &Uuid,
444        offset: i64,
445        limit: i32,
446    ) -> Result<types::Page<types::ClientResponse>, Error> {
447        self.get_with_params(
448            &format!("v1/sites/{site_id}/clients"),
449            &[("offset", offset.to_string()), ("limit", limit.to_string())],
450        )
451        .await
452    }
453
454    pub async fn get_client(
455        &self,
456        site_id: &Uuid,
457        client_id: &Uuid,
458    ) -> Result<types::ClientDetailsResponse, Error> {
459        self.get(&format!("v1/sites/{site_id}/clients/{client_id}"))
460            .await
461    }
462
463    pub async fn client_action(
464        &self,
465        site_id: &Uuid,
466        client_id: &Uuid,
467        action: &str,
468    ) -> Result<types::ClientActionResponse, Error> {
469        #[derive(Serialize)]
470        struct Body<'a> {
471            action: &'a str,
472        }
473
474        self.post(
475            &format!("v1/sites/{site_id}/clients/{client_id}/actions"),
476            &Body { action },
477        )
478        .await
479    }
480
481    // ── Networks ─────────────────────────────────────────────────────
482
483    pub async fn list_networks(
484        &self,
485        site_id: &Uuid,
486        offset: i64,
487        limit: i32,
488    ) -> Result<types::Page<types::NetworkResponse>, Error> {
489        self.get_with_params(
490            &format!("v1/sites/{site_id}/networks"),
491            &[("offset", offset.to_string()), ("limit", limit.to_string())],
492        )
493        .await
494    }
495
496    pub async fn get_network(
497        &self,
498        site_id: &Uuid,
499        network_id: &Uuid,
500    ) -> Result<types::NetworkDetailsResponse, Error> {
501        self.get(&format!("v1/sites/{site_id}/networks/{network_id}"))
502            .await
503    }
504
505    pub async fn create_network(
506        &self,
507        site_id: &Uuid,
508        body: &types::NetworkCreateUpdate,
509    ) -> Result<types::NetworkDetailsResponse, Error> {
510        self.post(&format!("v1/sites/{site_id}/networks"), body)
511            .await
512    }
513
514    pub async fn update_network(
515        &self,
516        site_id: &Uuid,
517        network_id: &Uuid,
518        body: &types::NetworkCreateUpdate,
519    ) -> Result<types::NetworkDetailsResponse, Error> {
520        self.put(&format!("v1/sites/{site_id}/networks/{network_id}"), body)
521            .await
522    }
523
524    pub async fn delete_network(&self, site_id: &Uuid, network_id: &Uuid) -> Result<(), Error> {
525        self.delete(&format!("v1/sites/{site_id}/networks/{network_id}"))
526            .await
527    }
528
529    pub async fn get_network_references(
530        &self,
531        site_id: &Uuid,
532        network_id: &Uuid,
533    ) -> Result<types::NetworkReferencesResponse, Error> {
534        self.get(&format!(
535            "v1/sites/{site_id}/networks/{network_id}/references"
536        ))
537        .await
538    }
539
540    // ── WiFi Broadcasts ──────────────────────────────────────────────
541
542    pub async fn list_wifi_broadcasts(
543        &self,
544        site_id: &Uuid,
545        offset: i64,
546        limit: i32,
547    ) -> Result<types::Page<types::WifiBroadcastResponse>, Error> {
548        self.get_with_params(
549            &format!("v1/sites/{site_id}/wifi/broadcasts"),
550            &[("offset", offset.to_string()), ("limit", limit.to_string())],
551        )
552        .await
553    }
554
555    pub async fn get_wifi_broadcast(
556        &self,
557        site_id: &Uuid,
558        broadcast_id: &Uuid,
559    ) -> Result<types::WifiBroadcastDetailsResponse, Error> {
560        self.get(&format!(
561            "v1/sites/{site_id}/wifi/broadcasts/{broadcast_id}"
562        ))
563        .await
564    }
565
566    pub async fn create_wifi_broadcast(
567        &self,
568        site_id: &Uuid,
569        body: &types::WifiBroadcastCreateUpdate,
570    ) -> Result<types::WifiBroadcastDetailsResponse, Error> {
571        self.post(&format!("v1/sites/{site_id}/wifi/broadcasts"), body)
572            .await
573    }
574
575    pub async fn update_wifi_broadcast(
576        &self,
577        site_id: &Uuid,
578        broadcast_id: &Uuid,
579        body: &types::WifiBroadcastCreateUpdate,
580    ) -> Result<types::WifiBroadcastDetailsResponse, Error> {
581        self.put(
582            &format!("v1/sites/{site_id}/wifi/broadcasts/{broadcast_id}"),
583            body,
584        )
585        .await
586    }
587
588    pub async fn delete_wifi_broadcast(
589        &self,
590        site_id: &Uuid,
591        broadcast_id: &Uuid,
592    ) -> Result<(), Error> {
593        self.delete(&format!(
594            "v1/sites/{site_id}/wifi/broadcasts/{broadcast_id}"
595        ))
596        .await
597    }
598
599    // ── Firewall Policies ────────────────────────────────────────────
600
601    pub async fn list_firewall_policies(
602        &self,
603        site_id: &Uuid,
604        offset: i64,
605        limit: i32,
606    ) -> Result<types::Page<types::FirewallPolicyResponse>, Error> {
607        self.get_with_params(
608            &format!("v1/sites/{site_id}/firewall/policies"),
609            &[("offset", offset.to_string()), ("limit", limit.to_string())],
610        )
611        .await
612    }
613
614    pub async fn get_firewall_policy(
615        &self,
616        site_id: &Uuid,
617        policy_id: &Uuid,
618    ) -> Result<types::FirewallPolicyResponse, Error> {
619        self.get(&format!("v1/sites/{site_id}/firewall/policies/{policy_id}"))
620            .await
621    }
622
623    pub async fn create_firewall_policy(
624        &self,
625        site_id: &Uuid,
626        body: &types::FirewallPolicyCreateUpdate,
627    ) -> Result<types::FirewallPolicyResponse, Error> {
628        self.post(&format!("v1/sites/{site_id}/firewall/policies"), body)
629            .await
630    }
631
632    pub async fn update_firewall_policy(
633        &self,
634        site_id: &Uuid,
635        policy_id: &Uuid,
636        body: &types::FirewallPolicyCreateUpdate,
637    ) -> Result<types::FirewallPolicyResponse, Error> {
638        self.put(
639            &format!("v1/sites/{site_id}/firewall/policies/{policy_id}"),
640            body,
641        )
642        .await
643    }
644
645    pub async fn patch_firewall_policy(
646        &self,
647        site_id: &Uuid,
648        policy_id: &Uuid,
649        body: &types::FirewallPolicyPatch,
650    ) -> Result<types::FirewallPolicyResponse, Error> {
651        self.patch(
652            &format!("v1/sites/{site_id}/firewall/policies/{policy_id}"),
653            body,
654        )
655        .await
656    }
657
658    pub async fn delete_firewall_policy(
659        &self,
660        site_id: &Uuid,
661        policy_id: &Uuid,
662    ) -> Result<(), Error> {
663        self.delete(&format!("v1/sites/{site_id}/firewall/policies/{policy_id}"))
664            .await
665    }
666
667    pub async fn get_firewall_policy_ordering(
668        &self,
669        site_id: &Uuid,
670    ) -> Result<types::FirewallPolicyOrdering, Error> {
671        self.get(&format!("v1/sites/{site_id}/firewall/policies/ordering"))
672            .await
673    }
674
675    pub async fn set_firewall_policy_ordering(
676        &self,
677        site_id: &Uuid,
678        body: &types::FirewallPolicyOrdering,
679    ) -> Result<types::FirewallPolicyOrdering, Error> {
680        self.put(
681            &format!("v1/sites/{site_id}/firewall/policies/ordering"),
682            body,
683        )
684        .await
685    }
686
687    // ── Firewall Zones ───────────────────────────────────────────────
688
689    pub async fn list_firewall_zones(
690        &self,
691        site_id: &Uuid,
692        offset: i64,
693        limit: i32,
694    ) -> Result<types::Page<types::FirewallZoneResponse>, Error> {
695        self.get_with_params(
696            &format!("v1/sites/{site_id}/firewall/zones"),
697            &[("offset", offset.to_string()), ("limit", limit.to_string())],
698        )
699        .await
700    }
701
702    pub async fn get_firewall_zone(
703        &self,
704        site_id: &Uuid,
705        zone_id: &Uuid,
706    ) -> Result<types::FirewallZoneResponse, Error> {
707        self.get(&format!("v1/sites/{site_id}/firewall/zones/{zone_id}"))
708            .await
709    }
710
711    pub async fn create_firewall_zone(
712        &self,
713        site_id: &Uuid,
714        body: &types::FirewallZoneCreateUpdate,
715    ) -> Result<types::FirewallZoneResponse, Error> {
716        self.post(&format!("v1/sites/{site_id}/firewall/zones"), body)
717            .await
718    }
719
720    pub async fn update_firewall_zone(
721        &self,
722        site_id: &Uuid,
723        zone_id: &Uuid,
724        body: &types::FirewallZoneCreateUpdate,
725    ) -> Result<types::FirewallZoneResponse, Error> {
726        self.put(
727            &format!("v1/sites/{site_id}/firewall/zones/{zone_id}"),
728            body,
729        )
730        .await
731    }
732
733    pub async fn delete_firewall_zone(&self, site_id: &Uuid, zone_id: &Uuid) -> Result<(), Error> {
734        self.delete(&format!("v1/sites/{site_id}/firewall/zones/{zone_id}"))
735            .await
736    }
737
738    // ── ACL Rules ────────────────────────────────────────────────────
739
740    pub async fn list_acl_rules(
741        &self,
742        site_id: &Uuid,
743        offset: i64,
744        limit: i32,
745    ) -> Result<types::Page<types::AclRuleResponse>, Error> {
746        self.get_with_params(
747            &format!("v1/sites/{site_id}/acl-rules"),
748            &[("offset", offset.to_string()), ("limit", limit.to_string())],
749        )
750        .await
751    }
752
753    pub async fn get_acl_rule(
754        &self,
755        site_id: &Uuid,
756        rule_id: &Uuid,
757    ) -> Result<types::AclRuleResponse, Error> {
758        self.get(&format!("v1/sites/{site_id}/acl-rules/{rule_id}"))
759            .await
760    }
761
762    pub async fn create_acl_rule(
763        &self,
764        site_id: &Uuid,
765        body: &types::AclRuleCreateUpdate,
766    ) -> Result<types::AclRuleResponse, Error> {
767        self.post(&format!("v1/sites/{site_id}/acl-rules"), body)
768            .await
769    }
770
771    pub async fn update_acl_rule(
772        &self,
773        site_id: &Uuid,
774        rule_id: &Uuid,
775        body: &types::AclRuleCreateUpdate,
776    ) -> Result<types::AclRuleResponse, Error> {
777        self.put(&format!("v1/sites/{site_id}/acl-rules/{rule_id}"), body)
778            .await
779    }
780
781    pub async fn delete_acl_rule(&self, site_id: &Uuid, rule_id: &Uuid) -> Result<(), Error> {
782        self.delete(&format!("v1/sites/{site_id}/acl-rules/{rule_id}"))
783            .await
784    }
785
786    pub async fn get_acl_rule_ordering(
787        &self,
788        site_id: &Uuid,
789    ) -> Result<types::AclRuleOrdering, Error> {
790        self.get(&format!("v1/sites/{site_id}/acl-rules/ordering"))
791            .await
792    }
793
794    pub async fn set_acl_rule_ordering(
795        &self,
796        site_id: &Uuid,
797        body: &types::AclRuleOrdering,
798    ) -> Result<types::AclRuleOrdering, Error> {
799        self.put(&format!("v1/sites/{site_id}/acl-rules/ordering"), body)
800            .await
801    }
802
803    // ── DNS Policies ─────────────────────────────────────────────────
804
805    pub async fn list_dns_policies(
806        &self,
807        site_id: &Uuid,
808        offset: i64,
809        limit: i32,
810    ) -> Result<types::Page<types::DnsPolicyResponse>, Error> {
811        self.get_with_params(
812            &format!("v1/sites/{site_id}/dns/policies"),
813            &[("offset", offset.to_string()), ("limit", limit.to_string())],
814        )
815        .await
816    }
817
818    pub async fn get_dns_policy(
819        &self,
820        site_id: &Uuid,
821        dns_id: &Uuid,
822    ) -> Result<types::DnsPolicyResponse, Error> {
823        self.get(&format!("v1/sites/{site_id}/dns/policies/{dns_id}"))
824            .await
825    }
826
827    pub async fn create_dns_policy(
828        &self,
829        site_id: &Uuid,
830        body: &types::DnsPolicyCreateUpdate,
831    ) -> Result<types::DnsPolicyResponse, Error> {
832        self.post(&format!("v1/sites/{site_id}/dns/policies"), body)
833            .await
834    }
835
836    pub async fn update_dns_policy(
837        &self,
838        site_id: &Uuid,
839        dns_id: &Uuid,
840        body: &types::DnsPolicyCreateUpdate,
841    ) -> Result<types::DnsPolicyResponse, Error> {
842        self.put(&format!("v1/sites/{site_id}/dns/policies/{dns_id}"), body)
843            .await
844    }
845
846    pub async fn delete_dns_policy(&self, site_id: &Uuid, dns_id: &Uuid) -> Result<(), Error> {
847        self.delete(&format!("v1/sites/{site_id}/dns/policies/{dns_id}"))
848            .await
849    }
850
851    // ── Traffic Matching Lists ───────────────────────────────────────
852
853    pub async fn list_traffic_matching_lists(
854        &self,
855        site_id: &Uuid,
856        offset: i64,
857        limit: i32,
858    ) -> Result<types::Page<types::TrafficMatchingListResponse>, Error> {
859        self.get_with_params(
860            &format!("v1/sites/{site_id}/traffic-matching-lists"),
861            &[("offset", offset.to_string()), ("limit", limit.to_string())],
862        )
863        .await
864    }
865
866    pub async fn get_traffic_matching_list(
867        &self,
868        site_id: &Uuid,
869        list_id: &Uuid,
870    ) -> Result<types::TrafficMatchingListResponse, Error> {
871        self.get(&format!(
872            "v1/sites/{site_id}/traffic-matching-lists/{list_id}"
873        ))
874        .await
875    }
876
877    pub async fn create_traffic_matching_list(
878        &self,
879        site_id: &Uuid,
880        body: &types::TrafficMatchingListCreateUpdate,
881    ) -> Result<types::TrafficMatchingListResponse, Error> {
882        self.post(&format!("v1/sites/{site_id}/traffic-matching-lists"), body)
883            .await
884    }
885
886    pub async fn update_traffic_matching_list(
887        &self,
888        site_id: &Uuid,
889        list_id: &Uuid,
890        body: &types::TrafficMatchingListCreateUpdate,
891    ) -> Result<types::TrafficMatchingListResponse, Error> {
892        self.put(
893            &format!("v1/sites/{site_id}/traffic-matching-lists/{list_id}"),
894            body,
895        )
896        .await
897    }
898
899    pub async fn delete_traffic_matching_list(
900        &self,
901        site_id: &Uuid,
902        list_id: &Uuid,
903    ) -> Result<(), Error> {
904        self.delete(&format!(
905            "v1/sites/{site_id}/traffic-matching-lists/{list_id}"
906        ))
907        .await
908    }
909
910    // ── Hotspot Vouchers ─────────────────────────────────────────────
911
912    pub async fn list_vouchers(
913        &self,
914        site_id: &Uuid,
915        offset: i64,
916        limit: i32,
917    ) -> Result<types::Page<types::VoucherResponse>, Error> {
918        self.get_with_params(
919            &format!("v1/sites/{site_id}/hotspot/vouchers"),
920            &[("offset", offset.to_string()), ("limit", limit.to_string())],
921        )
922        .await
923    }
924
925    pub async fn get_voucher(
926        &self,
927        site_id: &Uuid,
928        voucher_id: &Uuid,
929    ) -> Result<types::VoucherResponse, Error> {
930        self.get(&format!("v1/sites/{site_id}/hotspot/vouchers/{voucher_id}"))
931            .await
932    }
933
934    pub async fn create_vouchers(
935        &self,
936        site_id: &Uuid,
937        body: &types::VoucherCreateRequest,
938    ) -> Result<Vec<types::VoucherResponse>, Error> {
939        self.post(&format!("v1/sites/{site_id}/hotspot/vouchers"), body)
940            .await
941    }
942
943    pub async fn delete_voucher(
944        &self,
945        site_id: &Uuid,
946        voucher_id: &Uuid,
947    ) -> Result<types::VoucherDeletionResults, Error> {
948        self.delete_with_response(&format!("v1/sites/{site_id}/hotspot/vouchers/{voucher_id}"))
949            .await
950    }
951
952    pub async fn purge_vouchers(
953        &self,
954        site_id: &Uuid,
955        filter: &str,
956    ) -> Result<types::VoucherDeletionResults, Error> {
957        self.delete_with_params(
958            &format!("v1/sites/{site_id}/hotspot/vouchers"),
959            &[("filter", filter.to_owned())],
960        )
961        .await
962    }
963
964    // ── VPN (read-only) ──────────────────────────────────────────────
965
966    pub async fn list_vpn_servers(
967        &self,
968        site_id: &Uuid,
969        offset: i64,
970        limit: i32,
971    ) -> Result<types::Page<types::VpnServerResponse>, Error> {
972        self.get_with_params(
973            &format!("v1/sites/{site_id}/vpn/servers"),
974            &[("offset", offset.to_string()), ("limit", limit.to_string())],
975        )
976        .await
977    }
978
979    pub async fn list_vpn_tunnels(
980        &self,
981        site_id: &Uuid,
982        offset: i64,
983        limit: i32,
984    ) -> Result<types::Page<types::VpnTunnelResponse>, Error> {
985        self.get_with_params(
986            &format!("v1/sites/{site_id}/vpn/tunnels"),
987            &[("offset", offset.to_string()), ("limit", limit.to_string())],
988        )
989        .await
990    }
991
992    // ── WAN (read-only) ──────────────────────────────────────────────
993
994    pub async fn list_wans(
995        &self,
996        site_id: &Uuid,
997        offset: i64,
998        limit: i32,
999    ) -> Result<types::Page<types::WanResponse>, Error> {
1000        self.get_with_params(
1001            &format!("v1/sites/{site_id}/wans"),
1002            &[("offset", offset.to_string()), ("limit", limit.to_string())],
1003        )
1004        .await
1005    }
1006
1007    // ── DPI (read-only) ──────────────────────────────────────────────
1008
1009    pub async fn list_dpi_categories(
1010        &self,
1011        site_id: &Uuid,
1012        offset: i64,
1013        limit: i32,
1014    ) -> Result<types::Page<types::DpiCategoryResponse>, Error> {
1015        self.get_with_params(
1016            &format!("v1/sites/{site_id}/dpi/categories"),
1017            &[("offset", offset.to_string()), ("limit", limit.to_string())],
1018        )
1019        .await
1020    }
1021
1022    pub async fn list_dpi_applications(
1023        &self,
1024        site_id: &Uuid,
1025        offset: i64,
1026        limit: i32,
1027    ) -> Result<types::Page<types::DpiApplicationResponse>, Error> {
1028        self.get_with_params(
1029            &format!("v1/sites/{site_id}/dpi/applications"),
1030            &[("offset", offset.to_string()), ("limit", limit.to_string())],
1031        )
1032        .await
1033    }
1034
1035    // ── RADIUS (read-only) ───────────────────────────────────────────
1036
1037    pub async fn list_radius_profiles(
1038        &self,
1039        site_id: &Uuid,
1040        offset: i64,
1041        limit: i32,
1042    ) -> Result<types::Page<types::RadiusProfileResponse>, Error> {
1043        self.get_with_params(
1044            &format!("v1/sites/{site_id}/radius/profiles"),
1045            &[("offset", offset.to_string()), ("limit", limit.to_string())],
1046        )
1047        .await
1048    }
1049
1050    // ── Countries (no site scope) ────────────────────────────────────
1051
1052    pub async fn list_countries(
1053        &self,
1054        offset: i64,
1055        limit: i32,
1056    ) -> Result<types::Page<types::CountryResponse>, Error> {
1057        self.get_with_params(
1058            "v1/countries",
1059            &[("offset", offset.to_string()), ("limit", limit.to_string())],
1060        )
1061        .await
1062    }
1063}