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 {
pub fn new(request: &str) -> Result<Self> {
let mut parts = request.split("\r\n\r\n");
let head = parts.next().context("Headline Error")?;
let body = parts.next().map(|b| b.to_string());
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);
let (normalized_path, path_vars) = Self::process_path_variables(&path);
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,
})
}
fn extract_query_param(url: &str) -> (String, Option<HashMap<String, String>>) {
if let Some((path, query)) = url.split_once('?') {
let mut params = HashMap::new();
for pair in query.split('&') {
if let Some((key, value)) = pair.split_once('=') {
let decoded_key = Self::url_decode(key);
let decoded_value = Self::url_decode(value);
params.insert(decoded_key, decoded_value);
}
}
(path.to_string(), Some(params))
} else {
(url.to_string(), None)
}
}
fn url_decode(s: &str) -> String {
let mut result = String::new();
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'%' => {
let hex: String = chars.by_ref().take(2).collect();
if hex.len() == 2 {
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte as char);
} else {
result.push('%');
result.push_str(&hex);
}
} else {
result.push('%');
result.push_str(&hex);
}
}
'+' => result.push(' '), _ => result.push(ch),
}
}
result
}
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;
}
if segment.chars().all(|c| c.is_ascii_digit()) {
let var_name = if let Some(resource) = &last_resource {
let singular = Self::singularize(resource);
format!("{}_id", singular)
} else {
"id".to_string()
};
path_vars.insert(var_name.clone(), segment.to_string());
normalized_segments.push(format!(":{}", var_name));
} else {
last_resource = Some(segment.to_string());
normalized_segments.push(segment.to_string());
}
}
let normalized_path = normalized_segments.join("/");
(normalized_path, path_vars)
}
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()));
}
#[test]
fn test_query_params_with_encoding() {
let raw = "GET /admin/users?q=john&status=ACTIVE&affiliate=Partner%20A&sort=username HTTP/1.1\r\nHost: localhost\r\n\r\n";
let request = Request::new(raw).unwrap();
assert_eq!(request.method, Method::GET);
assert_eq!(request.path, "/admin/users");
let params = request.params.unwrap();
assert_eq!(params.get("q"), Some(&"john".to_string()));
assert_eq!(params.get("status"), Some(&"ACTIVE".to_string()));
assert_eq!(params.get("affiliate"), Some(&"Partner A".to_string()));
assert_eq!(params.get("sort"), Some(&"username".to_string()));
}
#[test]
fn test_query_params_with_plus_sign() {
let raw = "GET /search?query=hello+world&name=John+Doe HTTP/1.1\r\nHost: localhost\r\n\r\n";
let request = Request::new(raw).unwrap();
let params = request.params.unwrap();
assert_eq!(params.get("query"), Some(&"hello world".to_string()));
assert_eq!(params.get("name"), Some(&"John Doe".to_string()));
}
}