From 18d5263e843660e667d8e15fb4e1b84ffe074b8f Mon Sep 17 00:00:00 2001 From: Gus Power Date: Fri, 24 Jan 2025 18:30:36 +0000 Subject: [PATCH 1/7] [feat] Handling requests without an .sql extension directly in SQLPage. First commit, routing.rs. --- src/webserver/mod.rs | 1 + src/webserver/routing.rs | 193 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/webserver/routing.rs diff --git a/src/webserver/mod.rs b/src/webserver/mod.rs index 6592d412..00e19e80 100644 --- a/src/webserver/mod.rs +++ b/src/webserver/mod.rs @@ -43,4 +43,5 @@ pub use error_with_status::ErrorWithStatus; pub use database::make_placeholder; pub use database::migrations::apply; pub mod response_writer; +mod routing; mod static_content; diff --git a/src/webserver/routing.rs b/src/webserver/routing.rs new file mode 100644 index 00000000..4567bef9 --- /dev/null +++ b/src/webserver/routing.rs @@ -0,0 +1,193 @@ +use std::path::PathBuf; +use RoutingAction::{Error, Execute, NotFound, Redirect, Serve}; + +const INDEX: &'static str = "index.sql"; +const NOT_FOUND: &'static str = "404.sql"; +const EXECUTION_EXTENSION: &'static str = "sql"; + +#[derive(Debug, PartialEq)] +pub enum RoutingAction { + Error(String), + Execute(PathBuf), + NotFound(PathBuf), + Redirect(String), + Serve(PathBuf), +} + +pub trait ExecutionStore { + fn contains(&self, path: &PathBuf) -> bool; +} + +pub fn calculate_route(uri: &str, store: T) -> RoutingAction +where + T: ExecutionStore, +{ + let mut path = PathBuf::from(uri); + match path.extension() { + None => { + if uri.ends_with("/") { + path.push(INDEX); + find_execution(path, store) + } else { + Redirect(format!("{}/", uri)) + } + } + Some(extension) => { + if extension == EXECUTION_EXTENSION { + find_execution(path, store) + } else { + Serve(PathBuf::from(uri)) + } + } + } +} + +fn find_execution(path: PathBuf, store: T) -> RoutingAction +where + T: ExecutionStore, +{ + if store.contains(&path) { + Execute(path) + } else { + find_not_found(path, store) + } +} + +fn find_not_found(path: PathBuf, store: T) -> RoutingAction +where + T: ExecutionStore, +{ + let mut parent = path.parent(); + while let Some(p) = parent { + let target = p.join(NOT_FOUND); + if store.contains(&target) { + return NotFound(target); + } else { + parent = p.parent() + } + } + + Error(path_to_string(&path)) +} + +fn path_to_string(path: &PathBuf) -> String { + path.to_string_lossy().to_string() +} + +#[cfg(test)] +mod tests { + use super::RoutingAction::{Error, Execute, NotFound, Redirect, Serve}; + use super::{calculate_route, path_to_string, ExecutionStore}; + use std::path::PathBuf; + + #[test] + fn root_path_executes_index_sql() { + let actual = calculate_route("/", Store::default()); + let expected = Execute(PathBuf::from("/index.sql")); + + assert_eq!(expected, actual); + } + + #[test] + fn path_with_sql_extension_executes_corresponding_sql_file() { + let actual = calculate_route("/index.sql", Store::default()); + let expected = Execute(PathBuf::from("/index.sql")); + + assert_eq!(expected, actual); + } + + #[test] + fn path_with_sql_extension_executes_corresponding_not_found_file() { + let actual = calculate_route("/unknown.sql", Store::default()); + let expected = NotFound(PathBuf::from("/404.sql")); + + assert_eq!(expected, actual); + } + + #[test] + fn path_with_sql_extension_executes_deeper_not_found_file_if_exists() { + let actual = calculate_route("/unknown/unknown.sql", Store::new("/unknown/404.sql")); + let expected = NotFound(PathBuf::from("/unknown/404.sql")); + + assert_eq!(expected, actual); + } + + #[test] + fn path_with_sql_extension_executes_deepest_not_found_file_that_exists() { + let actual = calculate_route( + "/unknown/unknown/unknown.sql", + Store::new("/unknown/404.sql"), + ); + let expected = NotFound(PathBuf::from("/unknown/404.sql")); + + assert_eq!(expected, actual); + } + + #[test] + fn path_with_sql_extension_errors_when_no_not_found_file_available() { + let actual = calculate_route("/unknown.sql", Store::empty()); + let expected = Error("/unknown.sql".to_string()); + + assert_eq!(expected, actual); + } + + #[test] + fn path_with_no_extension_and_no_corresponding_sql_file_redirects_with_trailing_slash() { + let actual = calculate_route("/folder", Store::default()); + let expected = Redirect("/folder/".to_string()); + + assert_eq!(expected, actual); + } + + #[test] + fn path_with_trailing_slash_executes_index_sql_from_directory() { + let actual = calculate_route("/folder/", Store::new("/folder/index.sql")); + let expected = Execute(PathBuf::from("/folder/index.sql")); + + assert_eq!(expected, actual); + } + + #[test] + fn non_sql_file_extension_serves_corresponding_asset() { + let actual = calculate_route("/favicon.ico", Store::default()); + let expected = Serve(PathBuf::from("/favicon.ico")); + + assert_eq!(expected, actual); + } + + struct Store { + contents: Vec, + } + + impl Store { + const INDEX: &'static str = "/index.sql"; + const NOT_FOUND: &'static str = "/404.sql"; + fn new(path: &str) -> Self { + let mut contents = Self::default_contents(); + contents.push(path.to_string()); + Self { contents } + } + + fn default_contents() -> Vec { + vec![Self::INDEX.to_string(), Self::NOT_FOUND.to_string()] + } + + fn empty() -> Self { + Self { contents: vec![] } + } + } + + impl Default for Store { + fn default() -> Self { + Self { + contents: Self::default_contents(), + } + } + } + + impl ExecutionStore for Store { + fn contains(&self, path: &PathBuf) -> bool { + self.contents.contains(&path_to_string(path)) + } + } +} From 5c304c76283b4be2d5b9d95afc2a33af58ce5bbf Mon Sep 17 00:00:00 2001 From: Gus Power Date: Sat, 25 Jan 2025 16:12:13 +0000 Subject: [PATCH 2/7] up-type into from &str to Uri. Handle paths without extensions (serve equivalent .sql if exists, otherwise not found). --- src/webserver/routing.rs | 146 +++++++++++++++++++++++++++++---------- 1 file changed, 111 insertions(+), 35 deletions(-) diff --git a/src/webserver/routing.rs b/src/webserver/routing.rs index 4567bef9..cb88ae45 100644 --- a/src/webserver/routing.rs +++ b/src/webserver/routing.rs @@ -1,16 +1,18 @@ +use awc::http::Uri; use std::path::PathBuf; use RoutingAction::{Error, Execute, NotFound, Redirect, Serve}; const INDEX: &'static str = "index.sql"; const NOT_FOUND: &'static str = "404.sql"; const EXECUTION_EXTENSION: &'static str = "sql"; +const FORWARD_SLASH: &'static str = "/"; #[derive(Debug, PartialEq)] pub enum RoutingAction { Error(String), Execute(PathBuf), NotFound(PathBuf), - Redirect(String), + Redirect(Uri), Serve(PathBuf), } @@ -18,42 +20,56 @@ pub trait ExecutionStore { fn contains(&self, path: &PathBuf) -> bool; } -pub fn calculate_route(uri: &str, store: T) -> RoutingAction +pub fn calculate_route(uri: Uri, store: &T) -> RoutingAction where T: ExecutionStore, { - let mut path = PathBuf::from(uri); + let mut path = PathBuf::from(uri.path()); match path.extension() { None => { - if uri.ends_with("/") { + if uri.path().ends_with(FORWARD_SLASH) { path.push(INDEX); - find_execution(path, store) + find_execution_or_not_found(path, store) } else { - Redirect(format!("{}/", uri)) + let path_with_ext = path.with_extension(EXECUTION_EXTENSION); + match find_execution(path_with_ext, store) { + Some(action) => action, + None => Redirect(append_to_uri_path(&uri, FORWARD_SLASH)), + } } } Some(extension) => { if extension == EXECUTION_EXTENSION { - find_execution(path, store) + find_execution_or_not_found(path, store) } else { - Serve(PathBuf::from(uri)) + Serve(PathBuf::from(uri.path())) } } } } -fn find_execution(path: PathBuf, store: T) -> RoutingAction +fn find_execution_or_not_found(path: PathBuf, store: &T) -> RoutingAction +where + T: ExecutionStore, +{ + match find_execution(path.clone(), store) { + None => find_not_found(path, store), + Some(execute) => execute, + } +} + +fn find_execution(path: PathBuf, store: &T) -> Option where T: ExecutionStore, { if store.contains(&path) { - Execute(path) + Some(Execute(path)) } else { - find_not_found(path, store) + None } } -fn find_not_found(path: PathBuf, store: T) -> RoutingAction +fn find_not_found(path: PathBuf, store: &T) -> RoutingAction where T: ExecutionStore, { @@ -70,6 +86,12 @@ where Error(path_to_string(&path)) } +fn append_to_uri_path(uri: &Uri, append: &str) -> Uri { + let mut full_uri = uri.to_string(); + full_uri.insert_str(uri.path().len(), append); + full_uri.parse().unwrap() +} + fn path_to_string(path: &PathBuf) -> String { path.to_string_lossy().to_string() } @@ -77,84 +99,138 @@ fn path_to_string(path: &PathBuf) -> String { #[cfg(test)] mod tests { use super::RoutingAction::{Error, Execute, NotFound, Redirect, Serve}; - use super::{calculate_route, path_to_string, ExecutionStore}; + use super::{calculate_route, path_to_string, ExecutionStore, RoutingAction}; + use awc::http::Uri; + use std::default::Default as StdDefault; use std::path::PathBuf; + use std::str::FromStr; + use StoreConfig::{Default, Empty, File}; #[test] fn root_path_executes_index_sql() { - let actual = calculate_route("/", Store::default()); - let expected = Execute(PathBuf::from("/index.sql")); + let actual = do_route("/", Default); + let expected = execute("/index.sql"); assert_eq!(expected, actual); } #[test] fn path_with_sql_extension_executes_corresponding_sql_file() { - let actual = calculate_route("/index.sql", Store::default()); - let expected = Execute(PathBuf::from("/index.sql")); + let actual = do_route("/index.sql", Default); + let expected = execute("/index.sql"); assert_eq!(expected, actual); } #[test] fn path_with_sql_extension_executes_corresponding_not_found_file() { - let actual = calculate_route("/unknown.sql", Store::default()); - let expected = NotFound(PathBuf::from("/404.sql")); + let actual = do_route("/unknown.sql", Default); + let expected = not_found("/404.sql"); assert_eq!(expected, actual); } #[test] fn path_with_sql_extension_executes_deeper_not_found_file_if_exists() { - let actual = calculate_route("/unknown/unknown.sql", Store::new("/unknown/404.sql")); - let expected = NotFound(PathBuf::from("/unknown/404.sql")); + let actual = do_route("/unknown/unknown.sql", File("/unknown/404.sql")); + let expected = not_found("/unknown/404.sql"); assert_eq!(expected, actual); } #[test] fn path_with_sql_extension_executes_deepest_not_found_file_that_exists() { - let actual = calculate_route( - "/unknown/unknown/unknown.sql", - Store::new("/unknown/404.sql"), - ); - let expected = NotFound(PathBuf::from("/unknown/404.sql")); + let actual = do_route("/unknown/unknown/unknown.sql", File("/unknown/404.sql")); + let expected = not_found("/unknown/404.sql"); assert_eq!(expected, actual); } #[test] fn path_with_sql_extension_errors_when_no_not_found_file_available() { - let actual = calculate_route("/unknown.sql", Store::empty()); - let expected = Error("/unknown.sql".to_string()); + let actual = do_route("/unknown.sql", Empty); + let expected = error("/unknown.sql"); assert_eq!(expected, actual); } #[test] fn path_with_no_extension_and_no_corresponding_sql_file_redirects_with_trailing_slash() { - let actual = calculate_route("/folder", Store::default()); - let expected = Redirect("/folder/".to_string()); + let actual = do_route("/folder", Default); + let expected = redirect("/folder/"); + + assert_eq!(expected, actual); + } + + #[test] + fn path_with_no_extension_executes_corresponding_sql_file_if_exists() { + let actual = do_route("/path", File("/path.sql")); + let expected = execute("/path.sql"); assert_eq!(expected, actual); } #[test] fn path_with_trailing_slash_executes_index_sql_from_directory() { - let actual = calculate_route("/folder/", Store::new("/folder/index.sql")); - let expected = Execute(PathBuf::from("/folder/index.sql")); + let actual = do_route("/folder/", File("/folder/index.sql")); + let expected = execute("/folder/index.sql"); assert_eq!(expected, actual); } #[test] fn non_sql_file_extension_serves_corresponding_asset() { - let actual = calculate_route("/favicon.ico", Store::default()); - let expected = Serve(PathBuf::from("/favicon.ico")); + let actual = do_route("/favicon.ico", Default); + let expected = serve("/favicon.ico"); + + assert_eq!(expected, actual); + } + + #[test] + #[ignore] + fn path_without_site_prefix_redirects_to_site_prefix() { + let _prefix = "/sqlpage/"; + let actual = do_route("/path", File("/path.sql")); + let expected = redirect("/sqlpage/"); assert_eq!(expected, actual); } + fn do_route(uri: &str, config: StoreConfig) -> RoutingAction { + let store = match config { + Default => Store::default(), + Empty => Store::empty(), + File(file) => Store::new(file), + }; + calculate_route(Uri::from_str(uri).unwrap(), &store) + } + + fn error(uri: &str) -> RoutingAction { + Error(uri.to_string()) + } + + fn execute(path: &str) -> RoutingAction { + Execute(PathBuf::from(path)) + } + + fn not_found(path: &str) -> RoutingAction { + NotFound(PathBuf::from(path)) + } + + fn redirect(uri: &str) -> RoutingAction { + Redirect(Uri::from_str(uri).unwrap()) + } + + fn serve(path: &str) -> RoutingAction { + Serve(PathBuf::from(path)) + } + + enum StoreConfig { + Default, + Empty, + File(&'static str), + } + struct Store { contents: Vec, } @@ -177,7 +253,7 @@ mod tests { } } - impl Default for Store { + impl StdDefault for Store { fn default() -> Self { Self { contents: Self::default_contents(), From 534958a606f2f2a3abf87da18af0f1e6e3e93e54 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Sat, 25 Jan 2025 16:25:39 +0000 Subject: [PATCH 3/7] make ExecutionStore async. still way too noisy to do this in rust-lang. --- src/webserver/routing.rs | 96 ++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/src/webserver/routing.rs b/src/webserver/routing.rs index cb88ae45..4815556b 100644 --- a/src/webserver/routing.rs +++ b/src/webserver/routing.rs @@ -17,10 +17,10 @@ pub enum RoutingAction { } pub trait ExecutionStore { - fn contains(&self, path: &PathBuf) -> bool; + async fn contains(&self, path: &PathBuf) -> bool; } -pub fn calculate_route(uri: Uri, store: &T) -> RoutingAction +pub async fn calculate_route(uri: Uri, store: &T) -> RoutingAction where T: ExecutionStore, { @@ -29,10 +29,10 @@ where None => { if uri.path().ends_with(FORWARD_SLASH) { path.push(INDEX); - find_execution_or_not_found(path, store) + find_execution_or_not_found(path, store).await } else { let path_with_ext = path.with_extension(EXECUTION_EXTENSION); - match find_execution(path_with_ext, store) { + match find_execution(path_with_ext, store).await { Some(action) => action, None => Redirect(append_to_uri_path(&uri, FORWARD_SLASH)), } @@ -40,7 +40,7 @@ where } Some(extension) => { if extension == EXECUTION_EXTENSION { - find_execution_or_not_found(path, store) + find_execution_or_not_found(path, store).await } else { Serve(PathBuf::from(uri.path())) } @@ -48,35 +48,35 @@ where } } -fn find_execution_or_not_found(path: PathBuf, store: &T) -> RoutingAction +async fn find_execution_or_not_found(path: PathBuf, store: &T) -> RoutingAction where T: ExecutionStore, { - match find_execution(path.clone(), store) { - None => find_not_found(path, store), + match find_execution(path.clone(), store).await { + None => find_not_found(path, store).await, Some(execute) => execute, } } -fn find_execution(path: PathBuf, store: &T) -> Option +async fn find_execution(path: PathBuf, store: &T) -> Option where T: ExecutionStore, { - if store.contains(&path) { + if store.contains(&path).await { Some(Execute(path)) } else { None } } -fn find_not_found(path: PathBuf, store: &T) -> RoutingAction +async fn find_not_found(path: PathBuf, store: &T) -> RoutingAction where T: ExecutionStore, { let mut parent = path.parent(); while let Some(p) = parent { let target = p.join(NOT_FOUND); - if store.contains(&target) { + if store.contains(&target).await { return NotFound(target); } else { parent = p.parent() @@ -106,103 +106,103 @@ mod tests { use std::str::FromStr; use StoreConfig::{Default, Empty, File}; - #[test] - fn root_path_executes_index_sql() { - let actual = do_route("/", Default); + #[tokio::test] + async fn root_path_executes_index_sql() { + let actual = do_route("/", Default).await; let expected = execute("/index.sql"); assert_eq!(expected, actual); } - #[test] - fn path_with_sql_extension_executes_corresponding_sql_file() { - let actual = do_route("/index.sql", Default); + #[tokio::test] + async fn path_with_sql_extension_executes_corresponding_sql_file() { + let actual = do_route("/index.sql", Default).await; let expected = execute("/index.sql"); assert_eq!(expected, actual); } - #[test] - fn path_with_sql_extension_executes_corresponding_not_found_file() { - let actual = do_route("/unknown.sql", Default); + #[tokio::test] + async fn path_with_sql_extension_executes_corresponding_not_found_file() { + let actual = do_route("/unknown.sql", Default).await; let expected = not_found("/404.sql"); assert_eq!(expected, actual); } - #[test] - fn path_with_sql_extension_executes_deeper_not_found_file_if_exists() { - let actual = do_route("/unknown/unknown.sql", File("/unknown/404.sql")); + #[tokio::test] + async fn path_with_sql_extension_executes_deeper_not_found_file_if_exists() { + let actual = do_route("/unknown/unknown.sql", File("/unknown/404.sql")).await; let expected = not_found("/unknown/404.sql"); assert_eq!(expected, actual); } - #[test] - fn path_with_sql_extension_executes_deepest_not_found_file_that_exists() { - let actual = do_route("/unknown/unknown/unknown.sql", File("/unknown/404.sql")); + #[tokio::test] + async fn path_with_sql_extension_executes_deepest_not_found_file_that_exists() { + let actual = do_route("/unknown/unknown/unknown.sql", File("/unknown/404.sql")).await; let expected = not_found("/unknown/404.sql"); assert_eq!(expected, actual); } - #[test] - fn path_with_sql_extension_errors_when_no_not_found_file_available() { - let actual = do_route("/unknown.sql", Empty); + #[tokio::test] + async fn path_with_sql_extension_errors_when_no_not_found_file_available() { + let actual = do_route("/unknown.sql", Empty).await; let expected = error("/unknown.sql"); assert_eq!(expected, actual); } - #[test] - fn path_with_no_extension_and_no_corresponding_sql_file_redirects_with_trailing_slash() { - let actual = do_route("/folder", Default); + #[tokio::test] + async fn path_with_no_extension_and_no_corresponding_sql_file_redirects_with_trailing_slash() { + let actual = do_route("/folder", Default).await; let expected = redirect("/folder/"); assert_eq!(expected, actual); } - #[test] - fn path_with_no_extension_executes_corresponding_sql_file_if_exists() { - let actual = do_route("/path", File("/path.sql")); + #[tokio::test] + async fn path_with_no_extension_executes_corresponding_sql_file_if_exists() { + let actual = do_route("/path", File("/path.sql")).await; let expected = execute("/path.sql"); assert_eq!(expected, actual); } - #[test] - fn path_with_trailing_slash_executes_index_sql_from_directory() { - let actual = do_route("/folder/", File("/folder/index.sql")); + #[tokio::test] + async fn path_with_trailing_slash_executes_index_sql_from_directory() { + let actual = do_route("/folder/", File("/folder/index.sql")).await; let expected = execute("/folder/index.sql"); assert_eq!(expected, actual); } - #[test] - fn non_sql_file_extension_serves_corresponding_asset() { - let actual = do_route("/favicon.ico", Default); + #[tokio::test] + async fn non_sql_file_extension_serves_corresponding_asset() { + let actual = do_route("/favicon.ico", Default).await; let expected = serve("/favicon.ico"); assert_eq!(expected, actual); } - #[test] + #[tokio::test] #[ignore] - fn path_without_site_prefix_redirects_to_site_prefix() { + async fn path_without_site_prefix_redirects_to_site_prefix() { let _prefix = "/sqlpage/"; - let actual = do_route("/path", File("/path.sql")); + let actual = do_route("/path", File("/path.sql")).await; let expected = redirect("/sqlpage/"); assert_eq!(expected, actual); } - fn do_route(uri: &str, config: StoreConfig) -> RoutingAction { + async fn do_route(uri: &str, config: StoreConfig) -> RoutingAction { let store = match config { Default => Store::default(), Empty => Store::empty(), File(file) => Store::new(file), }; - calculate_route(Uri::from_str(uri).unwrap(), &store) + calculate_route(Uri::from_str(uri).unwrap(), &store).await } fn error(uri: &str) -> RoutingAction { @@ -262,7 +262,7 @@ mod tests { } impl ExecutionStore for Store { - fn contains(&self, path: &PathBuf) -> bool { + async fn contains(&self, path: &PathBuf) -> bool { self.contents.contains(&path_to_string(path)) } } From 25c36df0ae0576d99b0ba3074a82d07356604824 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Sun, 26 Jan 2025 12:45:09 +0000 Subject: [PATCH 4/7] handle site prefix. it's clunky, still have to work out some unwraps. namespaced tests as they were getting overwhelming. --- src/webserver/routing.rs | 302 ++++++++++++++++++++++++++++++--------- 1 file changed, 233 insertions(+), 69 deletions(-) diff --git a/src/webserver/routing.rs b/src/webserver/routing.rs index 4815556b..f6693629 100644 --- a/src/webserver/routing.rs +++ b/src/webserver/routing.rs @@ -20,11 +20,24 @@ pub trait ExecutionStore { async fn contains(&self, path: &PathBuf) -> bool; } -pub async fn calculate_route(uri: Uri, store: &T) -> RoutingAction +pub trait RoutingConfig { + fn prefix(&self) -> &str; +} + +pub async fn calculate_route(uri: Uri, store: &T, config: &C) -> RoutingAction where T: ExecutionStore, + C: RoutingConfig, { - let mut path = PathBuf::from(uri.path()); + if !uri.path().starts_with(config.prefix()) { + return Redirect(config.prefix().parse().unwrap()); + }; + + let mut path = PathBuf::from(format!( + "/{}", + uri.path().strip_prefix(config.prefix()).unwrap() + )); + match path.extension() { None => { if uri.path().ends_with(FORWARD_SLASH) { @@ -42,7 +55,7 @@ where if extension == EXECUTION_EXTENSION { find_execution_or_not_found(path, store).await } else { - Serve(PathBuf::from(uri.path())) + Serve(path) } } } @@ -99,110 +112,238 @@ fn path_to_string(path: &PathBuf) -> String { #[cfg(test)] mod tests { use super::RoutingAction::{Error, Execute, NotFound, Redirect, Serve}; - use super::{calculate_route, path_to_string, ExecutionStore, RoutingAction}; + use super::{calculate_route, path_to_string, ExecutionStore, RoutingAction, RoutingConfig}; use awc::http::Uri; use std::default::Default as StdDefault; use std::path::PathBuf; use std::str::FromStr; use StoreConfig::{Default, Empty, File}; - #[tokio::test] - async fn root_path_executes_index_sql() { - let actual = do_route("/", Default).await; - let expected = execute("/index.sql"); + mod execute { + use super::StoreConfig::{Default, File}; + use super::{do_route, execute}; - assert_eq!(expected, actual); - } + #[tokio::test] + async fn root_path_executes_index() { + let actual = do_route("/", Default, None).await; + let expected = execute("/index.sql"); - #[tokio::test] - async fn path_with_sql_extension_executes_corresponding_sql_file() { - let actual = do_route("/index.sql", Default).await; - let expected = execute("/index.sql"); + assert_eq!(expected, actual); + } - assert_eq!(expected, actual); - } + #[tokio::test] + async fn root_path_with_site_prefix_executes_index() { + let actual = do_route("/prefix/", Default, Some("/prefix/")).await; + let expected = execute("/index.sql"); - #[tokio::test] - async fn path_with_sql_extension_executes_corresponding_not_found_file() { - let actual = do_route("/unknown.sql", Default).await; - let expected = not_found("/404.sql"); + assert_eq!(expected, actual); + } - assert_eq!(expected, actual); - } + #[tokio::test] + async fn sql_extension() { + let actual = do_route("/index.sql", Default, None).await; + let expected = execute("/index.sql"); - #[tokio::test] - async fn path_with_sql_extension_executes_deeper_not_found_file_if_exists() { - let actual = do_route("/unknown/unknown.sql", File("/unknown/404.sql")).await; - let expected = not_found("/unknown/404.sql"); + assert_eq!(expected, actual); + } - assert_eq!(expected, actual); - } + #[tokio::test] + async fn sql_extension_and_site_prefix() { + let actual = do_route("/prefix/index.sql", Default, Some("/prefix/")).await; + let expected = execute("/index.sql"); - #[tokio::test] - async fn path_with_sql_extension_executes_deepest_not_found_file_that_exists() { - let actual = do_route("/unknown/unknown/unknown.sql", File("/unknown/404.sql")).await; - let expected = not_found("/unknown/404.sql"); + assert_eq!(expected, actual); + } - assert_eq!(expected, actual); - } + #[tokio::test] + async fn no_extension() { + let actual = do_route("/path", File("/path.sql"), None).await; + let expected = execute("/path.sql"); - #[tokio::test] - async fn path_with_sql_extension_errors_when_no_not_found_file_available() { - let actual = do_route("/unknown.sql", Empty).await; - let expected = error("/unknown.sql"); + assert_eq!(expected, actual); + } - assert_eq!(expected, actual); - } + #[tokio::test] + async fn no_extension_and_site_prefix() { + let actual = do_route("/prefix/path", File("/path.sql"), Some("/prefix/")).await; + let expected = execute("/path.sql"); + + assert_eq!(expected, actual); + } - #[tokio::test] - async fn path_with_no_extension_and_no_corresponding_sql_file_redirects_with_trailing_slash() { - let actual = do_route("/folder", Default).await; - let expected = redirect("/folder/"); + #[tokio::test] + async fn trailing_slash_executes_index_in_directory() { + let actual = do_route("/folder/", File("/folder/index.sql"), None).await; + let expected = execute("/folder/index.sql"); - assert_eq!(expected, actual); + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn trailing_slash_and_site_prefix_executes_index_in_directory() { + let actual = do_route( + "/prefix/folder/", + File("/folder/index.sql"), + Some("/prefix/"), + ) + .await; + let expected = execute("/folder/index.sql"); + + assert_eq!(expected, actual); + } } - #[tokio::test] - async fn path_with_no_extension_executes_corresponding_sql_file_if_exists() { - let actual = do_route("/path", File("/path.sql")).await; - let expected = execute("/path.sql"); + mod not_found { + use super::StoreConfig::{Default, File}; + use super::{do_route, not_found}; + + #[tokio::test] + async fn sql_extension() { + let actual = do_route("/unknown.sql", Default, None).await; + let expected = not_found("/404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_and_site_prefix() { + let actual = do_route("/prefix/unknown.sql", Default, Some("/prefix/")).await; + let expected = not_found("/404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_executes_deeper_not_found_file_if_exists() { + let actual = do_route("/unknown/unknown.sql", File("/unknown/404.sql"), None).await; + let expected = not_found("/unknown/404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_and_site_prefix_executes_deeper_not_found_file_if_exists() { + let actual = do_route( + "/prefix/unknown/unknown.sql", + File("/unknown/404.sql"), + Some("/prefix/"), + ) + .await; + let expected = not_found("/unknown/404.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_executes_deepest_not_found_file_that_exists() { + let actual = do_route( + "/unknown/unknown/unknown.sql", + File("/unknown/404.sql"), + None, + ) + .await; + let expected = not_found("/unknown/404.sql"); + + assert_eq!(expected, actual); + } - assert_eq!(expected, actual); + #[tokio::test] + async fn sql_extension_and_site_prefix_executes_deepest_not_found_file_that_exists() { + let actual = do_route( + "/prefix/unknown/unknown/unknown.sql", + File("/unknown/404.sql"), + Some("/prefix/"), + ) + .await; + let expected = not_found("/unknown/404.sql"); + + assert_eq!(expected, actual); + } } - #[tokio::test] - async fn path_with_trailing_slash_executes_index_sql_from_directory() { - let actual = do_route("/folder/", File("/folder/index.sql")).await; - let expected = execute("/folder/index.sql"); + mod error { + use super::StoreConfig::Empty; + use super::{do_route, error}; + + #[tokio::test] + async fn sql_extension_errors_when_no_not_found_file_available() { + let actual = do_route("/unknown.sql", Empty, None).await; + let expected = error("/unknown.sql"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn sql_extension_and_site_prefix_errors_when_no_not_found_file_available() { + let actual = do_route("/prefix/unknown.sql", Empty, Some("/prefix/")).await; + let expected = error("/unknown.sql"); - assert_eq!(expected, actual); + assert_eq!(expected, actual); + } } - #[tokio::test] - async fn non_sql_file_extension_serves_corresponding_asset() { - let actual = do_route("/favicon.ico", Default).await; - let expected = serve("/favicon.ico"); + mod asset { + use super::StoreConfig::Default; + use super::{do_route, serve}; + + #[tokio::test] + async fn serves_corresponding_asset() { + let actual = do_route("/favicon.ico", Default, None).await; + let expected = serve("/favicon.ico"); + + assert_eq!(expected, actual); + } - assert_eq!(expected, actual); + #[tokio::test] + async fn serves_corresponding_asset_given_site_prefix() { + let actual = do_route("/prefix/favicon.ico", Default, Some("/prefix/")).await; + let expected = serve("/favicon.ico"); + + assert_eq!(expected, actual); + } } - #[tokio::test] - #[ignore] - async fn path_without_site_prefix_redirects_to_site_prefix() { - let _prefix = "/sqlpage/"; - let actual = do_route("/path", File("/path.sql")).await; - let expected = redirect("/sqlpage/"); + mod redirect { + use super::StoreConfig::Default; + use super::{do_route, redirect}; + + #[tokio::test] + async fn path_without_site_prefix_redirects_to_site_prefix() { + let actual = do_route("/path", Default, Some("/prefix/")).await; + let expected = redirect("/prefix/"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_and_no_corresponding_file_redirects_with_trailing_slash() { + let actual = do_route("/folder", Default, None).await; + let expected = redirect("/folder/"); + + assert_eq!(expected, actual); + } - assert_eq!(expected, actual); + #[tokio::test] + async fn no_extension_site_prefix_and_no_corresponding_file_redirects_with_trailing_slash() + { + let actual = do_route("/prefix/folder", Default, Some("/prefix/")).await; + let expected = redirect("/prefix/folder/"); + + assert_eq!(expected, actual); + } } - async fn do_route(uri: &str, config: StoreConfig) -> RoutingAction { + async fn do_route(uri: &str, config: StoreConfig, prefix: Option<&str>) -> RoutingAction { let store = match config { Default => Store::default(), Empty => Store::empty(), File(file) => Store::new(file), }; - calculate_route(Uri::from_str(uri).unwrap(), &store).await + let config = match prefix { + None => Config::default(), + Some(value) => Config::new(value), + }; + calculate_route(Uri::from_str(uri).unwrap(), &store, &config).await } fn error(uri: &str) -> RoutingAction { @@ -266,4 +407,27 @@ mod tests { self.contents.contains(&path_to_string(path)) } } + + struct Config { + prefix: String, + } + + impl Config { + fn new(prefix: &str) -> Self { + Self { + prefix: prefix.to_string(), + } + } + } + impl RoutingConfig for Config { + fn prefix(&self) -> &str { + self.prefix.as_str() + } + } + + impl StdDefault for Config { + fn default() -> Self { + Self::new("/") + } + } } From 44bbff1bc71ad04920b810e850a07adce52bf3e8 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Sun, 26 Jan 2025 15:38:59 +0000 Subject: [PATCH 5/7] verify query and fragment works as expected for assets and redirects --- src/webserver/routing.rs | 149 +++++++++++++++++++++++++++------------ 1 file changed, 105 insertions(+), 44 deletions(-) diff --git a/src/webserver/routing.rs b/src/webserver/routing.rs index f6693629..d2106f7b 100644 --- a/src/webserver/routing.rs +++ b/src/webserver/routing.rs @@ -1,4 +1,5 @@ use awc::http::Uri; +use std::ffi::OsStr; use std::path::PathBuf; use RoutingAction::{Error, Execute, NotFound, Redirect, Serve}; @@ -21,7 +22,7 @@ pub trait ExecutionStore { } pub trait RoutingConfig { - fn prefix(&self) -> &str; + fn prefix(&self) -> &Uri; } pub async fn calculate_route(uri: Uri, store: &T, config: &C) -> RoutingAction @@ -29,60 +30,88 @@ where T: ExecutionStore, C: RoutingConfig, { - if !uri.path().starts_with(config.prefix()) { - return Redirect(config.prefix().parse().unwrap()); - }; - - let mut path = PathBuf::from(format!( - "/{}", - uri.path().strip_prefix(config.prefix()).unwrap() - )); - - match path.extension() { - None => { - if uri.path().ends_with(FORWARD_SLASH) { - path.push(INDEX); - find_execution_or_not_found(path, store).await - } else { - let path_with_ext = path.with_extension(EXECUTION_EXTENSION); - match find_execution(path_with_ext, store).await { - Some(action) => action, - None => Redirect(append_to_uri_path(&uri, FORWARD_SLASH)), - } - } - } - Some(extension) => { - if extension == EXECUTION_EXTENSION { - find_execution_or_not_found(path, store).await - } else { - Serve(path) - } + match check_uri(&uri, config) { + Ok(path) => match path.clone().extension() { + None => calculate_route_without_extension(&uri, path, store).await, + Some(extension) => calculate_route_with_extension(path, extension, store).await, + }, + Err(action) => action, + } +} + +fn check_uri(uri: &Uri, config: &C) -> Result +where + C: RoutingConfig, +{ + if uri.path().starts_with(config.prefix().path()) { + Ok(PathBuf::from(format!( + "/{}", + uri.path() + .strip_prefix(config.prefix().path()) + .expect("Unable to remove expected prefix from path") + ))) + } else { + Err(Redirect(config.prefix().clone())) + } +} + +async fn calculate_route_without_extension( + uri: &Uri, + mut path: PathBuf, + store: &T, +) -> RoutingAction +where + T: ExecutionStore, +{ + if uri.path().ends_with(FORWARD_SLASH) { + path.push(INDEX); + find_execution_or_not_found(&path, store).await + } else { + let path_with_ext = path.with_extension(EXECUTION_EXTENSION); + match find_execution(&path_with_ext, store).await { + Some(action) => action, + None => Redirect(append_to_uri_path(&uri, FORWARD_SLASH)), } } } -async fn find_execution_or_not_found(path: PathBuf, store: &T) -> RoutingAction +async fn calculate_route_with_extension( + path: PathBuf, + extension: &OsStr, + store: &T, +) -> RoutingAction +where + T: ExecutionStore, +{ + if extension == EXECUTION_EXTENSION { + find_execution_or_not_found(&path, store).await + } else { + Serve(path) + } +} + +async fn find_execution_or_not_found(path: &PathBuf, store: &T) -> RoutingAction where T: ExecutionStore, { - match find_execution(path.clone(), store).await { + match find_execution(path, store).await { None => find_not_found(path, store).await, Some(execute) => execute, } } -async fn find_execution(path: PathBuf, store: &T) -> Option +async fn find_execution(path: &PathBuf, store: &T) -> Option where T: ExecutionStore, { - if store.contains(&path).await { - Some(Execute(path)) + if store.contains(path).await { + Some(Execute(path.clone())) } else { None } } -async fn find_not_found(path: PathBuf, store: &T) -> RoutingAction +async fn find_not_found(path: &PathBuf, store: &T) -> RoutingAction where T: ExecutionStore, { @@ -96,13 +125,13 @@ where } } - Error(path_to_string(&path)) + Error(path_to_string(path)) } fn append_to_uri_path(uri: &Uri, append: &str) -> Uri { let mut full_uri = uri.to_string(); full_uri.insert_str(uri.path().len(), append); - full_uri.parse().unwrap() + full_uri.parse().expect("Could not append uri path") } fn path_to_string(path: &PathBuf) -> String { @@ -132,7 +161,7 @@ mod tests { } #[tokio::test] - async fn root_path_with_site_prefix_executes_index() { + async fn root_path_and_site_prefix_executes_index() { let actual = do_route("/prefix/", Default, Some("/prefix/")).await; let expected = execute("/index.sql"); @@ -140,7 +169,7 @@ mod tests { } #[tokio::test] - async fn sql_extension() { + async fn extension() { let actual = do_route("/index.sql", Default, None).await; let expected = execute("/index.sql"); @@ -148,7 +177,7 @@ mod tests { } #[tokio::test] - async fn sql_extension_and_site_prefix() { + async fn extension_and_site_prefix() { let actual = do_route("/prefix/index.sql", Default, Some("/prefix/")).await; let expected = execute("/index.sql"); @@ -294,6 +323,22 @@ mod tests { assert_eq!(expected, actual); } + #[tokio::test] + async fn asset_trims_query() { + let actual = do_route("/favicon.ico?version=10", Default, None).await; + let expected = serve("/favicon.ico"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn asset_trims_fragment() { + let actual = do_route("/favicon.ico#asset1", Default, None).await; + let expected = serve("/favicon.ico"); + + assert_eq!(expected, actual); + } + #[tokio::test] async fn serves_corresponding_asset_given_site_prefix() { let actual = do_route("/prefix/favicon.ico", Default, Some("/prefix/")).await; @@ -323,6 +368,22 @@ mod tests { assert_eq!(expected, actual); } + #[tokio::test] + async fn no_extension_no_corresponding_file_redirects_with_trailing_slash_and_query() { + let actual = do_route("/folder?misc=1&foo=bar", Default, None).await; + let expected = redirect("/folder/?misc=1&foo=bar"); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn no_extension_no_corresponding_file_redirects_with_trailing_slash_and_fragment() { + let actual = do_route("/folder#anchor1", Default, None).await; + let expected = redirect("/folder/#anchor1"); + + assert_eq!(expected, actual); + } + #[tokio::test] async fn no_extension_site_prefix_and_no_corresponding_file_redirects_with_trailing_slash() { @@ -409,19 +470,19 @@ mod tests { } struct Config { - prefix: String, + prefix: Uri, } impl Config { fn new(prefix: &str) -> Self { Self { - prefix: prefix.to_string(), + prefix: prefix.parse().unwrap(), } } } impl RoutingConfig for Config { - fn prefix(&self) -> &str { - self.prefix.as_str() + fn prefix(&self) -> &Uri { + &self.prefix } } From 85b4dabf759e1771434118ef520581dc50984e28 Mon Sep 17 00:00:00 2001 From: Gus Power Date: Sun, 26 Jan 2025 16:26:47 +0000 Subject: [PATCH 6/7] remove format!() --- src/webserver/routing.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/webserver/routing.rs b/src/webserver/routing.rs index d2106f7b..63018696 100644 --- a/src/webserver/routing.rs +++ b/src/webserver/routing.rs @@ -44,12 +44,14 @@ where C: RoutingConfig, { if uri.path().starts_with(config.prefix().path()) { - Ok(PathBuf::from(format!( - "/{}", + let mut result = String::from("/"); + result.push_str( uri.path() .strip_prefix(config.prefix().path()) - .expect("Unable to remove expected prefix from path") - ))) + .expect("Unable to remove expected prefix from path"), + ); + + Ok(PathBuf::from(result)) } else { Err(Redirect(config.prefix().clone())) } From e58b8656ac18afa203b7a9b2d69ba18350845e4c Mon Sep 17 00:00:00 2001 From: Gus Power Date: Tue, 28 Jan 2025 10:28:29 +0000 Subject: [PATCH 7/7] Implement RoutingConfig trait on app_config, implement FileStore trait on file_cache and filesystem, rename ExecutionStore -> FileStore as now checks both assets and sql files. *Very very rough* first implementation pass in http::main_handler. All tests pass. --- src/app_config.rs | 7 ++ src/file_cache.rs | 8 ++ src/filesystem.rs | 7 ++ src/webserver/http.rs | 87 ++++++++----- src/webserver/mod.rs | 2 +- src/webserver/routing.rs | 263 +++++++++++++++++++++------------------ 6 files changed, 220 insertions(+), 154 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index 21c59441..33702f5a 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -6,6 +6,7 @@ use serde::de::Error; use serde::{Deserialize, Deserializer, Serialize}; use std::net::{SocketAddr, ToSocketAddrs}; use std::path::{Path, PathBuf}; +use crate::webserver::routing::RoutingConfig; #[derive(Parser)] #[clap(author, version, about, long_about = None)] @@ -266,6 +267,12 @@ impl AppConfig { } } +impl RoutingConfig for AppConfig { + fn prefix(&self) -> &str { + &self.site_prefix + } +} + /// The directory where the `sqlpage.json` file is located. /// Determined by the `SQLPAGE_CONFIGURATION_DIRECTORY` environment variable fn configuration_directory() -> PathBuf { diff --git a/src/file_cache.rs b/src/file_cache.rs index fb45f498..68941cbf 100644 --- a/src/file_cache.rs +++ b/src/file_cache.rs @@ -13,6 +13,7 @@ use std::sync::atomic::{ use std::sync::Arc; use std::time::SystemTime; use tokio::sync::RwLock; +use crate::webserver::routing::FileStore; /// The maximum time in milliseconds that a file can be cached before its freshness is checked /// (in production mode) @@ -74,6 +75,13 @@ pub struct FileCache { static_files: HashMap>, } +impl FileStore for FileCache { + async fn contains(&self, path: &PathBuf) -> bool { + self.cache.read().await.contains_key(path) + || self.static_files.contains_key(path) + } +} + impl Default for FileCache { fn default() -> Self { Self::new() diff --git a/src/filesystem.rs b/src/filesystem.rs index 9ff8cb46..7902e896 100644 --- a/src/filesystem.rs +++ b/src/filesystem.rs @@ -8,6 +8,7 @@ use sqlx::postgres::types::PgTimeTz; use sqlx::{Postgres, Statement, Type}; use std::io::ErrorKind; use std::path::{Component, Path, PathBuf}; +use crate::webserver::routing::FileStore; pub(crate) struct FileSystem { local_root: PathBuf, @@ -133,6 +134,12 @@ impl FileSystem { } } +impl FileStore for FileSystem { + async fn contains(&self, path: &PathBuf) -> bool { + tokio::fs::try_exists(self.local_root.join(path)).await.unwrap_or(false) + } +} + async fn file_modified_since_local(path: &Path, since: DateTime) -> tokio::io::Result { tokio::fs::metadata(path) .await diff --git a/src/webserver/http.rs b/src/webserver/http.rs index a49302a6..c1d81c70 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -34,6 +34,8 @@ use std::pin::Pin; use std::sync::Arc; use std::time::SystemTime; use tokio::sync::mpsc; +use crate::webserver::routing::{calculate_route, AppFileStore}; +use crate::webserver::routing::RoutingAction::{NotFound, Execute, CustomNotFound, Redirect, Serve}; #[derive(Clone)] pub struct RequestContext { @@ -423,41 +425,58 @@ async fn serve_fallback( pub async fn main_handler( mut service_request: ServiceRequest, ) -> actix_web::Result { - if let Some(redirect) = redirect_missing_prefix(&service_request) { - return Ok(service_request.into_response(redirect)); - } - - let path = req_path(&service_request); - let sql_file_path = path_to_sql_file(&path); - let maybe_response = if let Some(sql_path) = sql_file_path { - if let Some(redirect) = redirect_missing_trailing_slash(service_request.uri()) { - Ok(redirect) - } else { - log::debug!("Processing SQL request: {:?}", sql_path); - process_sql_request(&mut service_request, sql_path).await - } - } else { - log::debug!("Serving file: {:?}", path); - let path = req_path(&service_request); - let if_modified_since = IfModifiedSince::parse(&service_request).ok(); - let app_state: &web::Data = service_request.app_data().expect("app_state"); - serve_file(&path, app_state, if_modified_since).await - }; - - // On 404/NOT_FOUND error, fall back to `404.sql` handler if it exists - let response = match maybe_response { - // File could not be served due to a 404 error. Try to find a user provide 404 handler in - // the form of a `404.sql` in the current directory. If there is none, look in the parent - // directeory, and its parent directory, ... - Err(e) if e.as_response_error().status_code() == StatusCode::NOT_FOUND => { - serve_fallback(&mut service_request, e).await? + let app_state: &web::Data = service_request.app_data().expect("app_state"); + let store = AppFileStore::new(&app_state.sql_file_cache, &app_state.file_system); + let path_and_query = service_request.uri().path_and_query().expect("expected valid path with query from request"); + return match calculate_route(path_and_query, &store, &app_state.config).await { + NotFound => { serve_fallback(&mut service_request, ErrorWithStatus { status: StatusCode::NOT_FOUND }.into()).await } + Execute(path) => { process_sql_request(&mut service_request, path).await } + CustomNotFound(path) => { process_sql_request(&mut service_request, path).await } + Redirect(uri) => { Ok(HttpResponse::MovedPermanently() + .insert_header((header::LOCATION, uri.to_string())) + .finish()) } + Serve(path) => { + let if_modified_since = IfModifiedSince::parse(&service_request).ok(); + let app_state: &web::Data = service_request.app_data().expect("app_state"); + serve_file(path.as_os_str().to_str().unwrap(), app_state, if_modified_since).await } - - // Either a valid response, or an unrelated error that shall be bubbled up. - e => e?, - }; - - Ok(service_request.into_response(response)) + }.map(|response| service_request.into_response(response)); + + // if let Some(redirect) = redirect_missing_prefix(&service_request) { + // return Ok(service_request.into_response(redirect)); + // } + // + // let path = req_path(&service_request); + // let sql_file_path = path_to_sql_file(&path); + // let maybe_response = if let Some(sql_path) = sql_file_path { + // if let Some(redirect) = redirect_missing_trailing_slash(service_request.uri()) { + // Ok(redirect) + // } else { + // log::debug!("Processing SQL request: {:?}", sql_path); + // process_sql_request(&mut service_request, sql_path).await + // } + // } else { + // log::debug!("Serving file: {:?}", path); + // let path = req_path(&service_request); + // let if_modified_since = IfModifiedSince::parse(&service_request).ok(); + // let app_state: &web::Data = service_request.app_data().expect("app_state"); + // serve_file(&path, app_state, if_modified_since).await + // }; + // + // // On 404/NOT_FOUND error, fall back to `404.sql` handler if it exists + // let response = match maybe_response { + // // File could not be served due to a 404 error. Try to find a user provide 404 handler in + // // the form of a `404.sql` in the current directory. If there is none, look in the parent + // // directeory, and its parent directory, ... + // Err(e) if e.as_response_error().status_code() == StatusCode::NOT_FOUND => { + // serve_fallback(&mut service_request, e).await? + // } + // + // // Either a valid response, or an unrelated error that shall be bubbled up. + // e => e?, + // }; + // + // Ok(service_request.into_response(response)) } fn redirect_missing_prefix(service_request: &ServiceRequest) -> Option { diff --git a/src/webserver/mod.rs b/src/webserver/mod.rs index 00e19e80..1393d9e6 100644 --- a/src/webserver/mod.rs +++ b/src/webserver/mod.rs @@ -43,5 +43,5 @@ pub use error_with_status::ErrorWithStatus; pub use database::make_placeholder; pub use database::migrations::apply; pub mod response_writer; -mod routing; +pub mod routing; mod static_content; diff --git a/src/webserver/routing.rs b/src/webserver/routing.rs index 63018696..e0b68068 100644 --- a/src/webserver/routing.rs +++ b/src/webserver/routing.rs @@ -1,113 +1,133 @@ +use crate::file_cache::FileCache; +use crate::filesystem::FileSystem; +use crate::webserver::database::ParsedSqlFile; +use awc::http::uri::PathAndQuery; use awc::http::Uri; -use std::ffi::OsStr; +use log::debug; use std::path::PathBuf; -use RoutingAction::{Error, Execute, NotFound, Redirect, Serve}; +use RoutingAction::{CustomNotFound, Execute, NotFound, Redirect, Serve}; const INDEX: &'static str = "index.sql"; const NOT_FOUND: &'static str = "404.sql"; -const EXECUTION_EXTENSION: &'static str = "sql"; +const SQL_EXTENSION: &'static str = "sql"; const FORWARD_SLASH: &'static str = "/"; #[derive(Debug, PartialEq)] pub enum RoutingAction { - Error(String), + CustomNotFound(PathBuf), Execute(PathBuf), - NotFound(PathBuf), + NotFound, Redirect(Uri), Serve(PathBuf), } -pub trait ExecutionStore { +#[expect(async_fn_in_trait)] +pub trait FileStore { async fn contains(&self, path: &PathBuf) -> bool; } pub trait RoutingConfig { - fn prefix(&self) -> &Uri; + fn prefix(&self) -> &str; } -pub async fn calculate_route(uri: Uri, store: &T, config: &C) -> RoutingAction +pub(crate) struct AppFileStore<'a> { + cache: &'a FileCache, + filesystem: &'a FileSystem, +} + +impl<'a> AppFileStore<'a> { + pub fn new( + cache: &'a FileCache, + filesystem: &'a FileSystem, + ) -> AppFileStore<'a> { + Self { cache, filesystem } + } +} + +impl FileStore for AppFileStore<'_> { + async fn contains(&self, path: &PathBuf) -> bool { + self.cache.contains(path).await || self.filesystem.contains(path).await + } +} + +pub async fn calculate_route( + path_and_query: &PathAndQuery, + store: &T, + config: &C, +) -> RoutingAction where - T: ExecutionStore, + T: FileStore, C: RoutingConfig, { - match check_uri(&uri, config) { + let result = match check_path(path_and_query, config) { Ok(path) => match path.clone().extension() { - None => calculate_route_without_extension(&uri, path, store).await, - Some(extension) => calculate_route_with_extension(path, extension, store).await, + None => calculate_route_without_extension(path_and_query, path, store).await, + Some(extension) => { + let ext = extension.to_str().expect("invalid file extension"); + find_file_or_not_found(&path, ext, store).await + } }, Err(action) => action, - } + }; + debug!("Route: [{}] -> {:?}", path_and_query, result); + result } -fn check_uri(uri: &Uri, config: &C) -> Result +fn check_path(path_and_query: &PathAndQuery, config: &C) -> Result where C: RoutingConfig, { - if uri.path().starts_with(config.prefix().path()) { - let mut result = String::from("/"); - result.push_str( - uri.path() - .strip_prefix(config.prefix().path()) - .expect("Unable to remove expected prefix from path"), - ); - - Ok(PathBuf::from(result)) - } else { - Err(Redirect(config.prefix().clone())) + match path_and_query.path().strip_prefix(config.prefix()) { + None => Err(Redirect( + config + .prefix() + .parse() + .expect("Expected prefix to be valid uri path"), + )), + Some(path) => Ok(PathBuf::from(path)), } } async fn calculate_route_without_extension( - uri: &Uri, + path_and_query: &PathAndQuery, mut path: PathBuf, store: &T, ) -> RoutingAction where - T: ExecutionStore, + T: FileStore, { - if uri.path().ends_with(FORWARD_SLASH) { + if path_and_query.path().ends_with(FORWARD_SLASH) { path.push(INDEX); - find_execution_or_not_found(&path, store).await + find_file_or_not_found(&path, SQL_EXTENSION, store).await } else { - let path_with_ext = path.with_extension(EXECUTION_EXTENSION); - match find_execution(&path_with_ext, store).await { + let path_with_ext = path.with_extension(SQL_EXTENSION); + match find_file(&path_with_ext, SQL_EXTENSION, store).await { Some(action) => action, - None => Redirect(append_to_uri_path(&uri, FORWARD_SLASH)), + None => Redirect(append_to_path(&path_and_query, FORWARD_SLASH)), } } } -async fn calculate_route_with_extension( - path: PathBuf, - extension: &OsStr, - store: &T, -) -> RoutingAction -where - T: ExecutionStore, -{ - if extension == EXECUTION_EXTENSION { - find_execution_or_not_found(&path, store).await - } else { - Serve(path) - } -} - -async fn find_execution_or_not_found(path: &PathBuf, store: &T) -> RoutingAction +async fn find_file_or_not_found(path: &PathBuf, extension: &str, store: &T) -> RoutingAction where - T: ExecutionStore, + T: FileStore, { - match find_execution(path, store).await { + match find_file(path, extension, store).await { None => find_not_found(path, store).await, Some(execute) => execute, } } -async fn find_execution(path: &PathBuf, store: &T) -> Option +async fn find_file(path: &PathBuf, extension: &str, store: &T) -> Option where - T: ExecutionStore, + T: FileStore, { if store.contains(path).await { - Some(Execute(path.clone())) + if extension == SQL_EXTENSION { + Some(Execute(path.clone())) + } else { + Some(Serve(path.clone())) + } } else { None } @@ -115,35 +135,31 @@ where async fn find_not_found(path: &PathBuf, store: &T) -> RoutingAction where - T: ExecutionStore, + T: FileStore, { let mut parent = path.parent(); while let Some(p) = parent { let target = p.join(NOT_FOUND); if store.contains(&target).await { - return NotFound(target); - } else { - parent = p.parent() + return CustomNotFound(target); } + parent = p.parent() } - Error(path_to_string(path)) + NotFound } -fn append_to_uri_path(uri: &Uri, append: &str) -> Uri { - let mut full_uri = uri.to_string(); - full_uri.insert_str(uri.path().len(), append); +fn append_to_path(path_and_query: &PathAndQuery, append: &str) -> Uri { + let mut full_uri = path_and_query.to_string(); + full_uri.insert_str(path_and_query.path().len(), append); full_uri.parse().expect("Could not append uri path") } -fn path_to_string(path: &PathBuf) -> String { - path.to_string_lossy().to_string() -} - #[cfg(test)] mod tests { - use super::RoutingAction::{Error, Execute, NotFound, Redirect, Serve}; - use super::{calculate_route, path_to_string, ExecutionStore, RoutingAction, RoutingConfig}; + use super::RoutingAction::{CustomNotFound, Execute, NotFound, Redirect, Serve}; + use super::{calculate_route, FileStore, RoutingAction, RoutingConfig}; + use awc::http::uri::PathAndQuery; use awc::http::Uri; use std::default::Default as StdDefault; use std::path::PathBuf; @@ -157,7 +173,7 @@ mod tests { #[tokio::test] async fn root_path_executes_index() { let actual = do_route("/", Default, None).await; - let expected = execute("/index.sql"); + let expected = execute("index.sql"); assert_eq!(expected, actual); } @@ -165,7 +181,7 @@ mod tests { #[tokio::test] async fn root_path_and_site_prefix_executes_index() { let actual = do_route("/prefix/", Default, Some("/prefix/")).await; - let expected = execute("/index.sql"); + let expected = execute("index.sql"); assert_eq!(expected, actual); } @@ -173,7 +189,7 @@ mod tests { #[tokio::test] async fn extension() { let actual = do_route("/index.sql", Default, None).await; - let expected = execute("/index.sql"); + let expected = execute("index.sql"); assert_eq!(expected, actual); } @@ -181,31 +197,31 @@ mod tests { #[tokio::test] async fn extension_and_site_prefix() { let actual = do_route("/prefix/index.sql", Default, Some("/prefix/")).await; - let expected = execute("/index.sql"); + let expected = execute("index.sql"); assert_eq!(expected, actual); } #[tokio::test] async fn no_extension() { - let actual = do_route("/path", File("/path.sql"), None).await; - let expected = execute("/path.sql"); + let actual = do_route("/path", File("path.sql"), None).await; + let expected = execute("path.sql"); assert_eq!(expected, actual); } #[tokio::test] async fn no_extension_and_site_prefix() { - let actual = do_route("/prefix/path", File("/path.sql"), Some("/prefix/")).await; - let expected = execute("/path.sql"); + let actual = do_route("/prefix/path", File("path.sql"), Some("/prefix/")).await; + let expected = execute("path.sql"); assert_eq!(expected, actual); } #[tokio::test] async fn trailing_slash_executes_index_in_directory() { - let actual = do_route("/folder/", File("/folder/index.sql"), None).await; - let expected = execute("/folder/index.sql"); + let actual = do_route("/folder/", File("folder/index.sql"), None).await; + let expected = execute("folder/index.sql"); assert_eq!(expected, actual); } @@ -214,24 +230,24 @@ mod tests { async fn trailing_slash_and_site_prefix_executes_index_in_directory() { let actual = do_route( "/prefix/folder/", - File("/folder/index.sql"), + File("folder/index.sql"), Some("/prefix/"), ) .await; - let expected = execute("/folder/index.sql"); + let expected = execute("folder/index.sql"); assert_eq!(expected, actual); } } - mod not_found { + mod custom_not_found { use super::StoreConfig::{Default, File}; - use super::{do_route, not_found}; + use super::{custom_not_found, do_route}; #[tokio::test] async fn sql_extension() { let actual = do_route("/unknown.sql", Default, None).await; - let expected = not_found("/404.sql"); + let expected = custom_not_found("404.sql"); assert_eq!(expected, actual); } @@ -239,15 +255,15 @@ mod tests { #[tokio::test] async fn sql_extension_and_site_prefix() { let actual = do_route("/prefix/unknown.sql", Default, Some("/prefix/")).await; - let expected = not_found("/404.sql"); + let expected = custom_not_found("404.sql"); assert_eq!(expected, actual); } #[tokio::test] async fn sql_extension_executes_deeper_not_found_file_if_exists() { - let actual = do_route("/unknown/unknown.sql", File("/unknown/404.sql"), None).await; - let expected = not_found("/unknown/404.sql"); + let actual = do_route("/unknown/unknown.sql", File("unknown/404.sql"), None).await; + let expected = custom_not_found("unknown/404.sql"); assert_eq!(expected, actual); } @@ -256,11 +272,11 @@ mod tests { async fn sql_extension_and_site_prefix_executes_deeper_not_found_file_if_exists() { let actual = do_route( "/prefix/unknown/unknown.sql", - File("/unknown/404.sql"), + File("unknown/404.sql"), Some("/prefix/"), ) .await; - let expected = not_found("/unknown/404.sql"); + let expected = custom_not_found("unknown/404.sql"); assert_eq!(expected, actual); } @@ -269,11 +285,11 @@ mod tests { async fn sql_extension_executes_deepest_not_found_file_that_exists() { let actual = do_route( "/unknown/unknown/unknown.sql", - File("/unknown/404.sql"), + File("unknown/404.sql"), None, ) .await; - let expected = not_found("/unknown/404.sql"); + let expected = custom_not_found("unknown/404.sql"); assert_eq!(expected, actual); } @@ -282,69 +298,78 @@ mod tests { async fn sql_extension_and_site_prefix_executes_deepest_not_found_file_that_exists() { let actual = do_route( "/prefix/unknown/unknown/unknown.sql", - File("/unknown/404.sql"), + File("unknown/404.sql"), Some("/prefix/"), ) .await; - let expected = not_found("/unknown/404.sql"); + let expected = custom_not_found("unknown/404.sql"); assert_eq!(expected, actual); } } - mod error { + mod not_found { use super::StoreConfig::Empty; - use super::{do_route, error}; + use super::{default_not_found, do_route}; #[tokio::test] - async fn sql_extension_errors_when_no_not_found_file_available() { + async fn default_404_when_no_not_found_file_available() { let actual = do_route("/unknown.sql", Empty, None).await; - let expected = error("/unknown.sql"); + let expected = default_not_found(); assert_eq!(expected, actual); } #[tokio::test] - async fn sql_extension_and_site_prefix_errors_when_no_not_found_file_available() { + async fn default_404_when_no_not_found_file_available_and_site_prefix() { let actual = do_route("/prefix/unknown.sql", Empty, Some("/prefix/")).await; - let expected = error("/unknown.sql"); + let expected = default_not_found(); + + assert_eq!(expected, actual); + } + + #[tokio::test] + async fn asset_not_found() { + let actual = do_route("/favicon.ico", Empty, None).await; + let expected = default_not_found(); assert_eq!(expected, actual); } } mod asset { - use super::StoreConfig::Default; + use super::StoreConfig::File; use super::{do_route, serve}; #[tokio::test] async fn serves_corresponding_asset() { - let actual = do_route("/favicon.ico", Default, None).await; - let expected = serve("/favicon.ico"); + let actual = do_route("/favicon.ico", File("favicon.ico"), None).await; + let expected = serve("favicon.ico"); assert_eq!(expected, actual); } #[tokio::test] async fn asset_trims_query() { - let actual = do_route("/favicon.ico?version=10", Default, None).await; - let expected = serve("/favicon.ico"); + let actual = do_route("/favicon.ico?version=10", File("favicon.ico"), None).await; + let expected = serve("favicon.ico"); assert_eq!(expected, actual); } #[tokio::test] async fn asset_trims_fragment() { - let actual = do_route("/favicon.ico#asset1", Default, None).await; - let expected = serve("/favicon.ico"); + let actual = do_route("/favicon.ico#asset1", File("favicon.ico"), None).await; + let expected = serve("favicon.ico"); assert_eq!(expected, actual); } #[tokio::test] async fn serves_corresponding_asset_given_site_prefix() { - let actual = do_route("/prefix/favicon.ico", Default, Some("/prefix/")).await; - let expected = serve("/favicon.ico"); + let actual = + do_route("/prefix/favicon.ico", File("favicon.ico"), Some("/prefix/")).await; + let expected = serve("favicon.ico"); assert_eq!(expected, actual); } @@ -396,7 +421,7 @@ mod tests { } } - async fn do_route(uri: &str, config: StoreConfig, prefix: Option<&str>) -> RoutingAction { + async fn do_route(path: &str, config: StoreConfig, prefix: Option<&str>) -> RoutingAction { let store = match config { Default => Store::default(), Empty => Store::empty(), @@ -406,19 +431,19 @@ mod tests { None => Config::default(), Some(value) => Config::new(value), }; - calculate_route(Uri::from_str(uri).unwrap(), &store, &config).await + calculate_route(&PathAndQuery::from_str(path).unwrap(), &store, &config).await } - fn error(uri: &str) -> RoutingAction { - Error(uri.to_string()) + fn default_not_found() -> RoutingAction { + NotFound } fn execute(path: &str) -> RoutingAction { Execute(PathBuf::from(path)) } - fn not_found(path: &str) -> RoutingAction { - NotFound(PathBuf::from(path)) + fn custom_not_found(path: &str) -> RoutingAction { + CustomNotFound(PathBuf::from(path)) } fn redirect(uri: &str) -> RoutingAction { @@ -440,8 +465,8 @@ mod tests { } impl Store { - const INDEX: &'static str = "/index.sql"; - const NOT_FOUND: &'static str = "/404.sql"; + const INDEX: &'static str = "index.sql"; + const NOT_FOUND: &'static str = "404.sql"; fn new(path: &str) -> Self { let mut contents = Self::default_contents(); contents.push(path.to_string()); @@ -465,25 +490,25 @@ mod tests { } } - impl ExecutionStore for Store { + impl FileStore for Store { async fn contains(&self, path: &PathBuf) -> bool { - self.contents.contains(&path_to_string(path)) + self.contents.contains(&path.to_string_lossy().to_string()) } } struct Config { - prefix: Uri, + prefix: String, } impl Config { fn new(prefix: &str) -> Self { Self { - prefix: prefix.parse().unwrap(), + prefix: prefix.to_string(), } } } impl RoutingConfig for Config { - fn prefix(&self) -> &Uri { + fn prefix(&self) -> &str { &self.prefix } }