limon-core 0.3.3

limon core library
Documentation
use std::time::Duration;

use curl::easy::{Easy2, Handler, HttpVersion, List, WriteError};
use tokio::task;

use crate::collectors::monitor::errors::HttpError;
use crate::models::monitor::config::HttpConfig;
use crate::models::monitor::measurement::{Data, HttpData};

#[derive(Default)]
struct ResponseBody(Vec<u8>);

impl Handler for ResponseBody {
  fn write(&mut self, data: &[u8]) -> Result<usize, WriteError> {
    self.0.extend_from_slice(data);

    Ok(data.len())
  }
}

impl ResponseBody {
  pub fn get_body(&self) -> String {
    String::from_utf8_lossy(&self.0).into()
  }
}

/// Collector for receiving monitor data from `HTTP` request.
pub struct Http;

impl Http {
  pub async fn measure(host: &String, config: &HttpConfig) -> Result<Data, HttpError> {
    let url = format!(
      "{}://{}{}{}",
      config.protocol.to_lowercase(),
      host,
      config
        .port
        .map_or(String::new(), |port| format!(":{}", port)),
      config.path.clone().unwrap_or_default()
    );

    let mut headers = List::new();

    if let Some(header_values) = &config.headers {
      for header in header_values {
        headers.append(&format!("{}: {}", header.name, header.value))?;
      }
    }

    let mut request = Easy2::new(ResponseBody::default());
    request.url(url.as_str())?;
    request.http_headers(headers)?;
    request.timeout(Duration::from_secs(config.timeout as u64))?;
    request.cookie_file("")?;
    request.follow_location(config.follow_redirects)?;
    request.http_version(HttpVersion::V2)?;

    match config.method.to_lowercase().as_str() {
      "get" => request.get(true)?,
      "post" => request.post(true)?,
      "put" => request.put(true)?,
      "patch" => request.custom_request("PATCH")?,
      "head" => {
        request.nobody(true)?;
        request.custom_request("HEAD")?
      }
      _ => unimplemented!("Unimplemented HTTP method"),
    };

    if let Some(body) = config.body.clone() {
      request.post_fields_copy(body.as_bytes())?;
    }

    let response = task::spawn_blocking(move || match request.perform() {
      Ok(()) => Ok(request),
      Err(error) => Err(HttpError::Unknown(error)),
    })
    .await
    .expect("curl request")?;

    let response_status = response.response_code()? as u16;
    let expected_status_code = config.expected_status_code as u16;

    if response_status != expected_status_code {
      return Err(HttpError::StatusMismatch {
        expected: expected_status_code,
        actual: response_status,
      });
    }

    if let Some(keyword) = config.keyword.clone() {
      let response_body = response.get_ref().get_body();

      if !response_body.contains(keyword.as_str()) {
        return Err(HttpError::KeywordNotFound { keyword });
      }
    }

    let dns = response.namelookup_time()?.as_secs_f32();
    let tcp = response.connect_time()?.as_secs_f32();
    let tls = response.appconnect_time()?.as_secs_f32();
    let ttfb = response.starttransfer_time()?.as_secs_f32();
    let total = response.total_time()?.as_secs_f32();
    let transfer = (total - ttfb).max(0.0);

    Ok(Data::Http(HttpData {
      dns,
      tcp,
      tls,
      ttfb,
      transfer,
    }))
  }
}

#[cfg(test)]
mod tests {
  use httpmock::prelude::*;

  use super::*;
  use crate::models::monitor::config::{Header, HttpConfig};

  #[test]
  fn response_body() {
    let mut response_body = ResponseBody([0].into());

    assert!(
      response_body.write(&[0]).is_ok(),
      "response body is writable"
    );
    assert_eq!(
      response_body.get_body(),
      "\0\0",
      "response body is readable"
    );
  }

  #[tokio::test]
  async fn headers() {
    let server = MockServer::start_async().await;

    let mock = server
      .mock_async(|when, then| {
        when
          .header("Authorization", "token")
          .method(GET)
          .path("/check");
        then.status(200);
      })
      .await;

    let result = Http::measure(
      &server.host(),
      &HttpConfig {
        timeout: 3,
        method: String::from("GET"),
        protocol: String::from("HTTP"),
        port: Some(server.port()),
        path: Some(String::from("/check")),
        headers: Some(vec![Header {
          name: String::from("Authorization"),
          value: String::from("token"),
        }]),
        expected_status_code: 200,
        ..Default::default()
      },
    )
    .await;

    mock.assert();

    assert!(result.is_ok(), "request header is correct");
  }

  #[tokio::test]
  async fn body() {
    let server = MockServer::start_async().await;

    let mock = server
      .mock_async(|when, then| {
        when.method(POST).path("/check").body("test");
        then.status(200);
      })
      .await;

    let result = Http::measure(
      &server.host(),
      &HttpConfig {
        timeout: 3,
        method: String::from("POST"),
        protocol: String::from("HTTP"),
        port: Some(server.port()),
        path: Some(String::from("/check")),
        body: Some(String::from("test")),
        expected_status_code: 200,
        ..Default::default()
      },
    )
    .await;

    mock.assert();

    assert!(result.is_ok(), "request body is correct");
  }

  #[tokio::test]
  async fn methods() {
    let server = MockServer::start_async().await;

    for method in ["GET", "POST", "PUT", "PATCH", "HEAD"] {
      let mock = server
        .mock_async(|when, then| {
          when.method(Method::from(method)).path("/check");
          then.status(200);
        })
        .await;

      let result = Http::measure(
        &server.host(),
        &HttpConfig {
          timeout: 3,
          method: String::from(method),
          protocol: String::from("HTTP"),
          port: Some(server.port()),
          path: Some(String::from("/check")),
          expected_status_code: 200,
          ..Default::default()
        },
      )
      .await;

      mock.assert();

      assert!(result.is_ok(), "request method is correct");
    }
  }

  #[tokio::test]
  async fn response_status_mismatch() {
    let server = MockServer::start_async().await;

    let mock = server
      .mock_async(|when, then| {
        when.method(GET).path("/check");
        then.status(400);
      })
      .await;

    let result = Http::measure(
      &server.host(),
      &HttpConfig {
        timeout: 3,
        method: String::from("GET"),
        protocol: String::from("HTTP"),
        port: Some(server.port()),
        path: Some(String::from("/check")),
        expected_status_code: 200,
        ..Default::default()
      },
    )
    .await;

    mock.assert();

    assert!(result.is_err(), "response has unexpected status");
  }

  #[tokio::test]
  async fn response_doesnt_contain_keyword() {
    let server = MockServer::start_async().await;

    let mock = server
      .mock_async(|when, then| {
        when.method(GET).path("/check");
        then.status(200).body("error");
      })
      .await;

    let result = Http::measure(
      &server.host(),
      &HttpConfig {
        timeout: 3,
        method: String::from("GET"),
        protocol: String::from("HTTP"),
        port: Some(server.port()),
        path: Some(String::from("/check")),
        expected_status_code: 200,
        keyword: Some(String::from("index")),
        ..Default::default()
      },
    )
    .await;

    mock.assert();

    assert!(result.is_err(), "response doesn't contain expected keyword");
  }

  #[tokio::test]
  async fn unknown_error() {
    let result = Http::measure(
      &String::from("127.0.0.1"),
      &HttpConfig {
        method: String::from("GET"),
        protocol: String::from("HTTP"),
        port: Some(5555),
        expected_status_code: 200,
        ..Default::default()
      },
    )
    .await;

    assert!(result.is_err(), "Could not connect to server");
  }
}