Skip to main content

dnslib/vendors/technitium/
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#[derive(Clone, Debug)]
9pub struct TechnitiumClient {
10    http: HttpClient,
11}
12
13impl TechnitiumClient {
14    pub fn new(base_url: String, token: ApiToken) -> Result<Self> {
15        Ok(Self {
16            http: HttpClient::new(base_url, token, true)?,
17        })
18    }
19
20    pub fn base_url(&self) -> &str {
21        &self.http.base_url
22    }
23
24    /// GET with query params.
25    pub async fn get(&self, path: &str, params: &[(&str, &str)]) -> Result<Value> {
26        let req = self.http.get(path).query(params);
27        let resp = self.http.send("GET", path, req).await?;
28        parse_response(resp).await
29    }
30
31    /// POST with form-encoded body.
32    pub async fn post(&self, path: &str, form: &[(&str, &str)]) -> Result<Value> {
33        let req = self.http.post(path).form(form);
34        let resp = self.http.send("POST", path, req).await?;
35        parse_response(resp).await
36    }
37
38    /// GET that returns a plain-text body (e.g. zone file export).
39    pub async fn get_text(&self, path: &str, params: &[(&str, &str)]) -> Result<String> {
40        let req = self.http.get(path).query(params);
41        let resp = self.http.send("GET", path, req).await?;
42        let status = resp.status();
43        if status.is_success() {
44            return resp.text().await.map_err(Error::Network);
45        }
46        let message = resp
47            .json::<serde_json::Value>()
48            .await
49            .ok()
50            .and_then(|b| {
51                b.get("errorMessage")
52                    .and_then(|m| m.as_str())
53                    .map(ToOwned::to_owned)
54            })
55            .unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
56        Err(Error::Api { message })
57    }
58
59    /// POST multipart/form-data with a zone file part.
60    /// Query params carry the zone name and overwrite flags.
61    pub async fn post_file(
62        &self,
63        path: &str,
64        params: &[(&str, &str)],
65        file_name: String,
66        file_bytes: Vec<u8>,
67    ) -> Result<Value> {
68        let file_part = multipart::Part::bytes(file_bytes)
69            .file_name(file_name)
70            .mime_str("text/plain")
71            .map_err(Error::Mime)?;
72        let form = multipart::Form::new().part("zoneFile", file_part);
73        let req = self.http.post(path).query(params).multipart(form);
74        let resp = self.http.send("POST", path, req).await?;
75        parse_response(resp).await
76    }
77}
78
79async fn parse_response(resp: Response) -> Result<Value> {
80    let status = resp.status();
81    let body: Value = resp.json().await.map_err(|e| {
82        // reqwest uses the same error type for network and decode errors;
83        // if we got a response the failure is a decode error
84        if e.is_decode() {
85            Error::InvalidJson(e)
86        } else {
87            Error::Network(e)
88        }
89    })?;
90
91    match body.get("status").and_then(|s| s.as_str()) {
92        Some("ok") => Ok(body),
93        Some("error") => {
94            let message = body
95                .get("errorMessage")
96                .and_then(|m| m.as_str())
97                .unwrap_or("unknown error")
98                .to_string();
99            Err(Error::Api { message })
100        }
101        _ if !status.is_success() => Err(Error::Http {
102            status: status.as_u16(),
103            body: body.to_string(),
104        }),
105        _ => Ok(body),
106    }
107}