Skip to main content

dnslib/vendors/cloudflare/
client.rs

1use reqwest::{Response, multipart};
2use serde_json::Value;
3
4use crate::core::error::{Error, Result};
5use crate::core::secret::ApiToken;
6use crate::vendors::http::HttpClient;
7
8/// Cloudflare DNS API client.
9///
10/// All Cloudflare responses use the envelope:
11/// `{"result": {...}, "success": true, "errors": [], "messages": []}`
12#[derive(Clone, Debug)]
13pub struct CloudflareClient {
14    http: HttpClient,
15}
16
17impl CloudflareClient {
18    pub fn new(base_url: String, token: ApiToken) -> Result<Self> {
19        Ok(Self {
20            http: HttpClient::new(base_url, token, false)?,
21        })
22    }
23
24    pub fn base_url(&self) -> &str {
25        &self.http.base_url
26    }
27
28    pub async fn get(&self, path: &str, params: &[(&str, String)]) -> Result<Value> {
29        let req = self.http.get(path).query(params);
30        let resp = self.http.send("GET", path, req).await?;
31        parse_response(resp).await
32    }
33
34    pub async fn post(&self, path: &str, body: &Value) -> Result<Value> {
35        let req = self.http.post(path).json(body);
36        let resp = self.http.send("POST", path, req).await?;
37        parse_response(resp).await
38    }
39
40    pub async fn post_multipart(
41        &self,
42        path: &str,
43        file_name: String,
44        file_bytes: Vec<u8>,
45    ) -> Result<Value> {
46        let file_part = multipart::Part::bytes(file_bytes)
47            .file_name(file_name)
48            .mime_str("text/plain")
49            .map_err(Error::Mime)?;
50        let form = multipart::Form::new().part("file", file_part);
51        let req = self.http.post(path).multipart(form);
52        let resp = self.http.send("POST", path, req).await?;
53        parse_response(resp).await
54    }
55
56    pub async fn get_text(&self, path: &str, params: &[(&str, String)]) -> Result<String> {
57        let req = self.http.get(path).query(params);
58        let resp = self.http.send("GET", path, req).await?;
59        parse_text_response(resp).await
60    }
61
62    pub async fn delete(&self, path: &str) -> Result<Value> {
63        let req = self.http.delete(path);
64        let resp = self.http.send("DELETE", path, req).await?;
65        parse_response(resp).await
66    }
67}
68
69async fn parse_text_response(resp: Response) -> Result<String> {
70    let status = resp.status();
71    if status.is_success() {
72        return resp.text().await.map_err(Error::Network);
73    }
74    let text = resp.text().await.unwrap_or_default();
75    let message = serde_json::from_str::<serde_json::Value>(&text)
76        .ok()
77        .and_then(|body| {
78            body.get("errors")
79                .and_then(|e| e.as_array())
80                .and_then(|a| a.first())
81                .and_then(|e| e.get("message"))
82                .and_then(|m| m.as_str())
83                .map(ToOwned::to_owned)
84        })
85        .unwrap_or(text);
86    if status.as_u16() == 403 {
87        Err(Error::forbidden(message))
88    } else {
89        Err(Error::Api { message })
90    }
91}
92
93async fn parse_response(resp: Response) -> Result<Value> {
94    let status = resp.status();
95    let body: Value = resp.json().await.map_err(|e| {
96        if e.is_decode() {
97            Error::InvalidJson(e)
98        } else {
99            Error::Network(e)
100        }
101    })?;
102
103    let success = body
104        .get("success")
105        .and_then(|s| s.as_bool())
106        .unwrap_or(false);
107
108    if success {
109        return Ok(body.get("result").cloned().unwrap_or(Value::Null));
110    }
111
112    let message = body
113        .get("errors")
114        .and_then(|e| e.as_array())
115        .and_then(|a| a.first())
116        .and_then(|e| e.get("message"))
117        .and_then(|m| m.as_str())
118        .unwrap_or("unknown error")
119        .to_string();
120
121    if status.as_u16() == 403 {
122        return Err(Error::forbidden(message));
123    }
124
125    Err(Error::Api { message })
126}
127
128// ─── Tests ────────────────────────────────────────────────────────────────────
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use serde_json::json;
134
135    fn make_resp(status: u16, body: Value) -> reqwest::Response {
136        http::Response::builder()
137            .status(status)
138            .header("content-type", "application/json")
139            .body(body.to_string())
140            .map(reqwest::Response::from)
141            .unwrap()
142    }
143
144    #[tokio::test]
145    async fn success_envelope_returns_result() {
146        let resp = make_resp(
147            200,
148            json!({
149                "result": { "id": "zone123", "name": "example.com" },
150                "success": true,
151                "errors": [],
152                "messages": []
153            }),
154        );
155        let val = parse_response(resp).await.unwrap();
156        assert_eq!(val["id"], "zone123");
157        assert_eq!(val["name"], "example.com");
158    }
159
160    #[tokio::test]
161    async fn success_with_null_result_returns_null() {
162        let resp = make_resp(
163            200,
164            json!({ "result": null, "success": true, "errors": [], "messages": [] }),
165        );
166        let val = parse_response(resp).await.unwrap();
167        assert!(val.is_null());
168    }
169
170    #[tokio::test]
171    async fn forbidden_returns_forbidden_error() {
172        let resp = make_resp(
173            403,
174            json!({
175                "result": null,
176                "success": false,
177                "errors": [{ "code": 9109, "message": "Invalid access token" }],
178                "messages": []
179            }),
180        );
181        let err = parse_response(resp).await.unwrap_err();
182        assert!(
183            matches!(err, Error::Forbidden { ref message } if message == "Invalid access token")
184        );
185    }
186
187    #[tokio::test]
188    async fn api_error_returns_first_error_message() {
189        let resp = make_resp(
190            400,
191            json!({
192                "result": null,
193                "success": false,
194                "errors": [{ "code": 1001, "message": "zone not found" }],
195                "messages": []
196            }),
197        );
198        let err = parse_response(resp).await.unwrap_err();
199        assert!(matches!(err, Error::Api { ref message } if message == "zone not found"));
200    }
201
202    #[tokio::test]
203    async fn empty_errors_array_uses_unknown_error() {
204        let resp = make_resp(
205            500,
206            json!({ "result": null, "success": false, "errors": [], "messages": [] }),
207        );
208        let err = parse_response(resp).await.unwrap_err();
209        assert!(matches!(err, Error::Api { ref message } if message == "unknown error"));
210    }
211}