request_http_parser/
parser.rs

1//! Parse string request from incoming http to Request struct model which include method, path,
2//! headers, params (optional) and body (optional)
3use anyhow::{Context, Result, anyhow};
4use std::collections::HashMap;
5
6#[derive(Debug, PartialEq, Eq)]
7pub enum Method {
8    GET,
9    POST,
10    PUT,
11    PATCH,
12    DELETE,
13    HEAD,
14    OPTIONS,
15}
16
17impl TryFrom<&str> for Method {
18    type Error = anyhow::Error;
19    fn try_from(value: &str) -> Result<Self, anyhow::Error> {
20        match value {
21            "GET" => Ok(Method::GET),
22            "POST" => Ok(Method::POST),
23            "PUT" => Ok(Method::PUT),
24            "PATCH" => Ok(Method::PATCH),
25            "DELETE" => Ok(Method::DELETE),
26            "HEAD" => Ok(Method::HEAD),
27            "OPTIONS" => Ok(Method::OPTIONS),
28            _ => Err(anyhow!("Method not supported")),
29        }
30    }
31}
32
33#[derive(Debug)]
34pub struct Request {
35    pub method: Method,
36    pub path: String,
37    pub params: Option<std::collections::HashMap<String, String>>,
38    pub headers: std::collections::HashMap<String, String>,
39    pub body: Option<String>,
40    pub path_vars: HashMap<String, String>,
41}
42
43impl Request {
44    /// # Examples
45    ///
46    /// ```
47    /// use request_http_parser::parser::{Method,Request};
48    ///     let req_str = format!(
49    ///         "POST /login HTTP/1.1\r\n\
50    ///         Content-Type: application/json\r\n\
51    ///         User-Agent: Test\r\n\
52    ///         Content-Length: {}\r\n\\r\n\
53    ///         {{\"username\": \"{}\",\"password\": \"{}\"}}",
54    ///         44, "crisandolin", "rumahorbo");
55    ///     let req = Request::new(&req_str).unwrap();
56    ///
57    ///     assert_eq!(Method::POST, req.method);
58    ///     assert_eq!("/login", req.path);  
59    /// ```
60    ///
61    pub fn new(request: &str) -> Result<Self> {
62        let mut parts = request.split("\r\n\r\n");
63        let head = parts.next().context("Headline Error")?;
64        // Body
65        let body = parts.next().map(|b| b.to_string());
66
67        // Method and path
68        let mut head_line = head.lines();
69        let first: &str = head_line.next().context("Empty Request")?;
70        let mut request_parts: std::str::SplitWhitespace<'_> = first.split_whitespace();
71        let method: Method = request_parts
72            .next()
73            .ok_or(anyhow!("missing method"))
74            .and_then(TryInto::try_into)
75            .context("Missing Method")?;
76        let url = request_parts.next().context("No Path")?;
77        let (path, params) = Self::extract_query_param(url);
78
79        // Process path variables
80        let (normalized_path, path_vars) = Self::process_path_variables(&path);
81
82        // Headers
83        let mut headers = HashMap::new();
84        for line in head_line {
85            if let Some((k, v)) = line.split_once(":") {
86                headers.insert(k.trim().to_lowercase(), v.trim().to_string());
87            }
88        }
89        Ok(Request {
90            method,
91            path: normalized_path,
92            headers,
93            body,
94            params,
95            path_vars,
96        })
97    }
98
99    /// extract query param from url
100    fn extract_query_param(url: &str) -> (String, Option<HashMap<String, String>>) {
101        // Find the query string
102        if let Some(pos) = url.find('?') {
103            let path = &url[0..pos];
104            let query_string = &url[pos + 1..]; // Get substring after '?'
105
106            // Parse query params into a HashMap
107            let params: HashMap<_, _> = query_string
108                .split('&')
109                .filter_map(|pair| {
110                    let mut kv = pair.split('=');
111                    Some((kv.next()?.to_string(), kv.next()?.to_string()))
112                })
113                .collect();
114
115            // Return the token if it exists
116            (path.to_string(), Some(params))
117            // params.get("token").map(|s| s.to_string())
118        } else {
119            (url.to_string(), None)
120        }
121    }
122
123    /// Process path and extract numeric segments as variables
124    /// Returns: (normalized_path, path_variables)
125    ///
126    /// Uses the preceding resource name for variable naming:
127    /// - "/affiliates/3" -> ("/affiliates/:affiliate_id", {"affiliate_id": "3"})
128    /// - "/affiliates/3/school/5" -> ("/affiliates/:affiliate_id/school/:school_id", {"affiliate_id": "3", "school_id": "5"})
129    /// - "/users/123/posts/456" -> ("/users/:user_id/posts/:post_id", {...})
130    fn process_path_variables(path: &str) -> (String, HashMap<String, String>) {
131        let segments: Vec<&str> = path.split('/').collect();
132        let mut normalized_segments: Vec<String> = Vec::new();
133        let mut path_vars = HashMap::new();
134        let mut last_resource: Option<String> = None;
135
136        for segment in segments {
137            if segment.is_empty() {
138                normalized_segments.push(segment.to_string());
139                continue;
140            }
141
142            // Check if segment is numeric (could be an ID)
143            if segment.chars().all(|c| c.is_ascii_digit()) {
144                // Use the last resource name as the variable name
145                let var_name = if let Some(resource) = &last_resource {
146                    // Convert plural to singular if needed
147                    let singular = Self::singularize(resource);
148                    format!("{}_id", singular)
149                } else {
150                    // Fallback if no resource name is available
151                    "id".to_string()
152                };
153
154                path_vars.insert(var_name.clone(), segment.to_string());
155                normalized_segments.push(format!(":{}", var_name));
156            } else {
157                // This is a resource name, remember it for the next segment
158                last_resource = Some(segment.to_string());
159                normalized_segments.push(segment.to_string());
160            }
161        }
162
163        let normalized_path = normalized_segments.join("/");
164        (normalized_path, path_vars)
165    }
166
167    /// Simple singularization - removes trailing 's' if present
168    /// You can make this more sophisticated if needed
169    fn singularize(word: &str) -> String {
170        if word.ends_with('s') && word.len() > 1 {
171            word[..word.len() - 1].to_string()
172        } else {
173            word.to_string()
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use crate::parser::Method;
181
182    use super::Request;
183
184    #[test]
185    fn parser() {
186        let req_str = format!(
187            "POST /login HTTP/1.1\r\n\
188                Content-Type: application/json\r\n\
189                User-Agent: Test\r\n\
190                Content-Length: {}\r\n\
191                \r\n\
192                {{\"username\": \"{}\",\"password\": \"{}\"}}",
193            44, "crisandolin", "rumahorbo"
194        );
195
196        let req = Request::new(&req_str).unwrap();
197
198        assert_eq!(Method::POST, req.method);
199        assert_eq!("/login", req.path);
200    }
201
202    #[test]
203    fn test_process_path_variables() {
204        let (path, vars) = Request::process_path_variables("/affiliates/3");
205        assert_eq!(path, "/affiliates/:affiliate_id");
206        assert_eq!(vars.get("affiliate_id"), Some(&"3".to_string()));
207
208        let (path, vars) = Request::process_path_variables("/affiliates/3/school/5");
209        assert_eq!(path, "/affiliates/:affiliate_id/school/:school_id");
210        assert_eq!(vars.get("affiliate_id"), Some(&"3".to_string()));
211        assert_eq!(vars.get("school_id"), Some(&"5".to_string()));
212
213        let (path, vars) = Request::process_path_variables("/users/123/posts/456/comments/789");
214        assert_eq!(path, "/users/:user_id/posts/:post_id/comments/:comment_id");
215        assert_eq!(vars.get("user_id"), Some(&"123".to_string()));
216        assert_eq!(vars.get("post_id"), Some(&"456".to_string()));
217        assert_eq!(vars.get("comment_id"), Some(&"789".to_string()));
218
219        let (path, vars) = Request::process_path_variables("/login");
220        assert_eq!(path, "/login");
221        assert!(vars.is_empty());
222    }
223
224    #[test]
225    fn test_complex_path() {
226        let raw = format!(
227            "PUT /affiliates/5/company/10 HTTP/1.1\r\n\
228                Content-Type: application/json\r\n\
229                User-Agent: Test\r\n\
230                Content-Length: {}\r\n\
231                \r\n\
232                {{\"username\": \"{}\",\"password\": \"{}\"}}",
233            44, "crisandolin", "rumahorbo"
234        );
235        let request = Request::new(&raw).unwrap();
236
237        assert_eq!(request.method, Method::PUT);
238        assert_eq!(
239            request.path,
240            "/affiliates/:affiliate_id/company/:company_id"
241        );
242        assert_eq!(
243            request.path_vars.get("affiliate_id"),
244            Some(&"5".to_string())
245        );
246        assert_eq!(request.path_vars.get("company_id"), Some(&"10".to_string()));
247    }
248}