Skip to main content

dnslib/vendors/pangolin/
client.rs

1use reqwest::Response;
2use serde_json::Value;
3
4use crate::core::error::{Error, Result};
5use crate::core::secret::ApiToken;
6use crate::vendors::http::HttpClient;
7
8/// Pangolin API client.
9///
10/// All Pangolin responses use the envelope:
11/// `{"data": {...}, "success": true, "error": false, "message": "...", "status": 200}`
12#[derive(Clone, Debug)]
13pub struct PangolinClient {
14    http: HttpClient,
15    pub org_id: String,
16}
17
18impl PangolinClient {
19    pub fn new(base_url: String, token: ApiToken, org_id: String) -> Result<Self> {
20        Ok(Self {
21            http: HttpClient::new(base_url, token, false)?,
22            org_id,
23        })
24    }
25
26    pub fn base_url(&self) -> &str {
27        &self.http.base_url
28    }
29
30    /// GET the given path (relative to base_url) with query parameters.
31    /// Strips the Pangolin envelope and returns the inner `data` value.
32    pub async fn get(&self, path: &str, params: &[(&str, String)]) -> Result<Value> {
33        let req = self.http.get(path).query(params);
34        let resp = self.http.send("GET", path, req).await?;
35        parse_response(resp).await
36    }
37}
38
39async fn parse_response(resp: Response) -> Result<Value> {
40    let http_status = resp.status();
41
42    // Always attempt JSON parsing first — Pangolin returns structured errors even for
43    // 4xx/5xx responses (e.g. 403 "Key does not have root access").
44    let body: Value = resp.json().await.map_err(|e| {
45        if e.is_decode() {
46            Error::InvalidJson(e)
47        } else {
48            Error::Network(e)
49        }
50    })?;
51
52    let success = body
53        .get("success")
54        .and_then(|s| s.as_bool())
55        .unwrap_or(false);
56    let api_status = body.get("status").and_then(|s| s.as_u64()).unwrap_or(0);
57
58    if success {
59        return Ok(body.get("data").cloned().unwrap_or(Value::Null));
60    }
61
62    // Extract the human-readable message from the envelope.
63    let message = body
64        .get("message")
65        .and_then(|m| m.as_str())
66        .unwrap_or("unknown error")
67        .to_string();
68
69    // 403 — insufficient API key permissions.
70    if http_status.as_u16() == 403 || api_status == 403 {
71        return Err(Error::forbidden(message));
72    }
73
74    // Other API-level errors (success: false with a message).
75    if body.get("error").and_then(|e| e.as_bool()).unwrap_or(false) || !http_status.is_success() {
76        return Err(Error::Api { message });
77    }
78
79    Err(Error::Http {
80        status: http_status.as_u16(),
81        body: body.to_string(),
82    })
83}
84
85// ─── Tests ────────────────────────────────────────────────────────────────────
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use serde_json::json;
91
92    fn make_resp(status: u16, body: Value) -> reqwest::Response {
93        http::Response::builder()
94            .status(status)
95            .header("content-type", "application/json")
96            .body(body.to_string())
97            .map(reqwest::Response::from)
98            .unwrap()
99    }
100
101    #[tokio::test]
102    async fn success_envelope_returns_data() {
103        let resp = make_resp(
104            200,
105            json!({
106                "data": { "orgs": [] },
107                "success": true,
108                "error": false,
109                "message": "ok",
110                "status": 200
111            }),
112        );
113        let val = parse_response(resp).await.unwrap();
114        assert_eq!(val, json!({ "orgs": [] }));
115    }
116
117    #[tokio::test]
118    async fn forbidden_envelope_returns_forbidden_error() {
119        let resp = make_resp(
120            403,
121            json!({
122                "data": null,
123                "success": false,
124                "error": true,
125                "message": "Key does not have root access",
126                "status": 403,
127                "stack": null
128            }),
129        );
130        let err = parse_response(resp).await.unwrap_err();
131        assert!(
132            matches!(err, Error::Forbidden { ref message } if message == "Key does not have root access")
133        );
134    }
135
136    #[tokio::test]
137    async fn api_error_envelope_returns_api_error() {
138        let resp = make_resp(
139            400,
140            json!({
141                "data": null,
142                "success": false,
143                "error": true,
144                "message": "zone not found",
145                "status": 400,
146                "stack": null
147            }),
148        );
149        let err = parse_response(resp).await.unwrap_err();
150        assert!(matches!(err, Error::Api { ref message } if message == "zone not found"));
151    }
152}