coa_website/
router.rs

1use std::{fs, io::ErrorKind, path::PathBuf};
2
3use crate::{config::Config, http::{HttpMethod, StatusCode}, request::HttpRequest, response::HttpResponse, stats::Stats};
4
5/// Router to dispatch `HttpRequest`
6#[derive(Default)]
7pub struct Router {
8    config: Config,
9    stats: Stats,
10}
11
12impl Router {
13
14    /// Create a new Router
15    pub fn new(config: Config, stats: Stats) -> Self {
16        Self { config, stats }
17    }
18
19    /// Handle a request and return a HttpResponse
20    pub fn handle_request(&mut self, request: &HttpRequest) -> HttpResponse {
21        let response: HttpResponse = match (request.method, request.path.as_str()) {
22            (_, "/stats") => {
23                self.stats.record(match self.stats.to_json() {
24                    Ok(_) => StatusCode::Ok.code(),
25                    Err(_) => StatusCode::InternalServerError.code()
26                });
27                self.get_head(&request.path, request.method == HttpMethod::HEAD)
28            }
29            (HttpMethod::GET | HttpMethod::HEAD, _) => {
30                let res = self.get_head(&request.path, request.method == HttpMethod::HEAD);
31                self.stats.record(res.status.code());
32                res
33            }
34        };
35
36        response
37    }
38
39    /// Handle methods GET and HEAD by serving dynamics routes or static files
40    fn get_head(&self, path: &str, is_head: bool) -> HttpResponse {
41        if path == "/stats" {
42            match self.stats.to_json() {
43                Ok(json) => HttpResponse::ok("application/json").with_body(json.into_bytes()).build().unwrap(),
44                Err(_) => HttpResponse::internal_server_error().build().unwrap(),
45            }
46        } else {
47            self.get_static(path, is_head)
48        }
49    }
50
51    /// Serve a static file. The default file and not found file are defined in the config. MIME types are defined in the config
52    fn get_static(&self, path: &str, is_head: bool) -> HttpResponse {
53        let mut full_path = PathBuf::from(&self.config.root_folder);
54        full_path.push(path.trim_start_matches("/"));
55
56        if full_path.is_dir() {
57            full_path.push(&self.config.index_file);
58        }
59
60        match fs::read(&full_path) {
61            Ok(content) => {
62                let extension = full_path
63                    .extension()
64                    .and_then(|e| e.to_str())
65                    .unwrap_or("");
66                let content_type = self.config.mime_type(extension);
67                if is_head {
68                    HttpResponse::builder()
69                        .with_status(StatusCode::Ok)
70                        .with_content_type(content_type)
71                        .with_header("Content-Length", &format!("{}", content.len()))
72                        .with_body(vec![])
73                        .build().unwrap()
74                } else {
75                    HttpResponse::ok(content_type).with_body(content).build().unwrap()
76                }
77            },
78            Err(e) if e.kind() == ErrorKind::PermissionDenied => {
79                HttpResponse::builder().with_status(StatusCode::Forbidden).build().unwrap()
80            },
81            Err(_) => {
82                let p = PathBuf::from(&self.config.root_folder).join(&self.config.not_found_file);
83                match fs::read(&p) {
84                    Ok(body) => HttpResponse::not_found().with_body(body).build().unwrap(),
85                    Err(_) => HttpResponse::not_found().build().unwrap()
86                }
87            }
88        }
89    }
90
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::config::Config;
97    use crate::http::HttpVersion;
98    use crate::stats::Stats;
99
100    fn mock_config() -> Config {
101        Config {
102            root_folder: "public".into(),
103            index_file: "index.html".into(),
104            log_file: "test.log".into(),
105            mime_types: [("html".into(), "text/html".into())].into_iter().collect(),
106            not_found_file: "404.html".into(),
107        }
108    }
109
110    #[test]
111    fn stats_route_returns_json() {
112        let mut router = Router::new(mock_config(), Stats::default());
113        let req = HttpRequest { method: HttpMethod::GET, path: "/stats".into(), version: HttpVersion::Http1_1, headers: Default::default() };
114        let res = router.handle_request(&req);
115        assert_eq!(res.status.code(), 200);
116        assert_eq!(res.headers.get("Content-Type").unwrap(), "application/json");
117    }
118
119    #[test]
120    fn missing_file_returns_404() {
121        let mut router = Router::new(mock_config(), Stats::default());
122        let req = HttpRequest { method: HttpMethod::GET, path: "/nope.html".into(), version: HttpVersion::Http1_1, headers: Default::default() };
123        assert_eq!(router.handle_request(&req).status.code(), 404);
124    }
125
126    #[test]
127    fn head_request_has_no_body() {
128        let mut router = Router::new(mock_config(), Stats::default());
129        let req = HttpRequest { method: HttpMethod::HEAD, path: "/index.html".into(), version: HttpVersion::Http1_1, headers: Default::default() };
130        let res = router.handle_request(&req);
131        assert_eq!(res.status.code(), 200);
132        assert!(res.body.is_none() || res.body.unwrap().is_empty());
133    }
134}