diff --git a/Cargo.toml b/Cargo.toml index ce2f330..363dea6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "volga" -version = "0.5.7" +version = "0.5.8" edition = "2021" rust-version = "1.80.0" authors = ["Roman Emreis "] @@ -24,16 +24,17 @@ memchr = "2.7.4" mime = "0.3.17" mime_guess = "2.0.5" pin-project-lite = "0.2.16" -tokio = { version = "1.44.2", features = ["full"] } -tokio-util = "0.7.14" +tokio = { version = "1.45.0", features = ["full"] } +tokio-util = "0.7.15" serde = "1.0.219" serde_json = "1.0.140" serde_urlencoded = "0.7.1" # optional -async-compression = { version = "0.4.22", features = ["tokio"], optional = true } +async-compression = { version = "0.4.23", features = ["tokio"], optional = true } base64 = { version = "0.22.1", optional = true } -chrono = { version = "0.4.40", optional = true } +chrono = { version = "0.4.41", optional = true } +cookie = { version = "0.18.1", features = ["percent-encode"], optional = true } handlebars = { version = "6.3.2", optional = true } httpdate = { version = "1.0.3", optional = true } hyper = { version = "1.6.0", features = ["server"], optional = true } @@ -63,6 +64,7 @@ full = [ "middleware", "di", "tls", + "cookie-full", "tracing", "multipart", "problem-details", @@ -84,6 +86,11 @@ tls = ["middleware", "dep:tokio-rustls", "tokio-rustls?/tls12", "tokio-rustls?/r tracing = ["middleware", "dep:tracing"] ws = ["dep:sha1", "dep:base64", "dep:tokio-tungstenite"] +cookie-full = ["cookie", "private-cookie", "signed-cookie"] +cookie = ["dep:cookie"] +private-cookie = ["di", "dep:cookie", "cookie?/private"] +signed-cookie = ["di", "dep:cookie", "cookie?/signed"] + compression-full = ["compression-brotli", "compression-gzip", "compression-zstd"] compression-brotli = ["middleware", "dep:async-compression", "async-compression?/brotli"] compression-gzip = ["middleware", "dep:async-compression", "async-compression?/zlib", "async-compression?/gzip"] @@ -260,4 +267,19 @@ required-features = ["middleware","static-files","tracing"] [[example]] name = "sse" -path = "examples/sse.rs" \ No newline at end of file +path = "examples/sse.rs" + +[[example]] +name = "cookies" +path = "examples/cookies.rs" +required-features = ["cookie"] + +[[example]] +name = "signed_cookies" +path = "examples/signed_cookies.rs" +required-features = ["cookie","signed-cookie","di"] + +[[example]] +name = "private_cookies" +path = "examples/private_cookies.rs" +required-features = ["cookie","private-cookie","di"] \ No newline at end of file diff --git a/README.md b/README.md index 6694735..bf52117 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ # Volga Fast, Easy, and very flexible Web Framework for Rust based on [Tokio](https://tokio.rs/) runtime and [hyper](https://hyper.rs/) for fun and painless microservices crafting. -[![latest](https://img.shields.io/badge/latest-0.5.7-blue)](https://crates.io/crates/volga) +[![latest](https://img.shields.io/badge/latest-0.5.8-blue)](https://crates.io/crates/volga) [![latest](https://img.shields.io/badge/rustc-1.80+-964B00)](https://crates.io/crates/volga) [![License: MIT](https://img.shields.io/badge/License-MIT-violet.svg)](https://github.com/RomanEmreis/volga/blob/main/LICENSE) [![Build](https://github.com/RomanEmreis/volga/actions/workflows/rust.yml/badge.svg)](https://github.com/RomanEmreis/volga/actions/workflows/rust.yml) [![Release](https://github.com/RomanEmreis/volga/actions/workflows/release.yml/badge.svg)](https://github.com/RomanEmreis/volga/actions/workflows/release.yml) -[Tutorial](https://romanemreis.github.io/volga-docs/) | [API Docs](https://docs.rs/volga/latest/volga/) | [Examples](https://github.com/RomanEmreis/volga/tree/main/examples) +> 💡 **Note**: This project is currently in preview. Breaking changes can be introduced without prior notice. + +[Tutorial](https://romanemreis.github.io/volga-docs/) | [API Docs](https://docs.rs/volga/latest/volga/) | [Examples](https://github.com/RomanEmreis/volga/tree/main/examples) | [Roadmap](https://github.com/RomanEmreis/volga/milestone/1) ## Features * Supports HTTP/1 and HTTP/2 @@ -21,7 +23,7 @@ Fast, Easy, and very flexible Web Framework for Rust based on [Tokio](https://to ### Dependencies ```toml [dependencies] -volga = "0.5.7" +volga = "0.5.8" tokio = { version = "1", features = ["full"] } ``` ### Simple request handler diff --git a/examples/cookies.rs b/examples/cookies.rs new file mode 100644 index 0000000..683bd42 --- /dev/null +++ b/examples/cookies.rs @@ -0,0 +1,42 @@ +//! Run with: +//! +//! ```no_rust +//! cargo run --example cookies --features cookie +//! ``` + +use uuid::Uuid; +use volga::{ + App, HttpResult, http::Cookies, + error::Error, + headers::{Header, Authorization}, + status, ok, see_other +}; + +async fn login(cookies: Cookies, auth: Header) -> Result<(HttpResult, Cookies), Error> { + let session_id = authorize(auth)?; + Ok((see_other!("/me"), cookies.add(("session-id", session_id)))) +} + +async fn me(cookies: Cookies) -> HttpResult { + if let Some(_session_id) = cookies.get("session-id") { + ok!("Success") + } else { + status!(401, "Unauthorized") + } +} + +fn authorize(_auth: Header) -> Result { + Ok(Uuid::new_v4().to_string()) + + // authorize the user and create a session +} + +#[tokio::main] +async fn main() -> std::io::Result<()> { + let mut app = App::new(); + + app.map_post("/login", login); + app.map_get("/me", me); + + app.run().await +} \ No newline at end of file diff --git a/examples/dependency_injection.rs b/examples/dependency_injection.rs index 70926ff..99d7176 100644 --- a/examples/dependency_injection.rs +++ b/examples/dependency_injection.rs @@ -71,7 +71,7 @@ async fn main() -> std::io::Result<()> { } async fn log_request(mut ctx: HttpContext, next: Next) -> HttpResult { - let log = ctx.resolve_shared::().await?; + let log = ctx.resolve_shared::()?; let req_id = HeaderValue::from_str(log.request_id.as_str()).unwrap(); ctx.request .headers_mut() @@ -129,13 +129,13 @@ impl RequestLog { } } -/// Custom implementation of `Inject` trait that helps to construct the `RequestLog` +/// Custom implementation of the ` Inject ` trait that helps to construct the `RequestLog` impl Inject for RequestLog { - async fn inject(container: &Container) -> Result { + fn inject(container: &Container) -> Result { // We don't need to own this, and it's not implement a Clone, so we can resolve a shared pointer - let req_gen = container.resolve_shared::().await?; + let req_gen = container.resolve_shared::()?; // Since we need to own ity, and it's a clonable struct, it is fine to resolve as a clone - let cache = container.resolve::().await?; + let cache = container.resolve::()?; Ok(Self { request_id: req_gen.generate_id(), inner: Default::default(), diff --git a/examples/private_cookies.rs b/examples/private_cookies.rs new file mode 100644 index 0000000..7e04dce --- /dev/null +++ b/examples/private_cookies.rs @@ -0,0 +1,45 @@ +//! Run with: +//! +//! ```no_rust +//! cargo run --example private_cookies --features cookie,private-cookie,di +//! ``` + +use uuid::Uuid; +use volga::{ + App, HttpResult, + error::Error, + http::{PrivateKey, PrivateCookies}, + headers::{Header, Authorization}, + status, ok, see_other +}; + +async fn login(cookies: PrivateCookies, auth: Header) -> Result<(HttpResult, PrivateCookies), Error> { + let session_id = authorize(auth)?; + Ok((see_other!("/me"), cookies.add(("session-id", session_id)))) +} + +async fn me(cookies: PrivateCookies) -> HttpResult { + if let Some(_session_id) = cookies.get("session-id") { + ok!("Success") + } else { + status!(401, "Unauthorized") + } +} + +fn authorize(_auth: Header) -> Result { + Ok(Uuid::new_v4().to_string()) + + // authorize the user and create a session +} + +#[tokio::main] +async fn main() -> std::io::Result<()> { + let mut app = App::new(); + + app.add_singleton(PrivateKey::generate()); + + app.map_post("/login", login); + app.map_get("/me", me); + + app.run().await +} \ No newline at end of file diff --git a/examples/signed_cookies.rs b/examples/signed_cookies.rs new file mode 100644 index 0000000..1637b48 --- /dev/null +++ b/examples/signed_cookies.rs @@ -0,0 +1,45 @@ +//! Run with: +//! +//! ```no_rust +//! cargo run --example signed_cookies --features cookie,signed-cookie,di +//! ``` + +use uuid::Uuid; +use volga::{ + App, HttpResult, + error::Error, + http::{SignedCookies, SignedKey}, + headers::{Header, Authorization}, + status, ok, see_other +}; + +async fn login(cookies: SignedCookies, auth: Header) -> Result<(HttpResult, SignedCookies), Error> { + let session_id = authorize(auth)?; + Ok((see_other!("/me"), cookies.add(("session-id", session_id)))) +} + +async fn me(cookies: SignedCookies) -> HttpResult { + if let Some(_session_id) = cookies.get("session-id") { + ok!("Success") + } else { + status!(401, "Unauthorized") + } +} + +fn authorize(_auth: Header) -> Result { + Ok(Uuid::new_v4().to_string()) + + // authorize the user and create a session +} + +#[tokio::main] +async fn main() -> std::io::Result<()> { + let mut app = App::new(); + + app.add_singleton(SignedKey::generate()); + + app.map_post("/login", login); + app.map_get("/me", me); + + app.run().await +} \ No newline at end of file diff --git a/src/di.rs b/src/di.rs index e6428c8..1249b1f 100644 --- a/src/di.rs +++ b/src/di.rs @@ -92,35 +92,35 @@ mod tests { #[derive(Default)] struct TestDependency; - #[tokio::test] - async fn it_adds_singleton() { + #[test] + fn it_adds_singleton() { let mut app = App::new(); app.add_singleton(TestDependency); let container = app.container.build(); - let dep = container.resolve_shared::().await; + let dep = container.resolve_shared::(); assert!(dep.is_ok()); } - #[tokio::test] - async fn it_adds_scoped() { + #[test] + fn it_adds_scoped() { let mut app = App::new(); app.add_scoped::(); let container = app.container.build(); - let dep = container.resolve_shared::().await; + let dep = container.resolve_shared::(); assert!(dep.is_ok()); } - #[tokio::test] - async fn it_adds_transient() { + #[test] + fn it_adds_transient() { let mut app = App::new(); app.add_transient::(); let container = app.container.build(); - let dep = container.resolve_shared::().await; + let dep = container.resolve_shared::(); assert!(dep.is_ok()); } diff --git a/src/di/container.rs b/src/di/container.rs index b455082..e1c38ec 100644 --- a/src/di/container.rs +++ b/src/di/container.rs @@ -3,8 +3,7 @@ use super::{Inject, DiError}; use crate::error::Error; use hyper::http::{Extensions, request::Parts}; -use futures_util::TryFutureExt; -use tokio::sync::OnceCell; +use std::sync::OnceLock; use std::{ any::{Any, TypeId}, collections::HashMap, @@ -20,7 +19,7 @@ type ArcService = Arc< pub(crate) enum ServiceEntry { Singleton(ArcService), - Scoped(OnceCell), + Scoped(OnceLock>), Transient, } @@ -29,7 +28,7 @@ impl ServiceEntry { fn as_scope(&self) -> Self { match self { ServiceEntry::Singleton(service) => ServiceEntry::Singleton(service.clone()), - ServiceEntry::Scoped(_) => ServiceEntry::Scoped(OnceCell::new()), + ServiceEntry::Scoped(_) => ServiceEntry::Scoped(OnceLock::new()), ServiceEntry::Transient => ServiceEntry::Transient, } } @@ -88,7 +87,7 @@ impl ContainerBuilder { /// Register a scoped service pub fn register_scoped(&mut self) { - let entry = ServiceEntry::Scoped(OnceCell::new()); + let entry = ServiceEntry::Scoped(OnceLock::new()); self.services.insert(TypeId::of::(), entry); } @@ -119,18 +118,17 @@ impl Container { /// `T` must implement [`Clone`] otherwise use [`resolve_shared`] method /// that returns a shared pointer #[inline] - pub async fn resolve(&self) -> Result { + pub fn resolve(&self) -> Result { self.resolve_shared::() - .map_ok(|s| s.as_ref().clone()) - .await + .map(|s| s.as_ref().clone()) } /// Resolves a service and returns a shared pointer #[inline] - pub async fn resolve_shared(&self) -> Result, Error> { + pub fn resolve_shared(&self) -> Result, Error> { match self.get_service_entry::()? { - ServiceEntry::Transient => T::inject(self).map_ok(Arc::new).await, - ServiceEntry::Scoped(cell) => self.resolve_scoped(cell).await, + ServiceEntry::Transient => T::inject(self).map(Arc::new), + ServiceEntry::Scoped(cell) => self.resolve_scoped(cell), ServiceEntry::Singleton(instance) => Self::resolve_internal(instance) } } @@ -145,15 +143,14 @@ impl Container { } #[inline] - async fn resolve_scoped(&self, cell: &OnceCell) -> Result, Error> { - let instance = cell - .get_or_try_init(|| async { - T::inject(self) - .map_ok(|scoped| Arc::new(scoped) as ArcService) - .await - }) - .await?; - Self::resolve_internal(instance) + fn resolve_scoped(&self, cell: &OnceLock>) -> Result, Error> { + let instance = cell.get_or_init(|| + T::inject(self).map(|scoped| Arc::new(scoped) as ArcService) + ); + instance + .as_ref() + .map_err(|err| Error::server_error(err.to_string())) + .and_then(Self::resolve_internal) } #[inline] @@ -234,71 +231,71 @@ mod tests { } impl Inject for CacheWrapper { - async fn inject(container: &Container) -> Result { - let inner = container.resolve::().await?; + fn inject(container: &Container) -> Result { + let inner = container.resolve::()?; Ok(Self { inner }) } } - #[tokio::test] - async fn it_registers_singleton() { + #[test] + fn it_registers_singleton() { let mut container = ContainerBuilder::new(); container.register_singleton(InMemoryCache::default()); let container = container.build(); - let cache = container.resolve::().await.unwrap(); + let cache = container.resolve::().unwrap(); cache.set("key", "value"); - let cache = container.resolve::().await.unwrap(); + let cache = container.resolve::().unwrap(); let key = cache.get("key").unwrap(); assert_eq!(key, "value"); } - #[tokio::test] - async fn it_registers_transient() { + #[test] + fn it_registers_transient() { let mut container = ContainerBuilder::new(); container.register_transient::(); let container = container.build(); - let cache = container.resolve::().await.unwrap(); + let cache = container.resolve::().unwrap(); cache.set("key", "value"); - let cache = container.resolve::().await.unwrap(); + let cache = container.resolve::().unwrap(); let key = cache.get("key"); assert!(key.is_none()); } - #[tokio::test] - async fn it_registers_scoped() { + #[test] + fn it_registers_scoped() { let mut container = ContainerBuilder::new(); container.register_scoped::(); let container = container.build(); // working in the initial scope - let cache = container.resolve::().await.unwrap(); + let cache = container.resolve::().unwrap(); cache.set("key", "value 1"); - // create a new scope so new instance of InMemoryCache will be created + // create a new scope so a new instance of InMemoryCache will be created { let scope = container.create_scope(); - let cache = scope.resolve::().await.unwrap(); + let cache = scope.resolve::().unwrap(); cache.set("key", "value 2"); - let cache = scope.resolve::().await.unwrap(); + let cache = scope.resolve::().unwrap(); let key = cache.get("key").unwrap(); assert_eq!(key, "value 2"); } - // create a new scope so new instance of InMemoryCache will be created + // create a new scope so a new instance of InMemoryCache will be created { let scope = container.create_scope(); - let cache = scope.resolve::().await.unwrap(); + let cache = scope.resolve::().unwrap(); let key = cache.get("key"); assert!(key.is_none()); @@ -309,8 +306,8 @@ mod tests { assert_eq!(key, "value 1"); } - #[tokio::test] - async fn it_resolves_inner_dependencies() { + #[test] + fn it_resolves_inner_dependencies() { let mut container = ContainerBuilder::new(); container.register_singleton(InMemoryCache::default()); @@ -320,18 +317,18 @@ mod tests { { let scope = container.create_scope(); - let cache = scope.resolve::().await.unwrap(); + let cache = scope.resolve::().unwrap(); cache.inner.set("key", "value 1"); } - let cache = container.resolve::().await.unwrap(); + let cache = container.resolve::().unwrap(); let key = cache.get("key").unwrap(); assert_eq!(key, "value 1"); } - #[tokio::test] - async fn inner_scope_does_not_affect_outer() { + #[test] + fn inner_scope_does_not_affect_outer() { let mut container = ContainerBuilder::new(); container.register_scoped::(); @@ -341,21 +338,21 @@ mod tests { { let scope = container.create_scope(); - let cache = scope.resolve::().await.unwrap(); + let cache = scope.resolve::().unwrap(); cache.inner.set("key", "value 1"); - let cache = scope.resolve::().await.unwrap(); + let cache = scope.resolve::().unwrap(); cache.inner.set("key", "value 2"); } - let cache = container.resolve::().await.unwrap(); + let cache = container.resolve::().unwrap(); let key = cache.get("key"); assert!(key.is_none()) } - #[tokio::test] - async fn it_resolves_inner_scoped_dependencies() { + #[test] + fn it_resolves_inner_scoped_dependencies() { let mut container = ContainerBuilder::new(); container.register_scoped::(); @@ -364,13 +361,13 @@ mod tests { let container = container.build(); let scope = container.create_scope(); - let cache = scope.resolve::().await.unwrap(); + let cache = scope.resolve::().unwrap(); cache.inner.set("key1", "value 1"); - let cache = scope.resolve::().await.unwrap(); + let cache = scope.resolve::().unwrap(); cache.inner.set("key2", "value 2"); - let cache = scope.resolve::().await.unwrap(); + let cache = scope.resolve::().unwrap(); assert_eq!(cache.inner.get("key1").unwrap(), "value 1"); assert_eq!(cache.inner.get("key2").unwrap(), "value 2"); @@ -393,22 +390,22 @@ mod tests { assert!(container.is_ok()); } - #[tokio::test] - async fn it_returns_error_when_resolve_unregistered() { + #[test] + fn it_returns_error_when_resolve_unregistered() { let container = ContainerBuilder::new().build(); - let cache = container.resolve::().await; + let cache = container.resolve::(); assert!(cache.is_err()); } - #[tokio::test] - async fn it_returns_error_when_resolve_unregistered_from_scope() { + #[test] + fn it_returns_error_when_resolve_unregistered_from_scope() { let container = ContainerBuilder::new() .build() .create_scope(); - let cache = container.resolve::().await; + let cache = container.resolve::(); assert!(cache.is_err()); } diff --git a/src/di/dc.rs b/src/di/dc.rs index d7b80f4..1d7f994 100644 --- a/src/di/dc.rs +++ b/src/di/dc.rs @@ -1,8 +1,7 @@ //! Extractors for Dependency Injection use super::{Container, Inject}; -use futures_util::{pin_mut, ready}; -use pin_project_lite::pin_project; +use futures_util::future::{ready, Ready}; use crate::{ error::Error, @@ -11,10 +10,6 @@ use crate::{ use std::{ ops::{Deref, DerefMut}, - task::{Context, Poll}, - marker::PhantomData, - future::Future, - pin::Pin, sync::Arc }; @@ -83,36 +78,18 @@ impl Dc { } } -pin_project! { - /// A future that resolves a dependency from DI container. - pub struct ExtractDependencyFut { - #[pin] - container: Container, - _marker: PhantomData - } -} - -impl Future for ExtractDependencyFut { - type Output = Result, Error>; - - #[inline] - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.project(); - let fut = this.container.resolve_shared::(); - pin_mut!(fut); - let result = ready!(fut.poll(cx)); - Poll::Ready(result.map(Dc)) - } -} - impl FromPayload for Dc { - type Future = ExtractDependencyFut; + type Future = Ready>; + #[inline] fn from_payload(payload: Payload) -> Self::Future { let Payload::Parts(parts) = payload else { unreachable!() }; let container = Container::try_from(parts) .expect("DI Container must be provided"); - ExtractDependencyFut { container, _marker: PhantomData } + + ready(container + .resolve_shared::() + .map(Dc)) } fn source() -> Source { @@ -139,7 +116,7 @@ mod tests { let container = container.build(); let scope = container.create_scope(); - let vec = scope.resolve::().await.unwrap(); + let vec = scope.resolve::().unwrap(); vec.lock().unwrap().push(1); let mut req = Request::get("/").body(()).unwrap(); diff --git a/src/di/inject.rs b/src/di/inject.rs index 9edbcf1..be7d19f 100644 --- a/src/di/inject.rs +++ b/src/di/inject.rs @@ -2,12 +2,10 @@ use super::Container; use crate::error::Error; -use futures_util::future::ok; -use std::future::Future; /// A trait that adds the ability to inject dependencies when resolving a type from the DI container /// -/// If there is no need to inject other dependencies the `struct` must implement the `Default` trait +/// If there is no need to inject other dependencies, the `struct` must implement the `Default` trait /// /// # Example /// ```no_run @@ -45,10 +43,9 @@ use std::future::Future; /// } /// /// impl Inject for TransientService { -/// async fn inject(container: &Container) -> Result { +/// fn inject(container: &Container) -> Result { /// let scoped_service = container -/// .resolve::() -/// .await?; +/// .resolve::()?; /// Ok(Self { service: scoped_service }) /// } /// } @@ -64,12 +61,12 @@ use std::future::Future; /// }); /// ``` pub trait Inject: Sized + Send + Sync { - fn inject(container: &Container) -> impl Future> + Send; + fn inject(container: &Container) -> Result; } impl Inject for T { #[inline] - fn inject(_: &Container) -> impl Future> + Send { - ok(Self::default()) + fn inject(_: &Container) -> Result { + Ok(Self::default()) } } diff --git a/src/headers.rs b/src/headers.rs index a29c18f..e680aeb 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -22,9 +22,10 @@ pub use hyper::{ TRANSFER_ENCODING, VARY, UPGRADE, - CONNECTION + CONNECTION, + COOKIE, SET_COOKIE }, - http::HeaderValue, + http::{HeaderName, HeaderValue}, HeaderMap }; diff --git a/src/http.rs b/src/http.rs index e8b9149..b30a0df 100644 --- a/src/http.rs +++ b/src/http.rs @@ -24,10 +24,18 @@ pub use response::{ #[cfg(feature = "middleware")] pub use cors::CorsConfig; +#[cfg(feature = "cookie")] +pub use cookie::Cookies; +#[cfg(feature = "signed-cookie")] +pub use cookie::signed::{SignedKey, SignedCookies}; +#[cfg(feature = "private-cookie")] +pub use cookie::private::{PrivateKey, PrivateCookies}; pub mod body; pub mod request; pub mod response; pub mod endpoints; +#[cfg(feature = "cookie")] +pub mod cookie; #[cfg(feature = "middleware")] pub mod cors; diff --git a/src/http/cookie.rs b/src/http/cookie.rs new file mode 100644 index 0000000..3a38c39 --- /dev/null +++ b/src/http/cookie.rs @@ -0,0 +1,203 @@ +//! Set of utils to work with Cookies + +use cookie::CookieJar; +use futures_util::future::{ready, Ready}; +use crate::{ + error::Error, + headers::{COOKIE, SET_COOKIE, HeaderMap}, + http::{endpoints::args::{FromPayload, Payload, Source}}, +}; + +#[cfg(feature = "signed-cookie")] +pub mod signed; +#[cfg(feature = "private-cookie")] +pub mod private; + +/// Represents HTTP cookies +#[derive(Debug, Default, Clone)] +pub struct Cookies(CookieJar); + +impl From<&HeaderMap> for Cookies { + #[inline] + fn from(headers: &HeaderMap) -> Self { + let mut jar = CookieJar::new(); + for cookie in get_cookies(headers) { + jar.add_original(cookie); + } + + Self(jar) + } +} + +impl From for HeaderMap { + #[inline] + fn from(cookies: Cookies) -> Self { + let mut headers = Self::new(); + set_cookies(cookies.0, &mut headers); + headers + } +} + +impl Cookies { + /// Creates a new [`Cookies`] + #[inline] + pub fn new() -> Self { + Self::default() + } + + /// Unwraps the inner jar + #[inline] + pub fn into_inner(self) -> CookieJar { + self.0 + } + + /// Returns a reference to the cookie inside the jar by `name` + /// If the cookie cannot be found, `None` is returned. + pub fn get(&self, name: &str) -> Option<&cookie::Cookie<'static>> { + self.0.get(name) + } + + /// Adds a cookie. If a cookie with the same name already exists, it is replaced with this cookie. + #[allow(clippy::should_implement_trait)] + pub fn add>>(mut self, cookie: C) -> Self { + self.0.add(cookie); + self + } + + /// Removes cookie from this jar. If an original cookie with the same name as the cookie is present in the jar, + /// a removal cookie will be present in the delta computation. + /// + /// To properly generate the removal cookie, this cookie must contain the same path and domain as the cookie that was initially set. + pub fn remove>>(mut self, cookie: C) -> Self { + self.0.remove(cookie); + self + } + + /// Returns an iterator over all the cookies present in this jar. + pub fn iter(&self) -> impl Iterator> + '_ { + self.0.iter() + } +} + +/// Gets cookies from HTTP request's [`HeaderMap`] +#[inline] +fn get_cookies(headers: &HeaderMap) -> impl Iterator> + '_ { + headers + .get_all(COOKIE) + .into_iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|cookie| cookie::Cookie::parse_encoded(cookie.to_owned()).ok()) +} + +/// Sets cookies to the HTTP headers +#[inline] +pub(crate) fn set_cookies(jar: CookieJar, headers: &mut HeaderMap) { + for cookie in jar.delta() { + if let Ok(header_value) = cookie.encoded().to_string().parse() { + headers.append(SET_COOKIE, header_value); + } + } +} + +impl FromPayload for Cookies { + type Future = Ready>; + + #[inline] + fn from_payload(payload: Payload) -> Self::Future { + let Payload::Parts(parts) = payload else { unreachable!() }; + ready(Ok(Cookies::from(&parts.headers))) + } + + #[inline] + fn source() -> Source { + Source::Parts + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::headers::HeaderValue; + + #[test] + fn it_creates_cookies_from_empty_headers() { + let headers = HeaderMap::new(); + let cookies = Cookies::from(&headers); + assert_eq!(cookies.iter().count(), 0); + } + + #[test] + fn it_creates_cookies() { + let mut headers = HeaderMap::new(); + headers.insert( + COOKIE, + HeaderValue::from_static("session=abc123"), + ); + + let cookies = Cookies::from(&headers); + let cookie = cookies.get("session").expect("Cookie should exist"); + assert_eq!(cookie.value(), "abc123"); + } + + #[test] + fn it_creates_from_multiple_cookies() { + let mut headers = HeaderMap::new(); + headers.insert( + COOKIE, + HeaderValue::from_static("session=abc123; user=john; theme=dark"), + ); + + let cookies = Cookies::from(&headers); + assert_eq!(cookies.get("session").unwrap().value(), "abc123"); + assert_eq!(cookies.get("user").unwrap().value(), "john"); + assert_eq!(cookies.get("theme").unwrap().value(), "dark"); + } + + #[test] + fn it_removes_cookies() { + let mut cookies = Cookies::default(); + + // Add a new cookie + cookies = cookies.add(cookie::Cookie::new("test", "value")); + assert_eq!(cookies.get("test").unwrap().value(), "value"); + + // Remove a cookie + cookies = cookies.remove(cookie::Cookie::new("test", "")); + assert!(cookies.get("test").is_none()); + } + + #[test] + fn it_sets_cookies_to_headers() { + let mut cookies = Cookies::default(); + cookies = cookies.add(cookie::Cookie::new("session", "xyz789")); + + let mut headers = HeaderMap::new(); + set_cookies(cookies.0, &mut headers); + + let cookie_header = headers.get(SET_COOKIE).expect("Cookie header should be set"); + assert!(cookie_header.to_str().unwrap().contains("session=xyz789")); + } + + #[tokio::test] + async fn it_extracts_from_payload() { + use hyper::Request; + + let request = Request::builder() + .header(COOKIE, "test=value") + .body(()) + .unwrap(); + + let (parts, _) = request.into_parts(); + let payload = Payload::Parts(&parts); + + let cookies = Cookies::from_payload(payload).await.unwrap(); + + assert_eq!(cookies.get("test").unwrap().value(), "value"); + } + + #[test] + fn it_returns_parts_source() { + assert_eq!(Cookies::source(), Source::Parts); + } +} \ No newline at end of file diff --git a/src/http/cookie/private.rs b/src/http/cookie/private.rs new file mode 100644 index 0000000..1565f25 --- /dev/null +++ b/src/http/cookie/private.rs @@ -0,0 +1,258 @@ +//! Utilities for private cookies + +use cookie::{CookieJar, PrivateJar, Key}; +use futures_util::future::{ready, Ready}; +use crate::{ + error::Error, + di::container::Container, + headers::{HeaderMap}, + http::{ + endpoints::args::{FromPayload, Payload, Source}, + cookie::{get_cookies} + }, +}; + +/// Represents a cryptographic pass key for [`PrivateCookies`] +#[derive(Clone)] +pub struct PrivateKey(Key); + +/// Represents private HTTP cookies +pub struct PrivateCookies(PrivateKey, CookieJar); + +impl Default for PrivateKey { + #[inline] + fn default() -> Self { + Self::from(&[]) + } +} + +impl PrivateKey { + /// Creates a new [`PrivateKey`] from a 512-bit cryptographically random string. + /// + /// See also [`Key::from`] + #[inline] + pub fn from(bytes: &[u8]) -> Self { + Self(Key::from(bytes)) + } + + /// Generates signing/encryption keys from a secure, random source. + /// Keys are generated nondeterministically. + /// + /// See also [`Key::generate`] + #[inline] + pub fn generate() -> Self { + Self(Key::generate()) + } +} + +impl PrivateCookies { + /// Creates a new [`PrivateCookies`] + #[inline] + pub fn new(key: PrivateKey) -> Self { + Self(key, CookieJar::default()) + } + + /// Creates a new [`PrivateCookies`] from [`HeaderMap`] + #[inline] + pub fn from_headers(key: PrivateKey, headers: &HeaderMap) -> Self { + let mut jar = CookieJar::new(); + let mut private_jar = jar.private_mut(&key.0); + for cookie in get_cookies(headers) { + if let Some(cookie) = private_jar.decrypt(cookie) { + private_jar.add_original(cookie); + } + } + Self(key, jar) + } + + /// Unwraps the inner jar and the pass key. + #[inline] + pub fn into_parts(self) -> (PrivateKey, CookieJar) { + (self.0, self.1) + } + + /// Returns a reference to the cookie inside the private jar by `name` + /// and authenticates and decrypts the cookie's value, returning a [`Cookie`] with the decrypted value. + /// If the cookie cannot be found, or the cookie fails to authenticate or decrypt, `None` is returned. + pub fn get(&self, name: &str) -> Option> { + self.private().get(name) + } + + /// Adds a cookie. + /// The cookie's value is encrypted with authenticated encryption assuring confidentiality, integrity, and authenticity. + #[allow(clippy::should_implement_trait)] + pub fn add>>(mut self, cookie: C) -> Self { + self.private_mut().add(cookie); + self + } + + /// Removes a cookie from the private jar. + /// + /// For correct removal, the passed in cookie must contain the same path + /// and domain as the cookie that was initially set. + pub fn remove>>(mut self, cookie: C) -> Self { + self.private_mut().remove(cookie); + self + } + + /// Authenticates and decrypts the cookie, returning the plaintext version if decryption succeeds or `None` otherwise. + /// Authentication and decryption always succeed if a cookie was generated by a [`PrivateCookies`] with the same key as self. + pub fn decrypt(&self, cookie: cookie::Cookie<'static>) -> Option> { + self.private().decrypt(cookie) + } + + /// Returns an iterator over all the cookies present in this jar. + pub fn iter(&self) -> impl Iterator> + '_ { + self.1.iter() + } + + #[inline] + fn private(&self) -> PrivateJar<&'_ CookieJar> { + self.1.private(&self.0.0) + } + + #[inline] + fn private_mut(&mut self) -> PrivateJar<&'_ mut CookieJar> { + self.1.private_mut(&self.0.0) + } +} + +impl FromPayload for PrivateCookies { + type Future = Ready>; + + #[inline] + fn from_payload(payload: Payload) -> Self::Future { + let Payload::Parts(parts) = payload else { unreachable!() }; + let container = Container::try_from(parts) + .expect("DI Container must be provided"); + + ready(container + .resolve::() + .map(|key| PrivateCookies::from_headers(key, &parts.headers))) + } + + #[inline] + fn source() -> Source { + Source::Parts + } +} + +#[cfg(test)] +mod tests { + use crate::di::ContainerBuilder; + use super::*; + use crate::headers::{COOKIE, SET_COOKIE}; + use crate::http::cookie::set_cookies; + + #[test] + fn it_creates_cookies_from_empty_headers() { + let key = PrivateKey::generate(); + let cookies = PrivateCookies::new(key); + assert_eq!(cookies.iter().count(), 0); + } + + #[test] + fn it_creates_cookies() { + let key = PrivateKey::from( + b"f3d9e2a44c6b172a1ea9b9d05e5fe1bcaa8679d032ccae271c503af9618bb2ef7c4e51452dbfcd96f6e9c9d09166a3de77e"); + let cookies = PrivateCookies::new(key.clone()); + let cookies = cookies.add(("session", "abc123")); + + let mut headers = HeaderMap::new(); + set_cookies_for_request(cookies.1, &mut headers); + + let cookies = PrivateCookies::from_headers(key, &headers); + let cookie = cookies.get("session").expect("Cookie should exist"); + + assert_eq!(cookie.value(), "abc123"); + } + + #[test] + fn it_creates_from_multiple_cookies() { + let key = PrivateKey::generate(); + let cookies = PrivateCookies::new(key.clone()); + let cookies = cookies + .add(("session", "abc123")) + .add(("user", "john")) + .add(("theme", "dark")); + + let mut headers = HeaderMap::new(); + set_cookies_for_request(cookies.1, &mut headers); + + let cookies = PrivateCookies::from_headers(key, &headers); + + assert_eq!(cookies.get("session").unwrap().value(), "abc123"); + assert_eq!(cookies.get("user").unwrap().value(), "john"); + assert_eq!(cookies.get("theme").unwrap().value(), "dark"); + } + + #[test] + fn it_adds_and_removes_cookies() { + let key = PrivateKey::generate(); + let mut cookies = PrivateCookies::new(key); + + // Add a new cookie + cookies = cookies.add(cookie::Cookie::new("test", "value")); + assert_eq!(cookies.get("test").unwrap().value(), "value"); + + // Remove a cookie + cookies = cookies.remove(cookie::Cookie::new("test", "")); + assert!(cookies.get("test").is_none()); + } + + #[test] + fn it_sets_cookies_to_headers() { + let key = PrivateKey::generate(); + let mut cookies = PrivateCookies::new(key); + cookies = cookies.add(cookie::Cookie::new("session", "xyz789")); + + let mut headers = HeaderMap::new(); + set_cookies(cookies.1, &mut headers); + + let cookie_header = headers.get(SET_COOKIE).expect("Cookie header should be set"); + assert!(cookie_header.to_str().unwrap().contains("session")); + } + + #[tokio::test] + async fn it_extracts_from_payload() { + use hyper::Request; + + let key = PrivateKey::generate(); + let cookies = PrivateCookies::new(key.clone()); + let cookies = cookies.add(("test", "value")); + + let mut headers = HeaderMap::new(); + set_cookies_for_request(cookies.1, &mut headers); + + let mut container = ContainerBuilder::new(); + container.register_singleton(key); + let container = container.build(); + + let mut request = Request::builder() + .extension(container.create_scope()) + .body(()) + .unwrap(); + + request.headers_mut().extend(headers); + + let (parts, _) = request.into_parts(); + let payload = Payload::Parts(&parts); + + let cookies = PrivateCookies::from_payload(payload).await.unwrap(); + + assert_eq!(cookies.get("test").unwrap().value(), "value"); + } + + #[test] + fn if_return_parts_source() { + assert_eq!(PrivateCookies::source(), Source::Parts); + } + + fn set_cookies_for_request(jar: CookieJar, headers: &mut HeaderMap) { + for cookie in jar.delta() { + if let Ok(header_value) = cookie.encoded().to_string().parse() { + headers.append(COOKIE, header_value); + } + } + } +} \ No newline at end of file diff --git a/src/http/cookie/signed.rs b/src/http/cookie/signed.rs new file mode 100644 index 0000000..3a91e53 --- /dev/null +++ b/src/http/cookie/signed.rs @@ -0,0 +1,260 @@ +//! Utilities for signed cookies + +use cookie::{CookieJar, SignedJar, Key}; +use futures_util::future::{ready, Ready}; +use crate::{ + error::Error, + di::container::Container, + headers::{HeaderMap}, + http::{ + endpoints::args::{FromPayload, Payload, Source}, + cookie::{get_cookies} + }, +}; + +/// Represents a cryptographic pass key for [`SignedCookies`] +#[derive(Clone)] +pub struct SignedKey(Key); + +/// Represents signed HTTP cookies +pub struct SignedCookies(SignedKey, CookieJar); + +impl Default for SignedKey { + #[inline] + fn default() -> Self { + Self::from(&[]) + } +} + +impl SignedKey { + /// Creates a new [`SignedKey`] from a 512-bit cryptographically random string. + /// + /// See also [`Key::from`] + #[inline] + pub fn from(bytes: &[u8]) -> Self { + Self(Key::from(bytes)) + } + + /// Generates signing/encryption keys from a secure, random source. + /// Keys are generated nondeterministically. + /// + /// See also [`Key::generate`] + #[inline] + pub fn generate() -> Self { + Self(Key::generate()) + } +} + +impl SignedCookies { + /// Creates a new [`SignedCookies`]. + #[inline] + pub fn new(key: SignedKey) -> Self { + Self(key, CookieJar::default()) + } + + /// Creates a new [`SignedCookies`] from [`HeaderMap`]. + #[inline] + pub fn from_headers(key: SignedKey, headers: &HeaderMap) -> Self { + let mut jar = CookieJar::new(); + let mut signed_jar = jar.signed_mut(&key.0); + for cookie in get_cookies(headers) { + if let Some(cookie) = signed_jar.verify(cookie) { + signed_jar.add_original(cookie); + } + } + Self(key, jar) + } + + /// Unwraps the inner jar and the pass key. + #[inline] + pub fn into_parts(self) -> (SignedKey, CookieJar) { + (self.0, self.1) + } + + /// Returns a reference to the cookie inside the signed jar by `name` + /// and verifies the authenticity and integrity of the cookie's value, + /// returning a [`Cookie`] with the authenticated value. + /// If the cookie cannot be found, or the cookie fails to verify, `None` is returned. + pub fn get(&self, name: &str) -> Option> { + self.signed().get(name) + } + + /// Adds a cookie. The cookie's value is signed assuring integrity and authenticity. + #[allow(clippy::should_implement_trait)] + pub fn add>>(mut self, cookie: C) -> Self { + self.signed_mut().add(cookie); + self + } + + /// Removes a cookie from the signed jar. + /// + /// For correct removal, the passed in cookie must contain the same path + /// and domain as the cookie that was initially set. + pub fn remove>>(mut self, cookie: C) -> Self { + self.signed_mut().remove(cookie); + self + } + + /// Verifies the authenticity and integrity of the cookie, + /// returning the plaintext version if verification succeeds or None otherwise. + /// Verification always succeeds if a cookie was generated by a [`SignedCookies`] with the same `key` as `self`. + pub fn verify(&self, cookie: cookie::Cookie<'static>) -> Option> { + self.signed().verify(cookie) + } + + /// Returns an iterator over all the cookies present in this jar. + pub fn iter(&self) -> impl Iterator> + '_ { + self.1.iter() + } + + #[inline] + fn signed(&self) -> SignedJar<&'_ CookieJar> { + self.1.signed(&self.0.0) + } + + #[inline] + fn signed_mut(&mut self) -> SignedJar<&'_ mut CookieJar> { + self.1.signed_mut(&self.0.0) + } +} + +impl FromPayload for SignedCookies { + type Future = Ready>; + + #[inline] + fn from_payload(payload: Payload) -> Self::Future { + let Payload::Parts(parts) = payload else { unreachable!() }; + let container = Container::try_from(parts) + .expect("DI Container must be provided"); + + ready(container + .resolve::() + .map(|key| SignedCookies::from_headers(key, &parts.headers))) + } + + #[inline] + fn source() -> Source { + Source::Parts + } +} + +#[cfg(test)] +mod tests { + use crate::di::ContainerBuilder; + use super::*; + use crate::headers::{COOKIE, SET_COOKIE}; + use crate::http::cookie::set_cookies; + + #[test] + fn it_creates_cookies_from_empty_headers() { + let key = SignedKey(Key::generate()); + let cookies = SignedCookies::new(key); + assert_eq!(cookies.iter().count(), 0); + } + + #[test] + fn it_creates_cookies() { + let key = SignedKey::from( + b"f3d9e2a44c6b172a1ea9b9d05e5fe1bcaa8679d032ccae271c503af9618bb2ef7c4e51452dbfcd96f6e9c9d09166a3de77e"); + let cookies = SignedCookies::new(key.clone()); + let cookies = cookies.add(("session", "abc123")); + + let mut headers = HeaderMap::new(); + set_cookies_for_request(cookies.1, &mut headers); + + let cookies = SignedCookies::from_headers(key, &headers); + let cookie = cookies.get("session").expect("Cookie should exist"); + + assert_eq!(cookie.value(), "abc123"); + } + + #[test] + fn it_creates_from_multiple_cookies() { + let key = SignedKey::generate(); + let cookies = SignedCookies::new(key.clone()); + let cookies = cookies + .add(("session", "abc123")) + .add(("user", "john")) + .add(("theme", "dark")); + + let mut headers = HeaderMap::new(); + set_cookies_for_request(cookies.1, &mut headers); + + let cookies = SignedCookies::from_headers(key, &headers); + + assert_eq!(cookies.get("session").unwrap().value(), "abc123"); + assert_eq!(cookies.get("user").unwrap().value(), "john"); + assert_eq!(cookies.get("theme").unwrap().value(), "dark"); + } + + #[test] + fn it_adds_and_removes_cookies() { + let key = SignedKey::generate(); + let mut cookies = SignedCookies::new(key); + + // Add a new cookie + cookies = cookies.add(cookie::Cookie::new("test", "value")); + assert_eq!(cookies.get("test").unwrap().value(), "value"); + + // Remove a cookie + cookies = cookies.remove(cookie::Cookie::new("test", "")); + assert!(cookies.get("test").is_none()); + } + + #[test] + fn it_sets_cookies_to_headers() { + let key = SignedKey::generate(); + let mut cookies = SignedCookies::new(key); + cookies = cookies.add(cookie::Cookie::new("session", "xyz789")); + + let mut headers = HeaderMap::new(); + set_cookies(cookies.1, &mut headers); + + let cookie_header = headers.get(SET_COOKIE).expect("Cookie header should be set"); + assert!(cookie_header.to_str().unwrap().contains("session")); + } + + #[tokio::test] + async fn it_extracts_from_payload() { + use hyper::Request; + + let key = SignedKey::generate(); + let cookies = SignedCookies::new(key.clone()); + let cookies = cookies.add(("test", "value")); + + let mut headers = HeaderMap::new(); + set_cookies_for_request(cookies.1, &mut headers); + + let mut container = ContainerBuilder::new(); + container.register_singleton(key); + let container = container.build(); + + let mut request = Request::builder() + .extension(container.create_scope()) + .body(()) + .unwrap(); + + request.headers_mut().extend(headers); + + let (parts, _) = request.into_parts(); + let payload = Payload::Parts(&parts); + + let cookies = SignedCookies::from_payload(payload).await.unwrap(); + + assert_eq!(cookies.get("test").unwrap().value(), "value"); + } + + #[test] + fn if_return_parts_source() { + assert_eq!(SignedCookies::source(), Source::Parts); + } + + fn set_cookies_for_request(jar: CookieJar, headers: &mut HeaderMap) { + for cookie in jar.delta() { + if let Ok(header_value) = cookie.encoded().to_string().parse() { + headers.append(COOKIE, header_value); + } + } + } + +} \ No newline at end of file diff --git a/src/http/endpoints/args.rs b/src/http/endpoints/args.rs index b0b2255..e398018 100644 --- a/src/http/endpoints/args.rs +++ b/src/http/endpoints/args.rs @@ -69,7 +69,7 @@ pub trait FromRequestParts: Sized { fn from_parts(parts: &Parts) -> Result; } -/// Specifies extractor to read data from HTTP request +/// Specifies extractor to read data from an HTTP request /// depending on payload's [`Source`] pub(crate) trait FromPayload: Send + Sized { type Future: Future> + Send; @@ -77,7 +77,7 @@ pub(crate) trait FromPayload: Send + Sized { /// Extracts data from give [`Payload`] fn from_payload(payload: Payload) -> Self::Future; - /// Returns a [`Source`] where payload should be extracted from + /// Returns a [`Source`] where the payload should be extracted from fn source() -> Source { Source::None } diff --git a/src/http/request.rs b/src/http/request.rs index 253e393..af3f09d 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -124,19 +124,15 @@ impl HttpRequest { /// Resolves a service from Dependency Container as a clone, service must implement [`Clone`] #[inline] #[cfg(feature = "di")] - pub async fn resolve(&self) -> Result { - self.container() - .resolve::() - .await + pub fn resolve(&self) -> Result { + self.container().resolve::() } /// Resolves a service from Dependency Container #[inline] #[cfg(feature = "di")] - pub async fn resolve_shared(&self) -> Result, Error> { - self.container() - .resolve_shared::() - .await + pub fn resolve_shared(&self) -> Result, Error> { + self.container().resolve_shared::() } /// Extracts a payload from request parts @@ -273,9 +269,9 @@ mod tests { _ = http_req.container(); } - #[tokio::test] + #[test] #[cfg(feature = "di")] - async fn it_resolves_from_di_container() { + fn it_resolves_from_di_container() { let mut container = ContainerBuilder::new(); container.register_singleton(InMemoryCache::default()); @@ -287,14 +283,14 @@ mod tests { let (parts, body) = req.into_parts(); let http_req = HttpRequest::from_parts(parts, body); - let cache = http_req.resolve::().await; + let cache = http_req.resolve::(); assert!(cache.is_ok()); } - #[tokio::test] + #[test] #[cfg(feature = "di")] - async fn it_resolves_shared_from_di_container() { + fn it_resolves_shared_from_di_container() { let mut container = ContainerBuilder::new(); container.register_singleton(InMemoryCache::default()); @@ -306,7 +302,7 @@ mod tests { let (parts, body) = req.into_parts(); let http_req = HttpRequest::from_parts(parts, body); - let cache = http_req.resolve_shared::().await; + let cache = http_req.resolve_shared::(); assert!(cache.is_ok()); } diff --git a/src/http/response.rs b/src/http/response.rs index 119d8f3..185df11 100644 --- a/src/http/response.rs +++ b/src/http/response.rs @@ -5,7 +5,7 @@ use crate::error::Error; use crate::http::body::{BoxBody, HttpBody}; use std::collections::HashMap; -use tokio::{fs::File, io}; +use tokio::fs::File; use serde::Serialize; use hyper::{header::{HeaderName, HeaderValue}, http::response::Builder, Response, StatusCode}; @@ -71,17 +71,9 @@ pub struct ResponseContext { pub type HttpResponse = Response; pub type HttpResult = Result; -pub struct HttpResultWrapper(io::Result); pub type HttpHeaders = HashMap; - pub struct Results; -impl From for io::Result { - fn from(result: HttpResultWrapper) -> Self { - result.0 - } -} - impl Results { /// Produces a customized `OK 200` response #[inline] diff --git a/src/http/response/into_response.rs b/src/http/response/into_response.rs index 04da7e6..6f36705 100644 --- a/src/http/response/into_response.rs +++ b/src/http/response/into_response.rs @@ -2,10 +2,17 @@ use crate::{Json, Form, ok, status, form, response}; use crate::error::Error; use crate::http::StatusCode; -use crate::headers::CONTENT_TYPE; +use crate::headers::{HeaderMap, CONTENT_TYPE}; use mime::TEXT_PLAIN_UTF_8; use serde::Serialize; +#[cfg(feature = "cookie")] +use crate::http::{Cookies, cookie::set_cookies}; +#[cfg(feature = "signed-cookie")] +use crate::http::SignedCookies; +#[cfg(feature = "private-cookie")] +use crate::http::PrivateCookies; + use std::{ io::Error as IoError, convert::Infallible, @@ -129,6 +136,89 @@ impl IntoResponse for ResponseContext { } } +impl IntoResponse for StatusCode { + #[inline] + fn into_response(self) -> HttpResult { + response!( + self, + HttpBody::empty() + ) + } +} + +impl IntoResponse for (R, HeaderMap) +where + R: IntoResponse +{ + #[inline] + fn into_response(self) -> HttpResult { + let (resp, headers) = self; + match resp.into_response() { + Err(err) => Err(err), + Ok(mut resp) => { + resp.headers_mut().extend(headers); + Ok(resp) + }, + } + } +} + +#[cfg(feature = "signed-cookie")] +impl IntoResponse for (R, SignedCookies) +where + R: IntoResponse +{ + #[inline] + fn into_response(self) -> HttpResult { + let (resp, cookies) = self; + match resp.into_response() { + Err(err) => Err(err), + Ok(mut resp) => { + let (_, jar) = cookies.into_parts(); + set_cookies(jar, resp.headers_mut()); + Ok(resp) + }, + } + } +} + +#[cfg(feature = "private-cookie")] +impl IntoResponse for (R, PrivateCookies) +where + R: IntoResponse +{ + #[inline] + fn into_response(self) -> HttpResult { + let (resp, cookies) = self; + match resp.into_response() { + Err(err) => Err(err), + Ok(mut resp) => { + let (_, jar) = cookies.into_parts(); + set_cookies(jar, resp.headers_mut()); + Ok(resp) + }, + } + } +} + +#[cfg(feature = "cookie")] +impl IntoResponse for (R, Cookies) +where + R: IntoResponse +{ + #[inline] + fn into_response(self) -> HttpResult { + let (resp, cookies) = self; + match resp.into_response() { + Err(err) => Err(err), + Ok(mut resp) => { + set_cookies(cookies.into_inner(), resp.headers_mut()); + Ok(resp) + }, + } + } +} + macro_rules! impl_into_response { { $($type:ident),* $(,)? } => { $(impl IntoResponse for $type { @@ -161,10 +251,14 @@ mod tests { use std::collections::HashMap; use std::io::{Error as IoError, ErrorKind}; use http_body_util::BodyExt; + use hyper::StatusCode; use serde::Serialize; use crate::ResponseContext; use crate::error::Error; + use crate::headers::HeaderMap; use super::IntoResponse; + #[cfg(feature = "cookie")] + use crate::http::Cookies; #[derive(Serialize)] struct TestPayload { @@ -397,4 +491,132 @@ mod tests { assert_eq!(response.headers().get("Content-Type").unwrap(), "text/plain; charset=utf-8"); assert_eq!(response.status(), 200); } + + #[tokio::test] + async fn it_converts_status_response() { + let response = StatusCode::SEE_OTHER.into_response(); + + assert!(response.is_ok()); + let mut response = response.unwrap(); + let body = &response.body_mut().collect().await.unwrap().to_bytes(); + + assert_eq!(body.len(), 0); + assert_eq!(response.status(), 303); + } + + #[tokio::test] + async fn it_converts_tuple_of_status_and_headers_into_response() { + let mut headers = HeaderMap::new(); + headers.insert("x-api-key", "some api key".parse().unwrap()); + headers.insert("x-api-secret", "some api secret".parse().unwrap()); + + let response = ( + StatusCode::NO_CONTENT, + headers + ).into_response(); + + assert!(response.is_ok()); + let mut response = response.unwrap(); + let body = &response.body_mut().collect().await.unwrap().to_bytes(); + + assert_eq!(body.len(), 0); + assert_eq!(response.status(), 204); + assert_eq!(response.headers().get("x-api-key").unwrap(), "some api key"); + assert_eq!(response.headers().get("x-api-secret").unwrap(), "some api secret"); + } + + #[tokio::test] + #[cfg(feature = "cookie")] + async fn it_converts_tuple_of_redirect_status_and_cookies_into_redirect_response() { + let mut cookies = Cookies::new(); + cookies = cookies + .add(("key-1", "value-1")) + .add(("key-2", "value-2")); + + let response = ( + crate::found!("https://www.rust-lang.org/"), + cookies + ).into_response(); + + assert!(response.is_ok()); + let mut response = response.unwrap(); + let body = &response.body_mut().collect().await.unwrap().to_bytes(); + + assert_eq!(body.len(), 0); + assert_eq!(response.headers().get("location").unwrap(), "https://www.rust-lang.org/"); + assert_eq!(response.status(), 302); + + let cookies = get_cookies(response.headers()); + + assert!(cookies.contains(&"key-1=value-1")); + assert!(cookies.contains(&"key-2=value-2")); + } + + #[tokio::test] + #[cfg(feature = "signed-cookie")] + async fn it_converts_tuple_of_redirect_status_and_signed_cookies_into_redirect_response() { + use crate::http::{SignedKey, SignedCookies}; + + let key = SignedKey::generate(); + let mut cookies = SignedCookies::new(key); + cookies = cookies + .add(("key-1", "value-1")) + .add(("key-2", "value-2")); + + let response = ( + crate::see_other!("https://www.rust-lang.org/"), + cookies + ).into_response(); + + assert!(response.is_ok()); + let mut response = response.unwrap(); + let body = &response.body_mut().collect().await.unwrap().to_bytes(); + + assert_eq!(body.len(), 0); + assert_eq!(response.headers().get("location").unwrap(), "https://www.rust-lang.org/"); + assert_eq!(response.status(), 303); + + let cookies = get_cookies(response.headers()); + + assert_eq!(cookies.iter().filter(|c| c.contains("key-1")).count(), 1); + assert_eq!(cookies.iter().filter(|c| c.contains("key-2")).count(), 1); + } + + #[tokio::test] + #[cfg(feature = "private-cookie")] + async fn it_converts_tuple_of_redirect_status_and_private_cookies_into_redirect_response() { + use crate::http::{PrivateKey, PrivateCookies}; + + let key = PrivateKey::generate(); + let mut cookies = PrivateCookies::new(key); + cookies = cookies + .add(("key-1", "value-1")) + .add(("key-2", "value-2")); + + let response = ( + crate::see_other!("https://www.rust-lang.org/"), + cookies + ).into_response(); + + assert!(response.is_ok()); + let mut response = response.unwrap(); + let body = &response.body_mut().collect().await.unwrap().to_bytes(); + + assert_eq!(body.len(), 0); + assert_eq!(response.headers().get("location").unwrap(), "https://www.rust-lang.org/"); + assert_eq!(response.status(), 303); + + let cookies = get_cookies(response.headers()); + + assert_eq!(cookies.iter().filter(|c| c.contains("key-1")).count(), 1); + assert_eq!(cookies.iter().filter(|c| c.contains("key-2")).count(), 1); + } + + fn get_cookies(headers: &HeaderMap) -> Vec<&str> { + headers + .get_all("set-cookie") + .iter() + .map(|cookie| cookie.to_str().unwrap()) + .collect::>() + } } diff --git a/src/http/response/redirect.rs b/src/http/response/redirect.rs index 98ded0e..c0e78cc 100644 --- a/src/http/response/redirect.rs +++ b/src/http/response/redirect.rs @@ -9,39 +9,79 @@ /// ``` #[macro_export] macro_rules! redirect { + ($url:expr) => { + $crate::redirect!($url, []) + }; ($url:expr, [ $( ($key:expr, $value:expr) ),* $(,)? ]) => { $crate::status!(301, [ ($crate::headers::LOCATION, $url), $( ($key, $value) ),* ]) }; +} + +/// Produces HTTP 302 FOUND response +/// +/// # Example +/// ```no_run +/// use volga::found; +/// +/// let url = "https://www.rust-lang.org/"; +/// found!(url); +/// ``` +#[macro_export] +macro_rules! found { ($url:expr) => { - $crate::status!(301, [ + $crate::found!($url, []) + }; + ($url:expr, [ $( ($key:expr, $value:expr) ),* $(,)? ]) => { + $crate::status!(302, [ ($crate::headers::LOCATION, $url), + $( ($key, $value) ),* ]) }; } -/// Produces HTTP 307 TEMPORARY REDIRECT response +/// Produces HTTP 303 SEE OTHER response /// /// # Example /// ```no_run -/// use volga::temp_redirect; +/// use volga::see_other; /// /// let url = "https://www.rust-lang.org/"; -/// temp_redirect!(url); +/// see_other!(url); /// ``` #[macro_export] -macro_rules! temp_redirect { +macro_rules! see_other { + ($url:expr) => { + $crate::see_other!($url, []) + }; ($url:expr, [ $( ($key:expr, $value:expr) ),* $(,)? ]) => { - $crate::status!(307, [ + $crate::status!(303, [ ($crate::headers::LOCATION, $url), $( ($key, $value) ),* ]) }; +} + +/// Produces HTTP 307 TEMPORARY REDIRECT response +/// +/// # Example +/// ```no_run +/// use volga::temp_redirect; +/// +/// let url = "https://www.rust-lang.org/"; +/// temp_redirect!(url); +/// ``` +#[macro_export] +macro_rules! temp_redirect { ($url:expr) => { + $crate::temp_redirect!($url, []) + }; + ($url:expr, [ $( ($key:expr, $value:expr) ),* $(,)? ]) => { $crate::status!(307, [ ($crate::headers::LOCATION, $url), + $( ($key, $value) ),* ]) }; } @@ -57,17 +97,15 @@ macro_rules! temp_redirect { /// ``` #[macro_export] macro_rules! permanent_redirect { + ($url:expr) => { + $crate::permanent_redirect!($url, []) + }; ($url:expr, [ $( ($key:expr, $value:expr) ),* $(,)? ]) => { $crate::status!(308, [ ($crate::headers::LOCATION, $url), $( ($key, $value) ),* ]) }; - ($url:expr) => { - $crate::status!(308, [ - ($crate::headers::LOCATION, $url), - ]) - }; } #[cfg(test)] @@ -178,4 +216,74 @@ mod tests { assert_eq!(response.headers().get("x-api-key").unwrap(), "some api key"); assert_eq!(response.headers().get("x-req-id").unwrap(), "some req id"); } + + #[tokio::test] + async fn it_creates_found_redirect_response() { + let url = "https://www.rust-lang.org/"; + let response = found!(url); + + assert!(response.is_ok()); + + let mut response = response.unwrap(); + let body = &response.body_mut().collect().await.unwrap().to_bytes(); + + assert_eq!(body.len(), 0); + assert_eq!(response.status(), 302); + assert_eq!(response.headers().get("location").unwrap(), url); + } + + #[tokio::test] + async fn it_creates_found_redirect_response_with_custom_headers() { + let url = "https://www.rust-lang.org/"; + let response = found!(url, [ + ("x-api-key", "some api key"), + ("x-req-id", "some req id"), + ]); + + assert!(response.is_ok()); + + let mut response = response.unwrap(); + let body = &response.body_mut().collect().await.unwrap().to_bytes(); + + assert_eq!(body.len(), 0); + assert_eq!(response.status(), 302); + assert_eq!(response.headers().get("location").unwrap(), url); + assert_eq!(response.headers().get("x-api-key").unwrap(), "some api key"); + assert_eq!(response.headers().get("x-req-id").unwrap(), "some req id"); + } + + #[tokio::test] + async fn it_creates_see_other_redirect_response() { + let url = "https://www.rust-lang.org/"; + let response = see_other!(url); + + assert!(response.is_ok()); + + let mut response = response.unwrap(); + let body = &response.body_mut().collect().await.unwrap().to_bytes(); + + assert_eq!(body.len(), 0); + assert_eq!(response.status(), 303); + assert_eq!(response.headers().get("location").unwrap(), url); + } + + #[tokio::test] + async fn it_creates_see_other_redirect_response_with_custom_headers() { + let url = "https://www.rust-lang.org/"; + let response = see_other!(url, [ + ("x-api-key", "some api key"), + ("x-req-id", "some req id"), + ]); + + assert!(response.is_ok()); + + let mut response = response.unwrap(); + let body = &response.body_mut().collect().await.unwrap().to_bytes(); + + assert_eq!(body.len(), 0); + assert_eq!(response.status(), 303); + assert_eq!(response.headers().get("location").unwrap(), url); + assert_eq!(response.headers().get("x-api-key").unwrap(), "some api key"); + assert_eq!(response.headers().get("x-req-id").unwrap(), "some req id"); + } } \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index b24d54d..609540c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,7 +20,7 @@ //! // Start the server //! let mut app = App::new(); //! -//! // Example of request handler +//! // Example of a request handler //! app.map_get("/hello/{name}", async |name: String| { //! ok!("Hello {name}!") //! }); diff --git a/src/middleware/http_context.rs b/src/middleware/http_context.rs index b23a8b3..7f85d1f 100644 --- a/src/middleware/http_context.rs +++ b/src/middleware/http_context.rs @@ -71,15 +71,15 @@ impl HttpContext { /// Resolves a service from Dependency Container as a clone, service must implement [`Clone`] #[inline] #[cfg(feature = "di")] - pub async fn resolve(&self) -> Result { - self.request.resolve::().await + pub fn resolve(&self) -> Result { + self.request.resolve::() } /// Resolves a service from Dependency Container #[inline] #[cfg(feature = "di")] - pub async fn resolve_shared(&self) -> Result, Error> { - self.request.resolve_shared::().await + pub fn resolve_shared(&self) -> Result, Error> { + self.request.resolve_shared::() } /// Inserts the [`Header`] to HTTP request headers diff --git a/src/tls.rs b/src/tls.rs index 2fc2dcc..5106500 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -568,7 +568,6 @@ impl App { }; self.use_middleware(move |ctx, next| { - let hsts_header = STRICT_TRANSPORT_SECURITY.clone(); let hsts_header_value = hsts_header_value.clone(); let is_excluded = is_excluded.clone(); @@ -584,7 +583,7 @@ impl App { http_result.map(|mut response| { response .headers_mut() - .append(hsts_header, hsts_header_value.parse().unwrap()); + .append(STRICT_TRANSPORT_SECURITY, hsts_header_value.parse().unwrap()); response }) } else {