1use 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 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 let body = parts.next().map(|b| b.to_string());
66
67 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 let (normalized_path, path_vars) = Self::process_path_variables(&path);
81
82 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 fn extract_query_param(url: &str) -> (String, Option<HashMap<String, String>>) {
101 if let Some((path, query)) = url.split_once('?') {
102 let mut params = HashMap::new();
103 for pair in query.split('&') {
104 if let Some((key, value)) = pair.split_once('=') {
105 let decoded_key = Self::url_decode(key);
106 let decoded_value = Self::url_decode(value);
107 params.insert(decoded_key, decoded_value);
108 }
109 }
110 (path.to_string(), Some(params))
111 } else {
112 (url.to_string(), None)
113 }
114 }
115
116 fn url_decode(s: &str) -> String {
119 let mut result = String::new();
120 let mut chars = s.chars().peekable();
121
122 while let Some(ch) = chars.next() {
123 match ch {
124 '%' => {
125 let hex: String = chars.by_ref().take(2).collect();
127 if hex.len() == 2 {
128 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
129 result.push(byte as char);
130 } else {
131 result.push('%');
133 result.push_str(&hex);
134 }
135 } else {
136 result.push('%');
137 result.push_str(&hex);
138 }
139 }
140 '+' => result.push(' '), _ => result.push(ch),
142 }
143 }
144
145 result
146 }
147
148 fn process_path_variables(path: &str) -> (String, HashMap<String, String>) {
156 let segments: Vec<&str> = path.split('/').collect();
157 let mut normalized_segments: Vec<String> = Vec::new();
158 let mut path_vars = HashMap::new();
159 let mut last_resource: Option<String> = None;
160
161 for segment in segments {
162 if segment.is_empty() {
163 normalized_segments.push(segment.to_string());
164 continue;
165 }
166
167 if segment.chars().all(|c| c.is_ascii_digit()) {
169 let var_name = if let Some(resource) = &last_resource {
171 let singular = Self::singularize(resource);
173 format!("{}_id", singular)
174 } else {
175 "id".to_string()
177 };
178
179 path_vars.insert(var_name.clone(), segment.to_string());
180 normalized_segments.push(format!(":{}", var_name));
181 } else {
182 last_resource = Some(segment.to_string());
184 normalized_segments.push(segment.to_string());
185 }
186 }
187
188 let normalized_path = normalized_segments.join("/");
189 (normalized_path, path_vars)
190 }
191
192 fn singularize(word: &str) -> String {
195 if word.ends_with('s') && word.len() > 1 {
196 word[..word.len() - 1].to_string()
197 } else {
198 word.to_string()
199 }
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use crate::parser::Method;
206
207 use super::Request;
208
209 #[test]
210 fn parser() {
211 let req_str = format!(
212 "POST /login HTTP/1.1\r\n\
213 Content-Type: application/json\r\n\
214 User-Agent: Test\r\n\
215 Content-Length: {}\r\n\
216 \r\n\
217 {{\"username\": \"{}\",\"password\": \"{}\"}}",
218 44, "crisandolin", "rumahorbo"
219 );
220
221 let req = Request::new(&req_str).unwrap();
222
223 assert_eq!(Method::POST, req.method);
224 assert_eq!("/login", req.path);
225 }
226
227 #[test]
228 fn test_process_path_variables() {
229 let (path, vars) = Request::process_path_variables("/affiliates/3");
230 assert_eq!(path, "/affiliates/:affiliate_id");
231 assert_eq!(vars.get("affiliate_id"), Some(&"3".to_string()));
232
233 let (path, vars) = Request::process_path_variables("/affiliates/3/school/5");
234 assert_eq!(path, "/affiliates/:affiliate_id/school/:school_id");
235 assert_eq!(vars.get("affiliate_id"), Some(&"3".to_string()));
236 assert_eq!(vars.get("school_id"), Some(&"5".to_string()));
237
238 let (path, vars) = Request::process_path_variables("/users/123/posts/456/comments/789");
239 assert_eq!(path, "/users/:user_id/posts/:post_id/comments/:comment_id");
240 assert_eq!(vars.get("user_id"), Some(&"123".to_string()));
241 assert_eq!(vars.get("post_id"), Some(&"456".to_string()));
242 assert_eq!(vars.get("comment_id"), Some(&"789".to_string()));
243
244 let (path, vars) = Request::process_path_variables("/login");
245 assert_eq!(path, "/login");
246 assert!(vars.is_empty());
247 }
248
249 #[test]
250 fn test_complex_path() {
251 let raw = format!(
252 "PUT /affiliates/5/company/10 HTTP/1.1\r\n\
253 Content-Type: application/json\r\n\
254 User-Agent: Test\r\n\
255 Content-Length: {}\r\n\
256 \r\n\
257 {{\"username\": \"{}\",\"password\": \"{}\"}}",
258 44, "crisandolin", "rumahorbo"
259 );
260 let request = Request::new(&raw).unwrap();
261
262 assert_eq!(request.method, Method::PUT);
263 assert_eq!(
264 request.path,
265 "/affiliates/:affiliate_id/company/:company_id"
266 );
267 assert_eq!(
268 request.path_vars.get("affiliate_id"),
269 Some(&"5".to_string())
270 );
271 assert_eq!(request.path_vars.get("company_id"), Some(&"10".to_string()));
272 }
273
274 #[test]
275 fn test_query_params_with_encoding() {
276 let raw = "GET /admin/users?q=john&status=ACTIVE&affiliate=Partner%20A&sort=username HTTP/1.1\r\nHost: localhost\r\n\r\n";
277 let request = Request::new(raw).unwrap();
278
279 assert_eq!(request.method, Method::GET);
280 assert_eq!(request.path, "/admin/users");
281
282 let params = request.params.unwrap();
283 assert_eq!(params.get("q"), Some(&"john".to_string()));
284 assert_eq!(params.get("status"), Some(&"ACTIVE".to_string()));
285 assert_eq!(params.get("affiliate"), Some(&"Partner A".to_string()));
286 assert_eq!(params.get("sort"), Some(&"username".to_string()));
287 }
288
289 #[test]
290 fn test_query_params_with_plus_sign() {
291 let raw = "GET /search?query=hello+world&name=John+Doe HTTP/1.1\r\nHost: localhost\r\n\r\n";
292 let request = Request::new(raw).unwrap();
293
294 let params = request.params.unwrap();
295 assert_eq!(params.get("query"), Some(&"hello world".to_string()));
296 assert_eq!(params.get("name"), Some(&"John Doe".to_string()));
297 }
298}