request-http-parser 0.1.3

A simple library for parsing http request string to a model.
Documentation
//! Parse string request from incoming http to Request struct model which include method, path,
//! headers, params (optional) and body (optional)
use anyhow::{Context, Result, anyhow};
use std::collections::HashMap;

#[derive(Debug, PartialEq, Eq)]
pub enum Method {
    GET,
    POST,
    PUT,
    PATCH,
    DELETE,
    HEAD,
    OPTIONS,
}

impl TryFrom<&str> for Method {
    type Error = anyhow::Error;
    fn try_from(value: &str) -> Result<Self, anyhow::Error> {
        match value {
            "GET" => Ok(Method::GET),
            "POST" => Ok(Method::POST),
            "PUT" => Ok(Method::PUT),
            "PATCH" => Ok(Method::PATCH),
            "DELETE" => Ok(Method::DELETE),
            "HEAD" => Ok(Method::HEAD),
            "OPTIONS" => Ok(Method::OPTIONS),
            _ => Err(anyhow!("Method not supported")),
        }
    }
}

#[derive(Debug)]
pub struct Request {
    pub method: Method,
    pub path: String,
    pub params: Option<std::collections::HashMap<String, String>>,
    pub headers: std::collections::HashMap<String, String>,
    pub body: Option<String>,
    pub path_vars: HashMap<String, String>,
}

impl Request {
    /// # Examples
    ///
    /// ```
    /// use request_http_parser::parser::{Method,Request};
    ///     let req_str = format!(
    ///         "POST /login HTTP/1.1\r\n\
    ///         Content-Type: application/json\r\n\
    ///         User-Agent: Test\r\n\
    ///         Content-Length: {}\r\n\\r\n\
    ///         {{\"username\": \"{}\",\"password\": \"{}\"}}",
    ///         44, "crisandolin", "rumahorbo");
    ///     let req = Request::new(&req_str).unwrap();
    ///
    ///     assert_eq!(Method::POST, req.method);
    ///     assert_eq!("/login", req.path);  
    /// ```
    ///
    pub fn new(request: &str) -> Result<Self> {
        let mut parts = request.split("\r\n\r\n");
        let head = parts.next().context("Headline Error")?;
        // Body
        let body = parts.next().map(|b| b.to_string());

        // Method and path
        let mut head_line = head.lines();
        let first: &str = head_line.next().context("Empty Request")?;
        let mut request_parts: std::str::SplitWhitespace<'_> = first.split_whitespace();
        let method: Method = request_parts
            .next()
            .ok_or(anyhow!("missing method"))
            .and_then(TryInto::try_into)
            .context("Missing Method")?;
        let url = request_parts.next().context("No Path")?;
        let (path, params) = Self::extract_query_param(url);

        // Process path variables
        let (normalized_path, path_vars) = Self::process_path_variables(&path);

        // Headers
        let mut headers = HashMap::new();
        for line in head_line {
            if let Some((k, v)) = line.split_once(":") {
                headers.insert(k.trim().to_lowercase(), v.trim().to_string());
            }
        }
        Ok(Request {
            method,
            path: normalized_path,
            headers,
            body,
            params,
            path_vars,
        })
    }

    /// extract query param from url
    fn extract_query_param(url: &str) -> (String, Option<HashMap<String, String>>) {
        // Find the query string
        if let Some(pos) = url.find('?') {
            let path = &url[0..pos];
            let query_string = &url[pos + 1..]; // Get substring after '?'

            // Parse query params into a HashMap
            let params: HashMap<_, _> = query_string
                .split('&')
                .filter_map(|pair| {
                    let mut kv = pair.split('=');
                    Some((kv.next()?.to_string(), kv.next()?.to_string()))
                })
                .collect();

            // Return the token if it exists
            (path.to_string(), Some(params))
            // params.get("token").map(|s| s.to_string())
        } else {
            (url.to_string(), None)
        }
    }

    /// Process path and extract numeric segments as variables
    /// Returns: (normalized_path, path_variables)
    ///
    /// Uses the preceding resource name for variable naming:
    /// - "/affiliates/3" -> ("/affiliates/:affiliate_id", {"affiliate_id": "3"})
    /// - "/affiliates/3/school/5" -> ("/affiliates/:affiliate_id/school/:school_id", {"affiliate_id": "3", "school_id": "5"})
    /// - "/users/123/posts/456" -> ("/users/:user_id/posts/:post_id", {...})
    fn process_path_variables(path: &str) -> (String, HashMap<String, String>) {
        let segments: Vec<&str> = path.split('/').collect();
        let mut normalized_segments: Vec<String> = Vec::new();
        let mut path_vars = HashMap::new();
        let mut last_resource: Option<String> = None;

        for segment in segments {
            if segment.is_empty() {
                normalized_segments.push(segment.to_string());
                continue;
            }

            // Check if segment is numeric (could be an ID)
            if segment.chars().all(|c| c.is_ascii_digit()) {
                // Use the last resource name as the variable name
                let var_name = if let Some(resource) = &last_resource {
                    // Convert plural to singular if needed
                    let singular = Self::singularize(resource);
                    format!("{}_id", singular)
                } else {
                    // Fallback if no resource name is available
                    "id".to_string()
                };

                path_vars.insert(var_name.clone(), segment.to_string());
                normalized_segments.push(format!(":{}", var_name));
            } else {
                // This is a resource name, remember it for the next segment
                last_resource = Some(segment.to_string());
                normalized_segments.push(segment.to_string());
            }
        }

        let normalized_path = normalized_segments.join("/");
        (normalized_path, path_vars)
    }

    /// Simple singularization - removes trailing 's' if present
    /// You can make this more sophisticated if needed
    fn singularize(word: &str) -> String {
        if word.ends_with('s') && word.len() > 1 {
            word[..word.len() - 1].to_string()
        } else {
            word.to_string()
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::parser::Method;

    use super::Request;

    #[test]
    fn parser() {
        let req_str = format!(
            "POST /login HTTP/1.1\r\n\
                Content-Type: application/json\r\n\
                User-Agent: Test\r\n\
                Content-Length: {}\r\n\
                \r\n\
                {{\"username\": \"{}\",\"password\": \"{}\"}}",
            44, "crisandolin", "rumahorbo"
        );

        let req = Request::new(&req_str).unwrap();

        assert_eq!(Method::POST, req.method);
        assert_eq!("/login", req.path);
    }

    #[test]
    fn test_process_path_variables() {
        let (path, vars) = Request::process_path_variables("/affiliates/3");
        assert_eq!(path, "/affiliates/:affiliate_id");
        assert_eq!(vars.get("affiliate_id"), Some(&"3".to_string()));

        let (path, vars) = Request::process_path_variables("/affiliates/3/school/5");
        assert_eq!(path, "/affiliates/:affiliate_id/school/:school_id");
        assert_eq!(vars.get("affiliate_id"), Some(&"3".to_string()));
        assert_eq!(vars.get("school_id"), Some(&"5".to_string()));

        let (path, vars) = Request::process_path_variables("/users/123/posts/456/comments/789");
        assert_eq!(path, "/users/:user_id/posts/:post_id/comments/:comment_id");
        assert_eq!(vars.get("user_id"), Some(&"123".to_string()));
        assert_eq!(vars.get("post_id"), Some(&"456".to_string()));
        assert_eq!(vars.get("comment_id"), Some(&"789".to_string()));

        let (path, vars) = Request::process_path_variables("/login");
        assert_eq!(path, "/login");
        assert!(vars.is_empty());
    }

    #[test]
    fn test_complex_path() {
        let raw = format!(
            "PUT /affiliates/5/company/10 HTTP/1.1\r\n\
                Content-Type: application/json\r\n\
                User-Agent: Test\r\n\
                Content-Length: {}\r\n\
                \r\n\
                {{\"username\": \"{}\",\"password\": \"{}\"}}",
            44, "crisandolin", "rumahorbo"
        );
        let request = Request::new(&raw).unwrap();

        assert_eq!(request.method, Method::PUT);
        assert_eq!(
            request.path,
            "/affiliates/:affiliate_id/company/:company_id"
        );
        assert_eq!(
            request.path_vars.get("affiliate_id"),
            Some(&"5".to_string())
        );
        assert_eq!(request.path_vars.get("company_id"), Some(&"10".to_string()));
    }
}