francoisgib_webserver 1.0.3

HTTP Webserver
Documentation
use std::{collections::HashMap, str::FromStr};

use serde::Serialize;
use serde_json::to_string_pretty;
use smallvec::smallvec;

use crate::{
    http::{
        headers::{ContentType, HeaderEntry, HttpHeader, HttpHeaderValue},
        requests::HttpRequest,
        responses::HttpResponse,
        status::HttpStatus,
    },
    utils::buffer::Buffer,
};

/// A handler that collects and exposes basic server metrics.
///
/// `MetricsHandler` tracks:
/// - Total number of HTTP requests processed
/// - The count of responses grouped by HTTP status code
#[derive(Debug, Serialize)]
pub struct MetricsHandler {
    /// The name of the server.
    pub server_name: String,
    /// The endpoint at which metrics are exposed (e.g., `/metrics`).
    pub metrics_endpoint: String,
    /// Total number of requests handled.
    pub requests_count: u32,
    /// A map tracking the count of each HTTP status code returned.
    pub status_codes: HashMap<u16, u32>,
}

impl MetricsHandler {
    /// Creates a new `MetricsHandler`.
    ///
    /// # Arguments
    /// - `server_name`: Name of the server.
    /// - `metrics_endpoint`: The URL path where metrics are exposed.
    pub fn new(server_name: String, metrics_endpoint: String) -> Self {
        Self {
            server_name,
            metrics_endpoint,
            requests_count: 0,
            status_codes: HashMap::new(),
        }
    }

    /// Generates a JSON-formatted `HttpResponse` representing the current metrics.
    ///
    /// The response will have:
    /// - `Content-Type: application/json`
    /// - `Status: 200 OK`
    pub fn metrics(&self) -> HttpResponse {
        let body = to_string_pretty(self)
            .map(|json| Buffer::from_str(&json).unwrap())
            .ok();

        let body_length = body.as_ref().map(|buf| buf.len()).unwrap_or(0) as u64;

        let content_length = HeaderEntry::new(
            HttpHeader::ContentLength,
            HttpHeaderValue::ContentLength(body_length),
        );
        let content_type = HeaderEntry::new(
            HttpHeader::ContentType,
            HttpHeaderValue::ContentType(ContentType::ApplicationJson),
        );

        let headers = smallvec![content_length, content_type];
        HttpResponse::new((1, 1), HttpStatus::Ok, headers, body, None)
    }

    /// Updates the internal counters based on a processed request and its corresponding response.
    ///
    /// # Arguments
    /// - `_`: The HTTP request (not currently used, but will be later).
    /// - `response`: The HTTP response, from which the status code is extracted.
    pub fn add_metrics(&mut self, _: &HttpRequest, response: &HttpResponse) {
        self.requests_count += 1;
        self.status_codes
            .entry(response.status as u16)
            .and_modify(|cpt| *cpt += 1)
            .or_insert(1);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::http::methods::HttpMethod;
    use crate::http::responses::HttpResponse;
    use crate::http::status::HttpStatus;

    #[test]
    fn test_add_metrics_increments_counts() {
        let mut metrics = MetricsHandler::new("TestServer".into(), "/metrics".into());

        let resp_200 = dummy_response(HttpStatus::Ok);
        let resp_404 = dummy_response(HttpStatus::NotFound);

        metrics.add_metrics(&dummy_request(), &resp_200);
        metrics.add_metrics(&dummy_request(), &resp_404);
        metrics.add_metrics(&dummy_request(), &resp_200);

        assert_eq!(metrics.requests_count, 3);
        assert_eq!(metrics.status_codes.get(&(HttpStatus::Ok as u16)), Some(&2));
        assert_eq!(
            metrics.status_codes.get(&(HttpStatus::NotFound as u16)),
            Some(&1)
        );
    }

    #[test]
    fn test_metrics_endpoint_response() {
        let metrics = MetricsHandler::new("server".to_owned(), "/metrics".to_owned());

        let response = metrics.metrics();
        assert_eq!(response.status, HttpStatus::Ok);

        let content_type_header = response
            .headers
            .iter()
            .find(|entry| matches!(entry.name, HttpHeader::ContentType));
        assert!(content_type_header.is_some());

        if let Some(header) = content_type_header {
            if let HttpHeaderValue::ContentType(ContentType::ApplicationJson) = header.value {
            } else {
                panic!("Content-Type is not application/json");
            }
        } else {
            panic!("Missing Content-Type header");
        }

        assert!(response.body.is_some());
    }

    fn dummy_request() -> HttpRequest {
        HttpRequest::new(HttpMethod::GET, "/".to_owned(), (1, 1), smallvec![], None)
    }

    fn dummy_response(status: HttpStatus) -> HttpResponse {
        HttpResponse::new((1, 1), status, smallvec![], None, None)
    }
}