icinga_client/
client.rs

1use std::{convert::TryFrom, fmt::Display, fs, io, path::PathBuf, str::FromStr};
2
3pub use reqwest::blocking::RequestBuilder;
4pub use reqwest::header;
5pub use reqwest::Method;
6use reqwest::{blocking::ClientBuilder, Certificate, StatusCode};
7use serde::{Deserialize, Serialize};
8use url::{ParseError, Url};
9
10/// An http(s) URL.
11#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
12#[serde(try_from = "Url", into = "Url")]
13pub struct IcingaUrl {
14    url: Url,
15}
16
17impl IcingaUrl {
18    pub fn get(&self) -> &Url {
19        &self.url
20    }
21
22    pub fn take(self) -> Url {
23        self.url
24    }
25
26    pub fn host_str(&self) -> &str {
27        // [IcingaUrl]s always have a host
28        self.url.host_str().unwrap()
29    }
30
31    pub fn host(&self) -> url::Host<&str> {
32        // [IcingaUrl]s always have a host
33        self.url.host().unwrap()
34    }
35
36    pub fn split_credentials(&self) -> (Option<Credentials>, Self) {
37        let stripped = url_without_credentials(&self.url);
38        let user = self.url.username();
39        let password = self.url.password();
40        let credentials = if user.is_empty() && password.is_none() {
41            None
42        } else {
43            Some((user.to_string(), password.map(ToString::to_string)))
44        };
45
46        (credentials, Self { url: stripped })
47    }
48
49    pub fn set_username(&mut self, user: &str) {
50        // does not panic: url is not a mailto-link
51        self.url.set_username(user).unwrap()
52    }
53
54    pub fn set_password(&mut self, password: Option<&str>) {
55        // does not panic: url always has a base
56        self.url.set_password(password).unwrap()
57    }
58}
59
60impl TryFrom<Url> for IcingaUrl {
61    type Error = String;
62
63    fn try_from(url: Url) -> std::result::Result<Self, Self::Error> {
64        let scheme = url.scheme();
65        if scheme == "http" || scheme == "https" {
66            Ok(IcingaUrl { url })
67        } else {
68            Err(format!(
69                r#"Illegal url scheme "{}:" in url "{}". Only schemes "http(s)" are allowed."#,
70                scheme,
71                url.as_str()
72            ))
73        }
74    }
75}
76
77impl From<IcingaUrl> for Url {
78    fn from(u: IcingaUrl) -> Self {
79        u.take()
80    }
81}
82
83impl FromStr for IcingaUrl {
84    type Err = String;
85
86    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
87        let url = Url::from_str(s).map_err(|e| format!("Url '{}' is invalid: {}", s, e))?;
88        IcingaUrl::try_from(url)
89    }
90}
91
92fn url_without_credentials(url: &Url) -> Url {
93    let mut result: Url = url.clone();
94    result.set_username("").unwrap_or(());
95    result.set_password(Option::None).unwrap_or(());
96
97    result
98}
99
100#[derive(Debug)]
101pub enum Error {
102    ReqwestError(reqwest::Error),
103    SerdeError(serde_json::Error),
104    IcingaError(String),
105    CertReadError(io::Error),
106    Unauthorized,
107}
108
109impl Display for Error {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        write!(f, "{:?}", self)
112    }
113}
114
115impl From<reqwest::Error> for Error {
116    fn from(e: reqwest::Error) -> Self {
117        Error::ReqwestError(e)
118    }
119}
120
121impl From<serde_json::Error> for Error {
122    fn from(e: serde_json::Error) -> Self {
123        Error::SerdeError(e)
124    }
125}
126
127impl std::error::Error for Error {}
128
129pub type Result<T> = std::result::Result<T, Error>;
130pub type Credentials = (String, Option<String>);
131
132/// A blocking icinga API client
133pub struct Client {
134    client: reqwest::blocking::Client,
135    base_uri: Url,
136    credentials: Option<Credentials>,
137}
138
139impl Client {
140    /// Create a new client with [url] as base uri. If provided, uses the given certificate to
141    /// authenticate the server.
142    pub fn new(
143        url: IcingaUrl,
144        maybe_cert_path: Option<PathBuf>,
145        credentials: Option<Credentials>,
146    ) -> Result<Client> {
147        let client_builder = ClientBuilder::new();
148        let client = if let Some(cert_path) = maybe_cert_path {
149            let cert_content: Vec<u8> = fs::read(cert_path).map_err(Error::CertReadError)?;
150            let cert = Certificate::from_pem(&cert_content)?;
151            client_builder.add_root_certificate(cert).build()
152        } else {
153            client_builder.build()
154        }?;
155        Ok(Client {
156            client,
157            base_uri: url.take(),
158            credentials,
159        })
160    }
161
162    /// Send a request to the icinga API as specified by the given [RequestBuilder].
163    ///
164    /// This method adds credentials and an `application/json` ACCEPT header to `request`, sends it
165    /// with the underlying [Client] and deserializes the response.
166    pub fn send_request<T: serde::de::DeserializeOwned>(
167        &self,
168        request: RequestBuilder,
169    ) -> Result<T> {
170        send_request_with_credentials(&self.credentials, request)
171    }
172
173    /// Get a [RequestBuilder] from the underlying [Client] for the given `path` under
174    /// `self.base_uri`
175    pub fn request(
176        &self,
177        method: reqwest::Method,
178        path: &str,
179    ) -> std::result::Result<RequestBuilder, ParseError> {
180        Ok(self
181            .client
182            .request(method, join_path(&self.base_uri, path)?))
183    }
184}
185
186fn send_request_with_credentials<T: serde::de::DeserializeOwned>(
187    credentials: &Option<Credentials>,
188    request: RequestBuilder,
189) -> Result<T> {
190    let request = if let Some((api_name, api_pass)) = credentials {
191        request.basic_auth(api_name, api_pass.as_ref())
192    } else {
193        request
194    };
195    let response = request.header(header::ACCEPT, "application/json").send()?;
196
197    let status = response.status();
198    if status.is_success() {
199        Ok(response.json::<T>()?)
200    } else if status == StatusCode::UNAUTHORIZED {
201        Err(Error::Unauthorized)
202    } else {
203        let body = response.text()?;
204        Err(Error::IcingaError(format!(
205            "ERROR {} {} {}",
206            status.as_str(),
207            status.canonical_reason().unwrap_or(""),
208            body
209        )))?
210    }
211}
212
213fn join_path(url: &Url, path: &str) -> std::result::Result<Url, ParseError> {
214    let mut result: Url = url.clone();
215    if !result.path().ends_with("/") {
216        let relative_original_path: String = result.path().chars().chain("/".chars()).collect();
217        result.set_path(&relative_original_path);
218    }
219    result.join(path.trim_start_matches("/"))
220}
221
222#[cfg(test)]
223mod test {
224    use serde_json::Value;
225
226    use super::*;
227
228    #[test]
229    fn test_base_uri_with_appended_path_from_url() {
230        assert_eq!(
231            join_path(
232                &Url::parse("https://example.com:8443/hello/world?what=42").unwrap(),
233                "/and/then/some?queries=42"
234            )
235            .unwrap(),
236            Url::parse("https://example.com:8443/hello/world/and/then/some?queries=42").unwrap()
237        );
238        assert_eq!(
239            join_path(
240                &Url::parse("https://example.com:8443").unwrap(),
241                "/and/then/some?queries=42"
242            )
243            .unwrap(),
244            Url::parse("https://example.com:8443/and/then/some?queries=42").unwrap()
245        );
246    }
247
248    #[test]
249    fn test_strip_credentials_no_username_but_password() {
250        let url: IcingaUrl = "http://:mutti123@example.com".parse().unwrap();
251        assert_eq!(
252            url.split_credentials(),
253            (
254                Some(("".to_string(), Some("mutti123".to_string()))),
255                "http://example.com".parse().unwrap()
256            )
257        );
258    }
259
260    #[test]
261    fn test_strip_credentials_username_password() {
262        let url: IcingaUrl = "http://test:mutti123@example.com".parse().unwrap();
263        assert_eq!(
264            url.split_credentials(),
265            (
266                Some(("test".to_string(), Some("mutti123".to_string()))),
267                "http://example.com".parse().unwrap()
268            )
269        );
270    }
271
272    #[test]
273    fn test_strip_credentials_nothing() {
274        let url: IcingaUrl = "http://example.com".parse().unwrap();
275        assert_eq!(
276            url.split_credentials(),
277            (None, "http://example.com".parse().unwrap())
278        );
279    }
280
281    #[test]
282    fn test_strip_credentials_only_username() {
283        let url: IcingaUrl = "http://user@example.com".parse().unwrap();
284        assert_eq!(
285            url.split_credentials(),
286            (
287                Some(("user".to_string(), None)),
288                "http://example.com".parse().unwrap()
289            )
290        );
291    }
292
293    #[test]
294    fn test_strip_credentials_empty_username_and_password() {
295        let url: IcingaUrl = "http://:@example.com".parse().unwrap();
296        // Note: this case is the same as using no auth at all; doing it like this seems like the
297        // best solution, as [Url] does not distinguish between empty password and no password
298        assert_eq!(
299            url.split_credentials(),
300            (None, "http://example.com".parse().unwrap())
301        );
302    }
303
304    #[test]
305    fn test_icinga_url_ok() {
306        let url = Url::parse("https://user:pass@example.com:8443").unwrap();
307        assert_eq!(
308            IcingaUrl::try_from(url.clone()),
309            Ok(IcingaUrl { url: url.clone() })
310        );
311
312        let url = Url::parse("http://user:pass@example.com:8443").unwrap();
313        assert_eq!(
314            IcingaUrl::try_from(url.clone()),
315            Ok(IcingaUrl { url: url.clone() })
316        );
317
318        let url = Url::parse("http://example.com").unwrap();
319        assert_eq!(
320            IcingaUrl::try_from(url.clone()),
321            Ok(IcingaUrl { url: url.clone() })
322        );
323    }
324
325    #[test]
326    fn test_icinga_url_err() {
327        let url = Url::parse("unix:/run/foo.socket").unwrap();
328        assert_eq!(
329            IcingaUrl::try_from(url.clone()).err().unwrap().to_string(),
330            r#"Illegal url scheme "unix:" in url "unix:/run/foo.socket". Only schemes "http(s)" are allowed."#
331        );
332    }
333
334    #[test]
335    fn test_icinga_url_serde() {
336        let url: IcingaUrl = "http://user:pass@example.com:8443".parse().unwrap();
337        let value: Value = "http://user:pass@example.com:8443/".into();
338        assert_eq!(serde_json::to_value(url.clone()).unwrap(), value);
339
340        let parsed_url: IcingaUrl = serde_json::from_value(value).unwrap();
341        assert_eq!(parsed_url, url)
342    }
343
344    #[test]
345    fn test_without_credentials() {
346        assert_eq!(
347            url_without_credentials(
348                &Url::parse("https://user:pass@example.com:8443/hello/world?what=42").unwrap()
349            )
350            .as_str(),
351            "https://example.com:8443/hello/world?what=42"
352        );
353        assert_eq!(
354            url_without_credentials(
355                &Url::parse("https://example.com:8443/hello/world?what=42").unwrap()
356            )
357            .as_str(),
358            "https://example.com:8443/hello/world?what=42"
359        );
360    }
361}