dnslib/vendors/cloudflare/
client.rs1use 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#[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#[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}