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#[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 self.url.host_str().unwrap()
29 }
30
31 pub fn host(&self) -> url::Host<&str> {
32 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 self.url.set_username(user).unwrap()
52 }
53
54 pub fn set_password(&mut self, password: Option<&str>) {
55 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
132pub struct Client {
134 client: reqwest::blocking::Client,
135 base_uri: Url,
136 credentials: Option<Credentials>,
137}
138
139impl Client {
140 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 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 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 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}