Skip to content

Commit 7d4bf79

Browse files
committed
feat!: add support for dynamic routing
1 parent 5e5fb77 commit 7d4bf79

File tree

2 files changed

+240
-46
lines changed

2 files changed

+240
-46
lines changed

src/http/response_status_codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::fmt::Display;
22

3+
#[derive(Debug, PartialEq, Eq)]
34
pub enum HttpStatusCode {
45
// 1XX
56
Continue = 100,

src/router.rs

Lines changed: 239 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1-
use anyhow::{anyhow, Context, Result};
1+
use anyhow::{bail, Context, Result};
22
use log::{trace, warn};
33
use std::{collections::HashMap, fs, str::FromStr};
44

55
use crate::{
66
file_server::FileServer,
7-
http::{HttpMethod, HttpRequest, HttpResponse, HttpResponseBuilder},
7+
http::{
8+
response_status_codes::HttpStatusCode, HttpMethod, HttpRequest, HttpResponse,
9+
HttpResponseBuilder,
10+
},
811
};
912

10-
type RoutingCallback = fn(&HttpRequest) -> Result<HttpResponse>;
11-
1213
#[derive(Debug)]
1314
pub struct Router {
14-
pub routes: HashMap<Route, RoutingCallback>,
15+
pub routes: HashMap<StoredRoute, RoutingCallback>,
16+
pub catcher_routes: HashMap<HttpMethod, RoutingCallback>,
1517
pub file_server: Option<FileServer>,
1618
}
1719

@@ -25,6 +27,7 @@ impl Router {
2527
pub fn new() -> Self {
2628
Router {
2729
routes: HashMap::new(),
30+
catcher_routes: HashMap::new(),
2831
file_server: None,
2932
}
3033
}
@@ -34,38 +37,91 @@ impl Router {
3437
self
3538
}
3639

40+
fn find_matching_route(&self, route: &RequestRoute) -> Result<Option<&StoredRoute>> {
41+
let mut excluded: Vec<&StoredRoute> = vec![];
42+
let request_route_parts = route.path.split('/');
43+
44+
for (idx, part) in request_route_parts.enumerate() {
45+
for match_candidate in self.routes.keys() {
46+
if excluded.contains(&match_candidate) {
47+
continue;
48+
};
49+
50+
if let Some(match_part) = match_candidate.parts.get(idx) {
51+
if !match_part.is_dynamic && !match_part.value.eq(part) {
52+
excluded.push(match_candidate);
53+
}
54+
} else {
55+
excluded.push(match_candidate);
56+
};
57+
}
58+
}
59+
60+
let selected_routes: Vec<&StoredRoute> = self
61+
.routes
62+
.keys()
63+
.filter(|route| !excluded.contains(route))
64+
.collect();
65+
66+
match selected_routes.len() {
67+
0 => Ok(None),
68+
1 => Ok(Some(selected_routes.first().unwrap())),
69+
_ => bail!("multiple selected routes is not possible"),
70+
}
71+
}
72+
3773
pub fn handle_request(&self, request: &HttpRequest) -> Result<HttpResponse> {
3874
let route_def = format!("{} {}", request.method, request.url);
39-
let route = Route::from_str(&route_def)?;
75+
let route = RequestRoute::from_str(&route_def)?;
4076
trace!("trying to match route: {route_def}");
4177

42-
let response = if let Some(route_callback) = self.routes.get(&route) {
43-
route_callback(request)
44-
} else {
45-
if let Some(file_server) = &self.file_server {
46-
match file_server.handle_file_access(&route.path) {
47-
Ok(file_path) => {
48-
let mime_type = mime_guess::from_path(&file_path).first_or_octet_stream();
49-
let content = fs::read(file_path)?;
50-
51-
return HttpResponseBuilder::new()
52-
.set_raw_body(content)
53-
.set_content_type(mime_type.as_ref())
54-
.build();
55-
}
56-
Err(e) => warn!("failed to match file: {e}"),
78+
// test against declared routes
79+
if let Ok(Some(matching_route)) = self.find_matching_route(&route) {
80+
let routing_data = matching_route.extract_routing_data(&request.url)?;
81+
let callback = self.routes.get(matching_route).context("expected route")?;
82+
return callback(request, &routing_data);
83+
}
84+
85+
// test against file server static mappings
86+
if let Some(file_server) = &self.file_server {
87+
match file_server.handle_file_access(&route.path) {
88+
Ok(file_path) => {
89+
let mime_type = mime_guess::from_path(&file_path).first_or_octet_stream();
90+
let content = fs::read(file_path)?;
91+
92+
return HttpResponseBuilder::new()
93+
.set_raw_body(content)
94+
.set_content_type(mime_type.as_ref())
95+
.build();
5796
}
97+
Err(e) => warn!("failed to match file: {e}"),
5898
}
99+
}
59100

60-
let catch_all_route = Route::from_str("GET /*")?;
61-
if let Some(catch_all_callback) = self.routes.get(&catch_all_route) {
62-
return catch_all_callback(request);
63-
}
101+
// test against catcher routes
102+
if let Some(catcher) = self.catcher_routes.get(&request.method) {
103+
return catcher(request, &RoutingData::default());
104+
}
64105

65-
Err(anyhow!("failed to match route: {route_def}"))
66-
};
106+
HttpResponseBuilder::new()
107+
.set_status(HttpStatusCode::NotFound)
108+
.build()
109+
}
110+
111+
pub fn add_catcher_route(
112+
&mut self,
113+
method: HttpMethod,
114+
callback: RoutingCallback,
115+
) -> Result<()> {
116+
if self.catcher_routes.contains_key(&method) {
117+
bail!(
118+
"cannot register catcher because one already exists for: {}",
119+
method.to_string()
120+
);
121+
}
67122

68-
response
123+
self.catcher_routes.insert(method, callback);
124+
Ok(())
69125
}
70126

71127
pub fn add_route(
@@ -74,13 +130,13 @@ impl Router {
74130
path: &str,
75131
callback: RoutingCallback,
76132
) -> Result<()> {
77-
let route = Route::new(method, &path);
133+
let route = StoredRoute::new(method, path)?;
78134

79135
if self.routes.contains_key(&route) {
80-
return Err(anyhow!(
136+
bail!(
81137
"cannot register route {:?} because a similar route already exists",
82138
route
83-
));
139+
);
84140
}
85141

86142
self.routes.insert(route, callback);
@@ -131,53 +187,151 @@ impl Router {
131187
self.add_route(HttpMethod::PATCH, path, callback)?;
132188
Ok(self)
133189
}
190+
191+
pub fn catch_all(mut self, method: HttpMethod, callback: RoutingCallback) -> Result<Self> {
192+
self.add_catcher_route(method, callback)?;
193+
Ok(self)
194+
}
134195
}
135196

136197
#[derive(Debug, Hash, Eq, PartialEq, Clone)]
137-
pub struct Route {
198+
pub struct StoredRoute {
138199
pub method: HttpMethod,
139200
pub path: String,
201+
pub parts: Vec<RoutePart>,
140202
}
141203

142-
impl Route {
143-
pub fn new(method: HttpMethod, path: &str) -> Route {
204+
impl StoredRoute {
205+
pub fn new(method: HttpMethod, path: &str) -> Result<Self> {
144206
let path = path.trim_matches('/').to_owned();
145-
Route { method, path }
207+
208+
let mut parts = vec![];
209+
for part in path.split('/') {
210+
let is_dynamic = part.starts_with(':');
211+
let value = if is_dynamic {
212+
part[1..].to_string()
213+
} else {
214+
part.to_string()
215+
};
216+
217+
if value.contains(':') {
218+
bail!("nested `:` is not allowed in dynamic route part");
219+
}
220+
221+
parts.push(RoutePart { is_dynamic, value });
222+
}
223+
224+
Ok(Self {
225+
method,
226+
path,
227+
parts,
228+
})
229+
}
230+
231+
pub fn extract_routing_data(&self, request_url: &str) -> Result<RoutingData> {
232+
let request_parts: Vec<_> = request_url.split('/').filter(|p| !p.is_empty()).collect();
233+
234+
let mut params: HashMap<String, String> = HashMap::new();
235+
for (idx, part) in self.parts.iter().enumerate() {
236+
if !part.is_dynamic {
237+
continue;
238+
}
239+
240+
let part_value = *request_parts
241+
.get(idx)
242+
.context("part `{part_idx}` should exist")?;
243+
244+
params.insert(part.value.to_owned(), part_value.to_owned());
245+
}
246+
247+
Ok(RoutingData { params })
146248
}
147249
}
148250

149-
impl FromStr for Route {
251+
#[derive(Debug, Hash, Eq, PartialEq, Clone)]
252+
pub struct RoutePart {
253+
pub is_dynamic: bool,
254+
pub value: String,
255+
}
256+
257+
#[derive(Debug, Hash, Eq, PartialEq, Clone)]
258+
pub struct RequestRoute {
259+
pub method: HttpMethod,
260+
pub path: String,
261+
}
262+
263+
impl RequestRoute {
264+
pub fn new(method: HttpMethod, path: &str) -> RequestRoute {
265+
let path = path.trim_matches('/').to_owned();
266+
RequestRoute { method, path }
267+
}
268+
}
269+
270+
impl FromStr for RequestRoute {
150271
type Err = anyhow::Error;
151272

152273
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
153-
let (method, path) = s.split_once(" ").context("route should have: VERB PATH")?;
274+
let (method, path) = s
275+
.split_once(" ")
276+
.context("route should have following format: METHOD PATH (ex: GET /index)")?;
154277
let method = HttpMethod::from_str(method)?;
155278

156-
Ok(Route::new(method, path))
279+
Ok(RequestRoute::new(method, path))
157280
}
158281
}
159282

283+
type RoutingCallback = fn(&HttpRequest, &RoutingData) -> Result<HttpResponse>;
284+
285+
#[derive(Debug, Default)]
286+
pub struct RoutingData {
287+
pub params: HashMap<String, String>,
288+
}
289+
160290
#[cfg(test)]
161291
mod tests {
162-
use serde_json::json;
292+
use serde_json::{json, Value};
163293

164294
use crate::http::{HttpRequestRaw, HttpResponseBuilder};
165295

166296
use super::*;
167297

168-
fn get_hello_callback(_request: &HttpRequest) -> Result<HttpResponse> {
298+
fn get_hello_callback(
299+
_request: &HttpRequest,
300+
_routing_data: &RoutingData,
301+
) -> Result<HttpResponse> {
169302
HttpResponseBuilder::new()
170303
.set_html_body("Hello World!")
171304
.build()
172305
}
173306

174-
fn post_user_callback(_request: &HttpRequest) -> Result<HttpResponse> {
307+
fn get_user_by_id(_request: &HttpRequest, routing_data: &RoutingData) -> Result<HttpResponse> {
308+
let id = routing_data.params.get("id").unwrap();
309+
let username = format!("user_{id}");
310+
let json = json!({ "username": username });
311+
312+
HttpResponseBuilder::new().set_json_body(&json)?.build()
313+
}
314+
315+
fn get_user_info(_request: &HttpRequest, routing_data: &RoutingData) -> Result<HttpResponse> {
316+
let id = routing_data.params.get("id").unwrap();
317+
let info_field = routing_data.params.get("field").unwrap();
318+
319+
let username = format!("user_{id}");
320+
let json = json!({ "username": username, "field": info_field });
321+
322+
HttpResponseBuilder::new().set_json_body(&json)?.build()
323+
}
324+
325+
fn post_user_callback(
326+
_request: &HttpRequest,
327+
_routing_data: &RoutingData,
328+
) -> Result<HttpResponse> {
175329
let json = json!({ "created": true });
176330
HttpResponseBuilder::new().set_json_body(&json)?.build()
177331
}
178332

179333
#[test]
180-
fn test_unknown_route_err() {
334+
fn test_unmatched_no_catcher() {
181335
let router = Router::new();
182336

183337
let request = HttpRequest::from_raw_request(HttpRequestRaw {
@@ -187,13 +341,15 @@ mod tests {
187341
})
188342
.unwrap();
189343

190-
let response = router.handle_request(&request);
191-
assert!(response.is_err());
344+
let response = router.handle_request(&request).unwrap();
345+
assert_eq!(HttpStatusCode::NotFound.to_string(), response.status);
192346
}
193347

194348
#[test]
195-
fn test_unknown_has_fallback() {
196-
let router = Router::new().get("/*", get_hello_callback).unwrap();
349+
fn test_unmatched_get_catcher() {
350+
let router = Router::new()
351+
.catch_all(HttpMethod::GET, get_hello_callback)
352+
.unwrap();
197353

198354
let request = HttpRequest::from_raw_request(HttpRequestRaw {
199355
request_line: "GET /not-a-real-page HTTP/1.1".to_owned(),
@@ -235,4 +391,41 @@ mod tests {
235391
let response = router.handle_request(&request).unwrap();
236392
assert_eq!("{\"created\":true}\r\n".as_bytes(), response.body);
237393
}
394+
395+
#[test]
396+
fn test_dynamic_route() {
397+
let router = Router::new()
398+
.get("/users/:id/details", get_user_by_id)
399+
.unwrap();
400+
401+
let request = HttpRequest::from_raw_request(HttpRequestRaw {
402+
request_line: "GET /users/5/details HTTP/1.1".to_owned(),
403+
headers: Vec::new(),
404+
body: vec![],
405+
})
406+
.unwrap();
407+
408+
let response = router.handle_request(&request).unwrap();
409+
let actual_res: Value = serde_json::from_slice(&response.body).unwrap();
410+
assert_eq!("user_5", actual_res["username"]);
411+
}
412+
413+
#[test]
414+
fn test_dynamic_route_multiparams() {
415+
let router = Router::new()
416+
.get("/users/:id/info/:field", get_user_info)
417+
.unwrap();
418+
419+
let request = HttpRequest::from_raw_request(HttpRequestRaw {
420+
request_line: "GET /users/17/info/gender HTTP/1.1".to_owned(),
421+
headers: Vec::new(),
422+
body: vec![],
423+
})
424+
.unwrap();
425+
426+
let response = router.handle_request(&request).unwrap();
427+
let actual_res: Value = serde_json::from_slice(&response.body).unwrap();
428+
let expected_result = json!({ "username": "user_17", "field": "gender"});
429+
assert_eq!(expected_result, actual_res);
430+
}
238431
}

0 commit comments

Comments
 (0)