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    ) -> Result<types::DeviceDetailsResponse, Error> {
353        #[derive(Serialize)]
354        #[serde(rename_all = "camelCase")]
355        struct Body<'a> {
356            mac_address: &'a str,
357            ignore_device_limit: bool,
358        }
359
360        self.post(
361            &format!("v1/sites/{site_id}/devices"),
362            &Body {
363                mac_address: mac,
364                ignore_device_limit: false,
365            },
366        )
367        .await
368    }
369
370    pub async fn remove_device(&self, site_id: &Uuid, device_id: &Uuid) -> Result<(), Error> {
371        self.delete(&format!("v1/sites/{site_id}/devices/{device_id}"))
372            .await
373    }
374
375    pub async fn device_action(
376        &self,
377        site_id: &Uuid,
378        device_id: &Uuid,
379        action: &str,
380    ) -> Result<(), Error> {
381        #[derive(Serialize)]
382        struct Body<'a> {
383            action: &'a str,
384        }
385
386        self.post_no_response(
387            &format!("v1/sites/{site_id}/devices/{device_id}/actions"),
388            &Body { action },
389        )
390        .await
391    }
392
393    pub async fn port_action(
394        &self,
395        site_id: &Uuid,
396        device_id: &Uuid,
397        port_idx: u32,
398        action: &str,
399    ) -> Result<(), Error> {
400        #[derive(Serialize)]
401        struct Body<'a> {
402            action: &'a str,
403        }
404
405        self.post_no_response(
406            &format!("v1/sites/{site_id}/devices/{device_id}/interfaces/ports/{port_idx}/actions"),
407            &Body { action },
408        )
409        .await
410    }
411
412    pub async fn list_pending_devices(
413        &self,
414        offset: i64,
415        limit: i32,
416    ) -> Result<types::Page<types::PendingDeviceResponse>, Error> {
417        self.get_with_params(
418            "v1/pending-devices",
419            &[("offset", offset.to_string()), ("limit", limit.to_string())],
420        )
421        .await
422    }
423
424    pub async fn list_device_tags(
425        &self,
426        site_id: &Uuid,
427        offset: i64,
428        limit: i32,
429    ) -> Result<types::Page<types::DeviceTagResponse>, Error> {
430        self.get_with_params(
431            &format!("v1/sites/{site_id}/device-tags"),
432            &[("offset", offset.to_string()), ("limit", limit.to_string())],
433        )
434        .await
435    }
436
437    // ── Clients ──────────────────────────────────────────────────────
438
439    pub async fn list_clients(
440        &self,
441        site_id: &Uuid,
442        offset: i64,
443        limit: i32,
444    ) -> Result<types::Page<types::ClientResponse>, Error> {
445        self.get_with_params(
446            &format!("v1/sites/{site_id}/clients"),
447            &[("offset", offset.to_string()), ("limit", limit.to_string())],
448        )
449        .await
450    }
451
452    pub async fn get_client(
453        &self,
454        site_id: &Uuid,
455        client_id: &Uuid,
456    ) -> Result<types::ClientDetailsResponse, Error> {
457        self.get(&format!("v1/sites/{site_id}/clients/{client_id}"))
458            .await
459    }
460
461    pub async fn client_action(
462        &self,
463        site_id: &Uuid,
464        client_id: &Uuid,
465        action: &str,
466    ) -> Result<types::ClientActionResponse, Error> {
467        #[derive(Serialize)]
468        struct Body<'a> {
469            action: &'a str,
470        }
471
472        self.post(
473            &format!("v1/sites/{site_id}/clients/{client_id}/actions"),
474            &Body { action },
475        )
476        .await
477    }
478
479    // ── Networks ─────────────────────────────────────────────────────
480
481    pub async fn list_networks(
482        &self,
483        site_id: &Uuid,
484        offset: i64,
485        limit: i32,
486    ) -> Result<types::Page<types::NetworkResponse>, Error> {
487        self.get_with_params(
488            &format!("v1/sites/{site_id}/networks"),
489            &[("offset", offset.to_string()), ("limit", limit.to_string())],
490        )
491        .await
492    }
493
494    pub async fn get_network(
495        &self,
496        site_id: &Uuid,
497        network_id: &Uuid,
498    ) -> Result<types::NetworkDetailsResponse, Error> {
499        self.get(&format!("v1/sites/{site_id}/networks/{network_id}"))
500            .await
501    }
502
503    pub async fn create_network(
504        &self,
505        site_id: &Uuid,
506        body: &types::NetworkCreateUpdate,
507    ) -> Result<types::NetworkDetailsResponse, Error> {
508        self.post(&format!("v1/sites/{site_id}/networks"), body)
509            .await
510    }
511
512    pub async fn update_network(
513        &self,
514        site_id: &Uuid,
515        network_id: &Uuid,
516        body: &types::NetworkCreateUpdate,
517    ) -> Result<types::NetworkDetailsResponse, Error> {
518        self.put(&format!("v1/sites/{site_id}/networks/{network_id}"), body)
519            .await
520    }
521
522    pub async fn delete_network(&self, site_id: &Uuid, network_id: &Uuid) -> Result<(), Error> {
523        self.delete(&format!("v1/sites/{site_id}/networks/{network_id}"))
524            .await
525    }
526
527    pub async fn get_network_references(
528        &self,
529        site_id: &Uuid,
530        network_id: &Uuid,
531    ) -> Result<types::NetworkReferencesResponse, Error> {
532        self.get(&format!(
533            "v1/sites/{site_id}/networks/{network_id}/references"
534        ))
535        .await
536    }
537
538    // ── WiFi Broadcasts ──────────────────────────────────────────────
539
540    pub async fn list_wifi_broadcasts(
541        &self,
542        site_id: &Uuid,
543        offset: i64,
544        limit: i32,
545    ) -> Result<types::Page<types::WifiBroadcastResponse>, Error> {
546        self.get_with_params(
547            &format!("v1/sites/{site_id}/wifi/broadcasts"),
548            &[("offset", offset.to_string()), ("limit", limit.to_string())],
549        )
550        .await
551    }
552
553    pub async fn get_wifi_broadcast(
554        &self,
555        site_id: &Uuid,
556        broadcast_id: &Uuid,
557    ) -> Result<types::WifiBroadcastDetailsResponse, Error> {
558        self.get(&format!(
559            "v1/sites/{site_id}/wifi/broadcasts/{broadcast_id}"
560        ))
561        .await
562    }
563
564    pub async fn create_wifi_broadcast(
565        &self,
566        site_id: &Uuid,
567        body: &types::WifiBroadcastCreateUpdate,
568    ) -> Result<types::WifiBroadcastDetailsResponse, Error> {
569        self.post(&format!("v1/sites/{site_id}/wifi/broadcasts"), body)
570            .await
571    }
572
573    pub async fn update_wifi_broadcast(
574        &self,
575        site_id: &Uuid,
576        broadcast_id: &Uuid,
577        body: &types::WifiBroadcastCreateUpdate,
578    ) -> Result<types::WifiBroadcastDetailsResponse, Error> {
579        self.put(
580            &format!("v1/sites/{site_id}/wifi/broadcasts/{broadcast_id}"),
581            body,
582        )
583        .await
584    }
585
586    pub async fn delete_wifi_broadcast(
587        &self,
588        site_id: &Uuid,
589        broadcast_id: &Uuid,
590    ) -> Result<(), Error> {
591        self.delete(&format!(
592            "v1/sites/{site_id}/wifi/broadcasts/{broadcast_id}"
593        ))
594        .await
595    }
596
597    // ── Firewall Policies ────────────────────────────────────────────
598
599    pub async fn list_firewall_policies(
600        &self,
601        site_id: &Uuid,
602        offset: i64,
603        limit: i32,
604    ) -> Result<types::Page<types::FirewallPolicyResponse>, Error> {
605        self.get_with_params(
606            &format!("v1/sites/{site_id}/firewall/policies"),
607            &[("offset", offset.to_string()), ("limit", limit.to_string())],
608        )
609        .await
610    }
611
612    pub async fn get_firewall_policy(
613        &self,
614        site_id: &Uuid,
615        policy_id: &Uuid,
616    ) -> Result<types::FirewallPolicyResponse, Error> {
617        self.get(&format!("v1/sites/{site_id}/firewall/policies/{policy_id}"))
618            .await
619    }
620
621    pub async fn create_firewall_policy(
622        &self,
623        site_id: &Uuid,
624        body: &types::FirewallPolicyCreateUpdate,
625    ) -> Result<types::FirewallPolicyResponse, Error> {
626        self.post(&format!("v1/sites/{site_id}/firewall/policies"), body)
627            .await
628    }
629
630    pub async fn update_firewall_policy(
631        &self,
632        site_id: &Uuid,
633        policy_id: &Uuid,
634        body: &types::FirewallPolicyCreateUpdate,
635    ) -> Result<types::FirewallPolicyResponse, Error> {
636        self.put(
637            &format!("v1/sites/{site_id}/firewall/policies/{policy_id}"),
638            body,
639        )
640        .await
641    }
642
643    pub async fn patch_firewall_policy(
644        &self,
645        site_id: &Uuid,
646        policy_id: &Uuid,
647        body: &types::FirewallPolicyPatch,
648    ) -> Result<types::FirewallPolicyResponse, Error> {
649        self.patch(
650            &format!("v1/sites/{site_id}/firewall/policies/{policy_id}"),
651            body,
652        )
653        .await
654    }
655
656    pub async fn delete_firewall_policy(
657        &self,
658        site_id: &Uuid,
659        policy_id: &Uuid,
660    ) -> Result<(), Error> {
661        self.delete(&format!("v1/sites/{site_id}/firewall/policies/{policy_id}"))
662            .await
663    }
664
665    pub async fn get_firewall_policy_ordering(
666        &self,
667        site_id: &Uuid,
668    ) -> Result<types::FirewallPolicyOrdering, Error> {
669        self.get(&format!("v1/sites/{site_id}/firewall/policies/ordering"))
670            .await
671    }
672
673    pub async fn set_firewall_policy_ordering(
674        &self,
675        site_id: &Uuid,
676        body: &types::FirewallPolicyOrdering,
677    ) -> Result<types::FirewallPolicyOrdering, Error> {
678        self.put(
679            &format!("v1/sites/{site_id}/firewall/policies/ordering"),
680            body,
681        )
682        .await
683    }
684
685    // ── Firewall Zones ───────────────────────────────────────────────
686
687    pub async fn list_firewall_zones(
688        &self,
689        site_id: &Uuid,
690        offset: i64,
691        limit: i32,
692    ) -> Result<types::Page<types::FirewallZoneResponse>, Error> {
693        self.get_with_params(
694            &format!("v1/sites/{site_id}/firewall/zones"),
695            &[("offset", offset.to_string()), ("limit", limit.to_string())],
696        )
697        .await
698    }
699
700    pub async fn get_firewall_zone(
701        &self,
702        site_id: &Uuid,
703        zone_id: &Uuid,
704    ) -> Result<types::FirewallZoneResponse, Error> {
705        self.get(&format!("v1/sites/{site_id}/firewall/zones/{zone_id}"))
706            .await
707    }
708
709    pub async fn create_firewall_zone(
710        &self,
711        site_id: &Uuid,
712        body: &types::FirewallZoneCreateUpdate,
713    ) -> Result<types::FirewallZoneResponse, Error> {
714        self.post(&format!("v1/sites/{site_id}/firewall/zones"), body)
715            .await
716    }
717
718    pub async fn update_firewall_zone(
719        &self,
720        site_id: &Uuid,
721        zone_id: &Uuid,
722        body: &types::FirewallZoneCreateUpdate,
723    ) -> Result<types::FirewallZoneResponse, Error> {
724        self.put(
725            &format!("v1/sites/{site_id}/firewall/zones/{zone_id}"),
726            body,
727        )
728        .await
729    }
730
731    pub async fn delete_firewall_zone(&self, site_id: &Uuid, zone_id: &Uuid) -> Result<(), Error> {
732        self.delete(&format!("v1/sites/{site_id}/firewall/zones/{zone_id}"))
733            .await
734    }
735
736    // ── ACL Rules ────────────────────────────────────────────────────
737
738    pub async fn list_acl_rules(
739        &self,
740        site_id: &Uuid,
741        offset: i64,
742        limit: i32,
743    ) -> Result<types::Page<types::AclRuleResponse>, Error> {
744        self.get_with_params(
745            &format!("v1/sites/{site_id}/acl-rules"),
746            &[("offset", offset.to_string()), ("limit", limit.to_string())],
747        )
748        .await
749    }
750
751    pub async fn get_acl_rule(
752        &self,
753        site_id: &Uuid,
754        rule_id: &Uuid,
755    ) -> Result<types::AclRuleResponse, Error> {
756        self.get(&format!("v1/sites/{site_id}/acl-rules/{rule_id}"))
757            .await
758    }
759
760    pub async fn create_acl_rule(
761        &self,
762        site_id: &Uuid,
763        body: &types::AclRuleCreateUpdate,
764    ) -> Result<types::AclRuleResponse, Error> {
765        self.post(&format!("v1/sites/{site_id}/acl-rules"), body)
766            .await
767    }
768
769    pub async fn update_acl_rule(
770        &self,
771        site_id: &Uuid,
772        rule_id: &Uuid,
773        body: &types::AclRuleCreateUpdate,
774    ) -> Result<types::AclRuleResponse, Error> {
775        self.put(&format!("v1/sites/{site_id}/acl-rules/{rule_id}"), body)
776            .await
777    }
778
779    pub async fn delete_acl_rule(&self, site_id: &Uuid, rule_id: &Uuid) -> Result<(), Error> {
780        self.delete(&format!("v1/sites/{site_id}/acl-rules/{rule_id}"))
781            .await
782    }
783
784    pub async fn get_acl_rule_ordering(
785        &self,
786        site_id: &Uuid,
787    ) -> Result<types::AclRuleOrdering, Error> {
788        self.get(&format!("v1/sites/{site_id}/acl-rules/ordering"))
789            .await
790    }
791
792    pub async fn set_acl_rule_ordering(
793        &self,
794        site_id: &Uuid,
795        body: &types::AclRuleOrdering,
796    ) -> Result<types::AclRuleOrdering, Error> {
797        self.put(&format!("v1/sites/{site_id}/acl-rules/ordering"), body)
798            .await
799    }
800
801    // ── DNS Policies ─────────────────────────────────────────────────
802
803    pub async fn list_dns_policies(
804        &self,
805        site_id: &Uuid,
806        offset: i64,
807        limit: i32,
808    ) -> Result<types::Page<types::DnsPolicyResponse>, Error> {
809        self.get_with_params(
810            &format!("v1/sites/{site_id}/dns/policies"),
811            &[("offset", offset.to_string()), ("limit", limit.to_string())],
812        )
813        .await
814    }
815
816    pub async fn get_dns_policy(
817        &self,
818        site_id: &Uuid,
819        dns_id: &Uuid,
820    ) -> Result<types::DnsPolicyResponse, Error> {
821        self.get(&format!("v1/sites/{site_id}/dns/policies/{dns_id}"))
822            .await
823    }
824
825    pub async fn create_dns_policy(
826        &self,
827        site_id: &Uuid,
828        body: &types::DnsPolicyCreateUpdate,
829    ) -> Result<types::DnsPolicyResponse, Error> {
830        self.post(&format!("v1/sites/{site_id}/dns/policies"), body)
831            .await
832    }
833
834    pub async fn update_dns_policy(
835        &self,
836        site_id: &Uuid,
837        dns_id: &Uuid,
838        body: &types::DnsPolicyCreateUpdate,
839    ) -> Result<types::DnsPolicyResponse, Error> {
840        self.put(&format!("v1/sites/{site_id}/dns/policies/{dns_id}"), body)
841            .await
842    }
843
844    pub async fn delete_dns_policy(&self, site_id: &Uuid, dns_id: &Uuid) -> Result<(), Error> {
845        self.delete(&format!("v1/sites/{site_id}/dns/policies/{dns_id}"))
846            .await
847    }
848
849    // ── Traffic Matching Lists ───────────────────────────────────────
850
851    pub async fn list_traffic_matching_lists(
852        &self,
853        site_id: &Uuid,
854        offset: i64,
855        limit: i32,
856    ) -> Result<types::Page<types::TrafficMatchingListResponse>, Error> {
857        self.get_with_params(
858            &format!("v1/sites/{site_id}/traffic-matching-lists"),
859            &[("offset", offset.to_string()), ("limit", limit.to_string())],
860        )
861        .await
862    }
863
864    pub async fn get_traffic_matching_list(
865        &self,
866        site_id: &Uuid,
867        list_id: &Uuid,
868    ) -> Result<types::TrafficMatchingListResponse, Error> {
869        self.get(&format!(
870            "v1/sites/{site_id}/traffic-matching-lists/{list_id}"
871        ))
872        .await
873    }
874
875    pub async fn create_traffic_matching_list(
876        &self,
877        site_id: &Uuid,
878        body: &types::TrafficMatchingListCreateUpdate,
879    ) -> Result<types::TrafficMatchingListResponse, Error> {
880        self.post(&format!("v1/sites/{site_id}/traffic-matching-lists"), body)
881            .await
882    }
883
884    pub async fn update_traffic_matching_list(
885        &self,
886        site_id: &Uuid,
887        list_id: &Uuid,
888        body: &types::TrafficMatchingListCreateUpdate,
889    ) -> Result<types::TrafficMatchingListResponse, Error> {
890        self.put(
891            &format!("v1/sites/{site_id}/traffic-matching-lists/{list_id}"),
892            body,
893        )
894        .await
895    }
896
897    pub async fn delete_traffic_matching_list(
898        &self,
899        site_id: &Uuid,
900        list_id: &Uuid,
901    ) -> Result<(), Error> {
902        self.delete(&format!(
903            "v1/sites/{site_id}/traffic-matching-lists/{list_id}"
904        ))
905        .await
906    }
907
908    // ── Hotspot Vouchers ─────────────────────────────────────────────
909
910    pub async fn list_vouchers(
911        &self,
912        site_id: &Uuid,
913        offset: i64,
914        limit: i32,
915    ) -> Result<types::Page<types::VoucherResponse>, Error> {
916        self.get_with_params(
917            &format!("v1/sites/{site_id}/hotspot/vouchers"),
918            &[("offset", offset.to_string()), ("limit", limit.to_string())],
919        )
920        .await
921    }
922
923    pub async fn get_voucher(
924        &self,
925        site_id: &Uuid,
926        voucher_id: &Uuid,
927    ) -> Result<types::VoucherResponse, Error> {
928        self.get(&format!("v1/sites/{site_id}/hotspot/vouchers/{voucher_id}"))
929            .await
930    }
931
932    pub async fn create_vouchers(
933        &self,
934        site_id: &Uuid,
935        body: &types::VoucherCreateRequest,
936    ) -> Result<Vec<types::VoucherResponse>, Error> {
937        self.post(&format!("v1/sites/{site_id}/hotspot/vouchers"), body)
938            .await
939    }
940
941    pub async fn delete_voucher(
942        &self,
943        site_id: &Uuid,
944        voucher_id: &Uuid,
945    ) -> Result<types::VoucherDeletionResults, Error> {
946        self.delete_with_response(&format!("v1/sites/{site_id}/hotspot/vouchers/{voucher_id}"))
947            .await
948    }
949
950    pub async fn purge_vouchers(
951        &self,
952        site_id: &Uuid,
953        filter: &str,
954    ) -> Result<types::VoucherDeletionResults, Error> {
955        self.delete_with_params(
956            &format!("v1/sites/{site_id}/hotspot/vouchers"),
957            &[("filter", filter.to_owned())],
958        )
959        .await
960    }
961
962    // ── VPN (read-only) ──────────────────────────────────────────────
963
964    pub async fn list_vpn_servers(
965        &self,
966        site_id: &Uuid,
967        offset: i64,
968        limit: i32,
969    ) -> Result<types::Page<types::VpnServerResponse>, Error> {
970        self.get_with_params(
971            &format!("v1/sites/{site_id}/vpn/servers"),
972            &[("offset", offset.to_string()), ("limit", limit.to_string())],
973        )
974        .await
975    }
976
977    pub async fn list_vpn_tunnels(
978        &self,
979        site_id: &Uuid,
980        offset: i64,
981        limit: i32,
982    ) -> Result<types::Page<types::VpnTunnelResponse>, Error> {
983        self.get_with_params(
984            &format!("v1/sites/{site_id}/vpn/tunnels"),
985            &[("offset", offset.to_string()), ("limit", limit.to_string())],
986        )
987        .await
988    }
989
990    // ── WAN (read-only) ──────────────────────────────────────────────
991
992    pub async fn list_wans(
993        &self,
994        site_id: &Uuid,
995        offset: i64,
996        limit: i32,
997    ) -> Result<types::Page<types::WanResponse>, Error> {
998        self.get_with_params(
999            &format!("v1/sites/{site_id}/wans"),
1000            &[("offset", offset.to_string()), ("limit", limit.to_string())],
1001        )
1002        .await
1003    }
1004
1005    // ── DPI (read-only) ──────────────────────────────────────────────
1006
1007    pub async fn list_dpi_categories(
1008        &self,
1009        site_id: &Uuid,
1010        offset: i64,
1011        limit: i32,
1012    ) -> Result<types::Page<types::DpiCategoryResponse>, Error> {
1013        self.get_with_params(
1014            &format!("v1/sites/{site_id}/dpi/categories"),
1015            &[("offset", offset.to_string()), ("limit", limit.to_string())],
1016        )
1017        .await
1018    }
1019
1020    pub async fn list_dpi_applications(
1021        &self,
1022        site_id: &Uuid,
1023        offset: i64,
1024        limit: i32,
1025    ) -> Result<types::Page<types::DpiApplicationResponse>, Error> {
1026        self.get_with_params(
1027            &format!("v1/sites/{site_id}/dpi/applications"),
1028            &[("offset", offset.to_string()), ("limit", limit.to_string())],
1029        )
1030        .await
1031    }
1032
1033    // ── RADIUS (read-only) ───────────────────────────────────────────
1034
1035    pub async fn list_radius_profiles(
1036        &self,
1037        site_id: &Uuid,
1038        offset: i64,
1039        limit: i32,
1040    ) -> Result<types::Page<types::RadiusProfileResponse>, Error> {
1041        self.get_with_params(
1042            &format!("v1/sites/{site_id}/radius/profiles"),
1043            &[("offset", offset.to_string()), ("limit", limit.to_string())],
1044        )
1045        .await
1046    }
1047
1048    // ── Countries (no site scope) ────────────────────────────────────
1049
1050    pub async fn list_countries(
1051        &self,
1052        offset: i64,
1053        limit: i32,
1054    ) -> Result<types::Page<types::CountryResponse>, Error> {
1055        self.get_with_params(
1056            "v1/countries",
1057            &[("offset", offset.to_string()), ("limit", limit.to_string())],
1058        )
1059        .await
1060    }
1061}