diff --git a/Cargo.lock b/Cargo.lock index 866b61e..86ae62a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,6 +456,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -557,6 +568,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "derive_builder" version = "0.20.1" @@ -788,6 +809,7 @@ dependencies = [ "thiserror", "tokio", "tower 0.5.1", + "tower-sessions", ] [[package]] @@ -1296,6 +1318,7 @@ checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", + "serde", ] [[package]] @@ -1396,6 +1419,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -1550,6 +1579,12 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -2202,6 +2237,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -2315,6 +2381,23 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-cookies" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d" +dependencies = [ + "async-trait", + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2327,6 +2410,57 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower-sessions" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65856c81ee244e0f8a55ab0f7b769b72fbde387c235f0a73cd97c579818d05eb" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb6abbfcaf6436ec5a772cd9f965401da12db793e404ae6134eac066fa5a04f3" +dependencies = [ + "async-trait", + "axum-core", + "base64", + "futures", + "http", + "parking_lot", + "rand", + "serde", + "serde_json", + "thiserror", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fad75660c8afbe74f4e7cbbe8e9090171a056b57370ea4d7d5e9eb3e4af3092" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + [[package]] name = "tracing" version = "0.1.40" diff --git a/Cargo.toml b/Cargo.toml index 84da259..d82e42f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,4 +62,5 @@ syn = { version = "2.0.74", features = ["full", "extra-traits"] } thiserror = "1.0.61" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } tower = "0.5.1" +tower-sessions = "0.13.0" trybuild = { version = "1.0.99", features = ["diff"] } diff --git a/flareon/Cargo.toml b/flareon/Cargo.toml index 3ac0127..0044cad 100644 --- a/flareon/Cargo.toml +++ b/flareon/Cargo.toml @@ -28,6 +28,7 @@ sqlx.workspace = true thiserror.workspace = true tokio.workspace = true tower.workspace = true +tower-sessions.workspace = true [dev-dependencies] async-stream.workspace = true diff --git a/flareon/src/lib.rs b/flareon/src/lib.rs index 714dfd6..ddb5f89 100644 --- a/flareon/src/lib.rs +++ b/flareon/src/lib.rs @@ -23,9 +23,10 @@ pub mod __private; pub mod request; pub mod response; pub mod router; +pub mod test; use std::fmt::{Debug, Formatter}; -use std::future::Future; +use std::future::{poll_fn, Future}; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; @@ -36,11 +37,13 @@ use bytes::Bytes; use derive_builder::Builder; use derive_more::{Deref, From}; pub use error::Error; +use flareon::router::RouterService; use futures_core::Stream; use http_body::{Frame, SizeHint}; use log::info; use request::Request; use router::{Route, Router}; +use tower::Service; use crate::response::Response; @@ -96,6 +99,7 @@ impl FlareonApp { impl FlareonAppBuilder { #[allow(unused_mut)] pub fn urls>>(&mut self, urls: T) -> &mut Self { + // TODO throw error if urls have already been set self.router = Some(Router::with_urls(urls.into())); self } @@ -189,6 +193,20 @@ impl Body { Self::new(BodyInner::Streaming(Box::pin(stream))) } + pub async fn into_bytes(self) -> std::result::Result { + self.into_bytes_limited(usize::MAX).await + } + + pub async fn into_bytes_limited(self, limit: usize) -> std::result::Result { + use http_body_util::BodyExt; + + http_body_util::Limited::new(self, limit) + .collect() + .await + .map(http_body_util::Collected::to_bytes) + .map_err(|source| Error::ReadRequestBody { source }) + } + #[must_use] fn axum(inner: axum::body::Body) -> Self { Self::new(BodyInner::Axum(inner)) @@ -254,9 +272,11 @@ impl http_body::Body for Body { } #[derive(Clone, Debug)] -pub struct FlareonProject { +// TODO add Middleware type? +pub struct FlareonProject { apps: Vec, - router: Router, + router: Arc, + handler: S, } #[derive(Debug)] @@ -282,12 +302,65 @@ impl FlareonProjectBuilder { new } + #[must_use] + pub fn middleware>( + self, + middleware: M, + ) -> FlareonProjectBuilderWithMiddleware { + self.to_builder_with_middleware().middleware(middleware) + } + /// Builds the Flareon project instance. #[must_use] - pub fn build(&self) -> FlareonProject { + pub fn build(&self) -> FlareonProject { + self.to_builder_with_middleware().build() + } + + #[must_use] + fn to_builder_with_middleware(&self) -> FlareonProjectBuilderWithMiddleware { + let router = Arc::new(Router::with_urls(self.urls.clone())); + let service = RouterService::new(router.clone()); + + FlareonProjectBuilderWithMiddleware::new(self.apps.clone(), router, service) + } +} + +#[derive(Debug)] +pub struct FlareonProjectBuilderWithMiddleware { + apps: Vec, + router: Arc, + handler: S, +} + +impl> FlareonProjectBuilderWithMiddleware { + #[must_use] + fn new(apps: Vec, router: Arc, handler: S) -> Self { + Self { + apps, + router, + handler, + } + } + + #[must_use] + pub fn middleware>( + self, + middleware: M, + ) -> FlareonProjectBuilderWithMiddleware { + FlareonProjectBuilderWithMiddleware { + apps: self.apps, + router: self.router, + handler: middleware.layer(self.handler), + } + } + + /// Builds the Flareon project instance. + #[must_use] + pub fn build(self) -> FlareonProject { FlareonProject { - apps: self.apps.clone(), - router: Router::with_urls(self.urls.clone()), + apps: self.apps, + router: self.router, + handler: self.handler, } } } @@ -298,12 +371,17 @@ impl Default for FlareonProjectBuilder { } } -impl FlareonProject { +impl FlareonProject<()> { #[must_use] pub fn builder() -> FlareonProjectBuilder { FlareonProjectBuilder::default() } +} +impl FlareonProject +where + S: Service + Send + Sync + Clone + 'static, +{ #[must_use] pub fn router(&self) -> &Router { &self.router @@ -318,7 +396,11 @@ impl FlareonProject { /// # Errors /// /// This function returns an error if the server fails to start. -pub async fn run(project: FlareonProject, address_str: &str) -> Result<()> { +pub async fn run(project: FlareonProject, address_str: &str) -> Result<()> +where + S: Service + Send + Sync + Clone + 'static, + S::Future: Send, +{ let listener = tokio::net::TcpListener::bind(address_str) .await .map_err(|e| Error::StartServer { source: e })?; @@ -339,17 +421,28 @@ pub async fn run(project: FlareonProject, address_str: &str) -> Result<()> { /// # Errors /// /// This function returns an error if the server fails to start. -pub async fn run_at(mut project: FlareonProject, listener: tokio::net::TcpListener) -> Result<()> { +pub async fn run_at( + mut project: FlareonProject, + listener: tokio::net::TcpListener, +) -> Result<()> +where + S: Service + Send + Sync + Clone + 'static, + S::Future: Send, +{ for app in &mut project.apps { info!("Initializing app: {:?}", app); } - let project = Arc::new(project); + let FlareonProject { + apps: _apps, + router, + mut handler, + } = project; let handler = |axum_request: axum::extract::Request| async move { - let request = request_axum_to_flareon(&project, axum_request); + let request = request_axum_to_flareon(axum_request, router.clone()); - pass_to_axum(&project, request) + pass_to_axum(request, &mut handler) .await .unwrap_or_else(handle_response_error) }; @@ -367,22 +460,25 @@ pub async fn run_at(mut project: FlareonProject, listener: tokio::net::TcpListen Ok(()) } -fn request_axum_to_flareon( - project: &Arc, - axum_request: axum::extract::Request, -) -> Request { +fn request_axum_to_flareon(axum_request: axum::extract::Request, router: Arc) -> Request { let (parts, body) = axum_request.into_parts(); let mut request = Request::from_parts(parts, Body::axum(body)); - request.extensions_mut().insert(project.clone()); + prepare_request(&mut request, router); request } -async fn pass_to_axum( - project: &Arc, - request: Request, -) -> Result { - let response = project.router.handle(request).await?; +pub(crate) fn prepare_request(request: &mut Request, router: Arc) { + request.extensions_mut().insert(router); +} + +async fn pass_to_axum(request: Request, handler: &mut S) -> Result +where + S: Service + Send + Sync + Clone + 'static, + S::Future: Send, +{ + poll_fn(|cx| handler.poll_ready(cx)).await?; + let response = handler.call(request).await?; let (parts, body) = response.into_parts(); Ok(http::Response::from_parts( @@ -414,7 +510,7 @@ impl Html { #[allow(clippy::needless_pass_by_value)] fn handle_response_error(_error: Error) -> axum::response::Response { - unimplemented!("500 error handler is not implemented yet") + todo!("500 error handler is not implemented yet") } #[cfg(test)] diff --git a/flareon/src/request.rs b/flareon/src/request.rs index 76de447..931c624 100644 --- a/flareon/src/request.rs +++ b/flareon/src/request.rs @@ -3,18 +3,19 @@ use std::sync::Arc; use async_trait::async_trait; use bytes::Bytes; -use http_body_util::BodyExt; use indexmap::IndexMap; +use tower_sessions::Session; use crate::headers::FORM_CONTENT_TYPE; -use crate::{Body, Error, FlareonProject}; +use crate::router::Router; +use crate::{Body, Error}; pub type Request = http::Request; #[async_trait] pub trait RequestExt { #[must_use] - fn project(&self) -> &FlareonProject; + fn router(&self) -> &Router; #[must_use] fn path_params(&self) -> &PathParams; @@ -22,6 +23,12 @@ pub trait RequestExt { #[must_use] fn path_params_mut(&mut self) -> &mut PathParams; + #[must_use] + fn session(&self) -> &Session; + + #[must_use] + fn session_mut(&mut self) -> &mut Session; + /// Get the request body as bytes. If the request method is GET or HEAD, the /// query string is returned. Otherwise, if the request content type is /// `application/x-www-form-urlencoded`, then the body is read and returned. @@ -46,10 +53,10 @@ pub trait RequestExt { #[async_trait] impl RequestExt for Request { - fn project(&self) -> &FlareonProject { + fn router(&self) -> &Router { self.extensions() - .get::>() - .expect("FlareonProject extension missing") + .get::>() + .expect("Router extension missing") } fn path_params(&self) -> &PathParams { @@ -59,9 +66,19 @@ impl RequestExt for Request { } fn path_params_mut(&mut self) -> &mut PathParams { + self.extensions_mut().get_or_insert_default::() + } + + fn session(&self) -> &Session { + self.extensions() + .get::() + .expect("Session extension missing") + } + + fn session_mut(&mut self) -> &mut Session { self.extensions_mut() - .get_mut::() - .expect("PathParams extension missing") + .get_mut::() + .expect("Session extension missing") } async fn form_data(&mut self) -> Result { @@ -75,7 +92,7 @@ impl RequestExt for Request { self.expect_content_type(FORM_CONTENT_TYPE)?; let body = std::mem::take(self.body_mut()); - let bytes = body_to_bytes(body, usize::MAX).await?; + let bytes = body.into_bytes().await?; Ok(bytes) } @@ -100,7 +117,7 @@ impl RequestExt for Request { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct PathParams { params: IndexMap, } @@ -132,11 +149,3 @@ impl PathParams { pub(crate) fn query_pairs(bytes: &Bytes) -> impl Iterator, Cow)> { form_urlencoded::parse(bytes.as_ref()) } - -async fn body_to_bytes(body: Body, limit: usize) -> Result { - http_body_util::Limited::new(body, limit) - .collect() - .await - .map(http_body_util::Collected::to_bytes) - .map_err(|source| Error::ReadRequestBody { source }) -} diff --git a/flareon/src/router.rs b/flareon/src/router.rs index 3676bbe..ade7295 100644 --- a/flareon/src/router.rs +++ b/flareon/src/router.rs @@ -134,13 +134,20 @@ impl Router { } #[derive(Debug, Clone)] -struct RouterService { +pub struct RouterService { router: Arc, } +impl RouterService { + #[must_use] + pub fn new(router: Arc) -> Self { + Self { router } + } +} + impl tower::Service for RouterService { type Error = Error; - type Future = Pin>>>; + type Future = Pin> + Send>>; type Response = Response; fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { @@ -221,7 +228,6 @@ macro_rules! reverse_str { ($request:expr, $view_name:literal $(, $($key:expr => $value:expr),*)?) => {{ use $crate::request::RequestExt; $request - .project() .router() .reverse($view_name, &$crate::reverse_param_map!($( $($key => $value),* )?))? }}; diff --git a/flareon/src/test.rs b/flareon/src/test.rs new file mode 100644 index 0000000..f08dc69 --- /dev/null +++ b/flareon/src/test.rs @@ -0,0 +1,45 @@ +//! Test utilities for Flareon projects. + +use std::future::poll_fn; + +use flareon::{prepare_request, FlareonProject}; +use tower::Service; + +use crate::request::Request; +use crate::response::Response; +use crate::{Body, Error, Result}; + +/// A test client for making requests to a Flareon project. +/// +/// Useful for End-to-End testing Flareon projects. +#[derive(Debug)] +pub struct Client { + project: FlareonProject, +} + +impl Client +where + S: Service + Send + Sync + Clone + 'static, + S::Future: Send, +{ + #[must_use] + pub fn new(project: FlareonProject) -> Self { + Self { project } + } + + pub async fn get(&mut self, path: &str) -> Result { + self.request( + http::Request::get(path) + .body(Body::empty()) + .expect("Test request should be valid"), + ) + .await + } + + pub async fn request(&mut self, mut request: Request) -> Result { + prepare_request(&mut request, self.project.router.clone()); + + poll_fn(|cx| self.project.handler.poll_ready(cx)).await?; + self.project.handler.call(request).await + } +} diff --git a/flareon/tests/router.rs b/flareon/tests/router.rs new file mode 100644 index 0000000..1b6791c --- /dev/null +++ b/flareon/tests/router.rs @@ -0,0 +1,58 @@ +use bytes::Bytes; +use flareon::request::{Request, RequestExt}; +use flareon::response::{Response, ResponseExt}; +use flareon::router::{Route, RouterService}; +use flareon::test::Client; +use flareon::{Body, Error, FlareonApp, FlareonProject, StatusCode}; + +async fn index(_request: Request) -> Result { + Ok(Response::new_html( + StatusCode::OK, + Body::fixed("Hello world!"), + )) +} + +async fn parameterized(request: Request) -> Result { + let name = request.path_params().get("name").unwrap().to_owned(); + + Ok(Response::new_html(StatusCode::OK, Body::fixed(name))) +} + +#[tokio::test] +async fn test_index() { + let mut client = Client::new(project()); + + let response = client.get("/").await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.into_body().into_bytes().await.unwrap(), + Bytes::from("Hello world!") + ); +} + +#[tokio::test] +async fn test_path_params() { + let mut client = Client::new(project()); + + let response = client.get("/get/John").await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.into_body().into_bytes().await.unwrap(), + Bytes::from("John") + ); +} + +#[must_use] +fn project() -> FlareonProject { + let app = FlareonApp::builder() + .urls([ + Route::with_handler_and_name("/", index, "index"), + Route::with_handler_and_name("/get/:name", parameterized, "parameterized"), + ]) + .build() + .unwrap(); + + FlareonProject::builder() + .register_app_with_views(app, "") + .build() +}