limon_core/collectors/monitor/
http.rs1use std::time::Duration;
2
3use curl::easy::{Easy2, Handler, HttpVersion, List, WriteError};
4use tokio::task;
5
6use crate::collectors::monitor::errors::HttpError;
7use crate::models::monitor::config::HttpConfig;
8use crate::models::monitor::measurement::{Data, HttpData};
9
10#[derive(Default)]
11struct ResponseBody(Vec<u8>);
12
13impl Handler for ResponseBody {
14 fn write(&mut self, data: &[u8]) -> Result<usize, WriteError> {
15 self.0.extend_from_slice(data);
16
17 Ok(data.len())
18 }
19}
20
21impl ResponseBody {
22 pub fn get_body(&self) -> String {
23 String::from_utf8_lossy(&self.0).into()
24 }
25}
26
27pub struct Http;
29
30impl Http {
31 pub async fn measure(host: &String, config: &HttpConfig) -> Result<Data, HttpError> {
32 let url = format!(
33 "{}://{}{}{}",
34 config.protocol.to_lowercase(),
35 host,
36 config
37 .port
38 .map_or(String::new(), |port| format!(":{}", port)),
39 config.path.clone().unwrap_or_default()
40 );
41
42 let mut headers = List::new();
43
44 if let Some(header_values) = &config.headers {
45 for header in header_values {
46 headers.append(&format!("{}: {}", header.name, header.value))?;
47 }
48 }
49
50 let mut request = Easy2::new(ResponseBody::default());
51 request.url(url.as_str())?;
52 request.http_headers(headers)?;
53 request.timeout(Duration::from_secs(config.timeout as u64))?;
54 request.cookie_file("")?;
55 request.follow_location(config.follow_redirects)?;
56 request.http_version(HttpVersion::V2)?;
57
58 match config.method.to_lowercase().as_str() {
59 "get" => request.get(true)?,
60 "post" => request.post(true)?,
61 "put" => request.put(true)?,
62 "patch" => request.custom_request("PATCH")?,
63 "head" => {
64 request.nobody(true)?;
65 request.custom_request("HEAD")?
66 }
67 _ => unimplemented!("Unimplemented HTTP method"),
68 };
69
70 if let Some(body) = config.body.clone() {
71 request.post_fields_copy(body.as_bytes())?;
72 }
73
74 let response = task::spawn_blocking(move || match request.perform() {
75 Ok(()) => Ok(request),
76 Err(error) => Err(HttpError::Unknown(error)),
77 })
78 .await
79 .expect("curl request")?;
80
81 let response_status = response.response_code()? as u16;
82 let expected_status_code = config.expected_status_code as u16;
83
84 if response_status != expected_status_code {
85 return Err(HttpError::StatusMismatch {
86 expected: expected_status_code,
87 actual: response_status,
88 });
89 }
90
91 if let Some(keyword) = config.keyword.clone() {
92 let response_body = response.get_ref().get_body();
93
94 if !response_body.contains(keyword.as_str()) {
95 return Err(HttpError::KeywordNotFound { keyword });
96 }
97 }
98
99 let dns = response.namelookup_time()?.as_secs_f32();
100 let tcp = response.connect_time()?.as_secs_f32();
101 let tls = response.appconnect_time()?.as_secs_f32();
102 let ttfb = response.starttransfer_time()?.as_secs_f32();
103 let total = response.total_time()?.as_secs_f32();
104 let transfer = (total - ttfb).max(0.0);
105
106 Ok(Data::Http(HttpData {
107 dns,
108 tcp,
109 tls,
110 ttfb,
111 transfer,
112 }))
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use httpmock::prelude::*;
119
120 use super::*;
121 use crate::models::monitor::config::{Header, HttpConfig};
122
123 #[test]
124 fn response_body() {
125 let mut response_body = ResponseBody([0].into());
126
127 assert!(
128 response_body.write(&[0]).is_ok(),
129 "response body is writable"
130 );
131 assert_eq!(
132 response_body.get_body(),
133 "\0\0",
134 "response body is readable"
135 );
136 }
137
138 #[tokio::test]
139 async fn headers() {
140 let server = MockServer::start_async().await;
141
142 let mock = server
143 .mock_async(|when, then| {
144 when
145 .header("Authorization", "token")
146 .method(GET)
147 .path("/check");
148 then.status(200);
149 })
150 .await;
151
152 let result = Http::measure(
153 &server.host(),
154 &HttpConfig {
155 timeout: 3,
156 method: String::from("GET"),
157 protocol: String::from("HTTP"),
158 port: Some(server.port()),
159 path: Some(String::from("/check")),
160 headers: Some(vec![Header {
161 name: String::from("Authorization"),
162 value: String::from("token"),
163 }]),
164 expected_status_code: 200,
165 ..Default::default()
166 },
167 )
168 .await;
169
170 mock.assert();
171
172 assert!(result.is_ok(), "request header is correct");
173 }
174
175 #[tokio::test]
176 async fn body() {
177 let server = MockServer::start_async().await;
178
179 let mock = server
180 .mock_async(|when, then| {
181 when.method(POST).path("/check").body("test");
182 then.status(200);
183 })
184 .await;
185
186 let result = Http::measure(
187 &server.host(),
188 &HttpConfig {
189 timeout: 3,
190 method: String::from("POST"),
191 protocol: String::from("HTTP"),
192 port: Some(server.port()),
193 path: Some(String::from("/check")),
194 body: Some(String::from("test")),
195 expected_status_code: 200,
196 ..Default::default()
197 },
198 )
199 .await;
200
201 mock.assert();
202
203 assert!(result.is_ok(), "request body is correct");
204 }
205
206 #[tokio::test]
207 async fn methods() {
208 let server = MockServer::start_async().await;
209
210 for method in ["GET", "POST", "PUT", "PATCH", "HEAD"] {
211 let mock = server
212 .mock_async(|when, then| {
213 when.method(Method::from(method)).path("/check");
214 then.status(200);
215 })
216 .await;
217
218 let result = Http::measure(
219 &server.host(),
220 &HttpConfig {
221 timeout: 3,
222 method: String::from(method),
223 protocol: String::from("HTTP"),
224 port: Some(server.port()),
225 path: Some(String::from("/check")),
226 expected_status_code: 200,
227 ..Default::default()
228 },
229 )
230 .await;
231
232 mock.assert();
233
234 assert!(result.is_ok(), "request method is correct");
235 }
236 }
237
238 #[tokio::test]
239 async fn response_status_mismatch() {
240 let server = MockServer::start_async().await;
241
242 let mock = server
243 .mock_async(|when, then| {
244 when.method(GET).path("/check");
245 then.status(400);
246 })
247 .await;
248
249 let result = Http::measure(
250 &server.host(),
251 &HttpConfig {
252 timeout: 3,
253 method: String::from("GET"),
254 protocol: String::from("HTTP"),
255 port: Some(server.port()),
256 path: Some(String::from("/check")),
257 expected_status_code: 200,
258 ..Default::default()
259 },
260 )
261 .await;
262
263 mock.assert();
264
265 assert!(result.is_err(), "response has unexpected status");
266 }
267
268 #[tokio::test]
269 async fn response_doesnt_contain_keyword() {
270 let server = MockServer::start_async().await;
271
272 let mock = server
273 .mock_async(|when, then| {
274 when.method(GET).path("/check");
275 then.status(200).body("error");
276 })
277 .await;
278
279 let result = Http::measure(
280 &server.host(),
281 &HttpConfig {
282 timeout: 3,
283 method: String::from("GET"),
284 protocol: String::from("HTTP"),
285 port: Some(server.port()),
286 path: Some(String::from("/check")),
287 expected_status_code: 200,
288 keyword: Some(String::from("index")),
289 ..Default::default()
290 },
291 )
292 .await;
293
294 mock.assert();
295
296 assert!(result.is_err(), "response doesn't contain expected keyword");
297 }
298
299 #[tokio::test]
300 async fn unknown_error() {
301 let result = Http::measure(
302 &String::from("127.0.0.1"),
303 &HttpConfig {
304 method: String::from("GET"),
305 protocol: String::from("HTTP"),
306 port: Some(5555),
307 expected_status_code: 200,
308 ..Default::default()
309 },
310 )
311 .await;
312
313 assert!(result.is_err(), "Could not connect to server");
314 }
315}