From f622f12bba84b6c33570591a7ca0f13b96d75e37 Mon Sep 17 00:00:00 2001 From: imbolc Date: Tue, 14 May 2024 15:31:02 +0600 Subject: [PATCH 01/11] Draft --- Cargo.toml | 2 + src/extractors.rs | 12 ++++ src/lib.rs | 3 + src/vary_middleware.rs | 134 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 src/vary_middleware.rs diff --git a/Cargo.toml b/Cargo.toml index 36dea9d..b59509a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ serde = ["dep:serde", "dep:serde_json"] axum-core = "0.4" http = { version = "1.0", default-features = false } async-trait = "0.1" +axum = "0.7" # TODO: remove +tokio = { version = "1", features = ["sync"] } # TODO: hide behind a feature? # Optional dependencies required for the `guards` feature. tower = { version = "0.4", default-features = false, optional = true } diff --git a/src/extractors.rs b/src/extractors.rs index 81ab239..0f53604 100644 --- a/src/extractors.rs +++ b/src/extractors.rs @@ -137,6 +137,12 @@ where type Rejection = std::convert::Infallible; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + use crate::vary_middleware::{HxRequestExtracted, Notifier}; + parts + .extensions + .get_mut::() + .map(Notifier::notify); + if parts.headers.contains_key(HX_REQUEST) { return Ok(HxRequest(true)); } else { @@ -164,6 +170,12 @@ where type Rejection = std::convert::Infallible; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + use crate::vary_middleware::{HxTargetExtracted, Notifier}; + parts + .extensions + .get_mut::() + .map(Notifier::notify); + if let Some(target) = parts.headers.get(HX_TARGET) { if let Ok(target) = target.to_str() { return Ok(HxTarget(Some(target.to_string()))); diff --git a/src/lib.rs b/src/lib.rs index 439ff68..5d4dae1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,3 +22,6 @@ pub use guard::*; pub use headers::*; #[doc(inline)] pub use responders::*; + +pub(crate) mod vary_middleware; +pub use vary_middleware::vary_middleware; diff --git a/src/vary_middleware.rs b/src/vary_middleware.rs new file mode 100644 index 0000000..57a4352 --- /dev/null +++ b/src/vary_middleware.rs @@ -0,0 +1,134 @@ +use crate::{ + headers::{HX_REQUEST_STR, HX_TARGET_STR}, + HxError, +}; +use axum::{extract::Request, middleware::Next, response::Response}; +use axum_core::response::IntoResponse; +use http::{ + header::{HeaderValue, VARY}, + Extensions, +}; +use std::sync::Arc; +use tokio::sync::oneshot::{self, Receiver, Sender}; + +const MIDDLEWARE_DOUBLE_USE: &str = + "Configuration error: `axum_httpx::vary_middleware` is used twice"; + +#[derive(Clone)] +pub(crate) struct HxRequestExtracted(Option>>); + +#[derive(Clone)] +pub(crate) struct HxTargetExtracted(Option>>); + +pub trait Notifier { + fn sender(&mut self) -> Option>; + + fn notify(&mut self) { + if let Some(sender) = self.sender().take() { + sender.send(()).ok(); + } + } +} + +impl Notifier for HxRequestExtracted { + fn sender(&mut self) -> Option> { + self.0.take().and_then(Arc::into_inner) + } +} + +impl Notifier for HxTargetExtracted { + fn sender(&mut self) -> Option> { + self.0.take().and_then(Arc::into_inner) + } +} + +impl HxRequestExtracted { + fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { + let (tx, rx) = oneshot::channel(); + if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { + panic!("{}", MIDDLEWARE_DOUBLE_USE); + } + rx + } +} + +impl HxTargetExtracted { + fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { + let (tx, rx) = oneshot::channel(); + if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { + panic!("{}", MIDDLEWARE_DOUBLE_USE); + } + rx + } +} + +pub async fn vary_middleware(mut request: Request, next: Next) -> Response { + let hx_request_rx = HxRequestExtracted::insert_into_extensions(request.extensions_mut()); + let hx_target_rx = HxTargetExtracted::insert_into_extensions(request.extensions_mut()); + + let mut response = next.run(request).await; + + let mut used = Vec::with_capacity(4); + if hx_request_rx.await.is_ok() { + used.push(HX_REQUEST_STR) + } + if hx_target_rx.await.is_ok() { + used.push(HX_TARGET_STR) + } + + if !used.is_empty() { + let value = match HeaderValue::from_str(&used.join(", ")) { + Ok(x) => x, + Err(e) => return HxError::from(e).into_response(), + }; + if let Err(e) = response.headers_mut().try_append(VARY, value) { + return HxError::from(e).into_response(); + } + } + + response +} + +#[cfg(test)] +mod tests { + use crate::{HxRequest, HxTarget}; + use axum::{routing::get, Router}; + + use super::*; + + fn vary_headers(resp: &axum_test::TestResponse) -> Vec { + resp.iter_headers_by_name("vary").cloned().collect() + } + + #[tokio::test] + async fn multiple_headers() { + let app = Router::new() + .route("/no-extractors", get(|| async { () })) + .route("/single-extractor", get(|_: HxRequest| async { () })) + // Extractors can be used multiple times e.g. in middlewares + .route( + "/repeated-extractor", + get(|_: HxRequest, _: HxRequest| async { () }), + ) + .route( + "/multiple-extractors", + get(|_: HxRequest, _: HxTarget| async { () }), + ) + .layer(axum::middleware::from_fn(vary_middleware)); + let server = axum_test::TestServer::new(app).unwrap(); + + assert!(vary_headers(&server.get("/no-extractors").await).is_empty()); + assert_eq!( + vary_headers(&server.get("/single-extractor").await), + [HX_REQUEST_STR] + ); + assert_eq!( + vary_headers(&server.get("/repeated-extractor").await), + [HX_REQUEST_STR] + ); + assert_eq!( + vary_headers(&server.get("/multiple-extractors").await), + [format!("{HX_REQUEST_STR}, {HX_TARGET_STR}")] + ); + } +} From c6f8bcb504520a49a237b4c904f4931069dfc76f Mon Sep 17 00:00:00 2001 From: imbolc Date: Tue, 14 May 2024 16:40:29 +0600 Subject: [PATCH 02/11] cargo fmt --- src/vary_middleware.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/vary_middleware.rs b/src/vary_middleware.rs index 57a4352..ccf3080 100644 --- a/src/vary_middleware.rs +++ b/src/vary_middleware.rs @@ -1,16 +1,18 @@ -use crate::{ - headers::{HX_REQUEST_STR, HX_TARGET_STR}, - HxError, -}; +use std::sync::Arc; + use axum::{extract::Request, middleware::Next, response::Response}; use axum_core::response::IntoResponse; use http::{ header::{HeaderValue, VARY}, Extensions, }; -use std::sync::Arc; use tokio::sync::oneshot::{self, Receiver, Sender}; +use crate::{ + headers::{HX_REQUEST_STR, HX_TARGET_STR}, + HxError, +}; + const MIDDLEWARE_DOUBLE_USE: &str = "Configuration error: `axum_httpx::vary_middleware` is used twice"; @@ -91,10 +93,10 @@ pub async fn vary_middleware(mut request: Request, next: Next) -> Response { #[cfg(test)] mod tests { - use crate::{HxRequest, HxTarget}; use axum::{routing::get, Router}; use super::*; + use crate::{HxRequest, HxTarget}; fn vary_headers(resp: &axum_test::TestResponse) -> Vec { resp.iter_headers_by_name("vary").cloned().collect() From 5220c8b8616641441884eb2f1b8c23adedcd5f04 Mon Sep 17 00:00:00 2001 From: imbolc Date: Sat, 15 Jun 2024 13:18:39 +0600 Subject: [PATCH 03/11] The remaining extractors --- src/extractors.rs | 20 ++++--- src/vary_middleware.rs | 118 +++++++++++++++++++++++++++++++++++------ 2 files changed, 117 insertions(+), 21 deletions(-) diff --git a/src/extractors.rs b/src/extractors.rs index 0f53604..7a90f11 100644 --- a/src/extractors.rs +++ b/src/extractors.rs @@ -137,11 +137,10 @@ where type Rejection = std::convert::Infallible; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { - use crate::vary_middleware::{HxRequestExtracted, Notifier}; parts .extensions - .get_mut::() - .map(Notifier::notify); + .get_mut::() + .map(crate::vary_middleware::Notifier::notify); if parts.headers.contains_key(HX_REQUEST) { return Ok(HxRequest(true)); @@ -170,11 +169,10 @@ where type Rejection = std::convert::Infallible; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { - use crate::vary_middleware::{HxTargetExtracted, Notifier}; parts .extensions - .get_mut::() - .map(Notifier::notify); + .get_mut::() + .map(crate::vary_middleware::Notifier::notify); if let Some(target) = parts.headers.get(HX_TARGET) { if let Ok(target) = target.to_str() { @@ -205,6 +203,11 @@ where type Rejection = std::convert::Infallible; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + parts + .extensions + .get_mut::() + .map(crate::vary_middleware::Notifier::notify); + if let Some(trigger_name) = parts.headers.get(HX_TRIGGER_NAME) { if let Ok(trigger_name) = trigger_name.to_str() { return Ok(HxTriggerName(Some(trigger_name.to_string()))); @@ -234,6 +237,11 @@ where type Rejection = std::convert::Infallible; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + parts + .extensions + .get_mut::() + .map(crate::vary_middleware::Notifier::notify); + if let Some(trigger) = parts.headers.get(HX_TRIGGER) { if let Ok(trigger) = trigger.to_str() { return Ok(HxTrigger(Some(trigger.to_string()))); diff --git a/src/vary_middleware.rs b/src/vary_middleware.rs index ccf3080..9349008 100644 --- a/src/vary_middleware.rs +++ b/src/vary_middleware.rs @@ -9,7 +9,7 @@ use http::{ use tokio::sync::oneshot::{self, Receiver, Sender}; use crate::{ - headers::{HX_REQUEST_STR, HX_TARGET_STR}, + headers::{HX_REQUEST_STR, HX_TARGET_STR, HX_TRIGGER_NAME_STR, HX_TRIGGER_STR}, HxError, }; @@ -22,6 +22,12 @@ pub(crate) struct HxRequestExtracted(Option>>); #[derive(Clone)] pub(crate) struct HxTargetExtracted(Option>>); +#[derive(Clone)] +pub(crate) struct HxTriggerExtracted(Option>>); + +#[derive(Clone)] +pub(crate) struct HxTriggerNameExtracted(Option>>); + pub trait Notifier { fn sender(&mut self) -> Option>; @@ -44,6 +50,18 @@ impl Notifier for HxTargetExtracted { } } +impl Notifier for HxTriggerExtracted { + fn sender(&mut self) -> Option> { + self.0.take().and_then(Arc::into_inner) + } +} + +impl Notifier for HxTriggerNameExtracted { + fn sender(&mut self) -> Option> { + self.0.take().and_then(Arc::into_inner) + } +} + impl HxRequestExtracted { fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { let (tx, rx) = oneshot::channel(); @@ -64,9 +82,32 @@ impl HxTargetExtracted { } } +impl HxTriggerExtracted { + fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { + let (tx, rx) = oneshot::channel(); + if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { + panic!("{}", MIDDLEWARE_DOUBLE_USE); + } + rx + } +} + +impl HxTriggerNameExtracted { + fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { + let (tx, rx) = oneshot::channel(); + if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { + panic!("{}", MIDDLEWARE_DOUBLE_USE); + } + rx + } +} + pub async fn vary_middleware(mut request: Request, next: Next) -> Response { let hx_request_rx = HxRequestExtracted::insert_into_extensions(request.extensions_mut()); let hx_target_rx = HxTargetExtracted::insert_into_extensions(request.extensions_mut()); + let hx_trigger_rx = HxTriggerExtracted::insert_into_extensions(request.extensions_mut()); + let hx_trigger_name_rx = + HxTriggerNameExtracted::insert_into_extensions(request.extensions_mut()); let mut response = next.run(request).await; @@ -77,6 +118,12 @@ pub async fn vary_middleware(mut request: Request, next: Next) -> Response { if hx_target_rx.await.is_ok() { used.push(HX_TARGET_STR) } + if hx_trigger_rx.await.is_ok() { + used.push(HX_TRIGGER_STR) + } + if hx_trigger_name_rx.await.is_ok() { + used.push(HX_TRIGGER_NAME_STR) + } if !used.is_empty() { let value = match HeaderValue::from_str(&used.join(", ")) { @@ -96,41 +143,82 @@ mod tests { use axum::{routing::get, Router}; use super::*; - use crate::{HxRequest, HxTarget}; + use crate::{HxRequest, HxTarget, HxTrigger, HxTriggerName}; fn vary_headers(resp: &axum_test::TestResponse) -> Vec { resp.iter_headers_by_name("vary").cloned().collect() } - #[tokio::test] - async fn multiple_headers() { + fn server() -> axum_test::TestServer { let app = Router::new() .route("/no-extractors", get(|| async { () })) - .route("/single-extractor", get(|_: HxRequest| async { () })) - // Extractors can be used multiple times e.g. in middlewares + .route("/hx-request", get(|_: HxRequest| async { () })) + .route("/hx-target", get(|_: HxTarget| async { () })) + .route("/hx-trigger", get(|_: HxTrigger| async { () })) + .route("/hx-trigger-name", get(|_: HxTriggerName| async { () })) .route( "/repeated-extractor", get(|_: HxRequest, _: HxRequest| async { () }), ) .route( "/multiple-extractors", - get(|_: HxRequest, _: HxTarget| async { () }), + get(|_: HxRequest, _: HxTarget, _: HxTrigger, _: HxTriggerName| async { () }), ) .layer(axum::middleware::from_fn(vary_middleware)); - let server = axum_test::TestServer::new(app).unwrap(); + axum_test::TestServer::new(app).unwrap() + } - assert!(vary_headers(&server.get("/no-extractors").await).is_empty()); + #[tokio::test] + async fn no_extractors() { + assert!(vary_headers(&server().get("/no-extractors").await).is_empty()); + } + + #[tokio::test] + async fn single_hx_request() { assert_eq!( - vary_headers(&server.get("/single-extractor").await), - [HX_REQUEST_STR] + vary_headers(&server().get("/hx-request").await), + ["hx-request"] ); + } + + #[tokio::test] + async fn single_hx_target() { assert_eq!( - vary_headers(&server.get("/repeated-extractor").await), - [HX_REQUEST_STR] + vary_headers(&server().get("/hx-target").await), + ["hx-target"] ); + } + + #[tokio::test] + async fn single_hx_trigger() { assert_eq!( - vary_headers(&server.get("/multiple-extractors").await), - [format!("{HX_REQUEST_STR}, {HX_TARGET_STR}")] + vary_headers(&server().get("/hx-trigger").await), + ["hx-trigger"] + ); + } + + #[tokio::test] + async fn single_hx_trigger_name() { + assert_eq!( + vary_headers(&server().get("/hx-trigger-name").await), + ["hx-trigger-name"] + ); + } + + #[tokio::test] + async fn repeated_extractor() { + assert_eq!( + vary_headers(&server().get("/repeated-extractor").await), + ["hx-request"] + ); + } + + // Extractors can be used multiple times e.g. in middlewares + #[tokio::test] + async fn multiple_extractors() { + assert_eq!( + vary_headers(&server().get("/multiple-extractors").await), + ["hx-request, hx-target, hx-trigger, hx-trigger-name"], ); } } From cdbd892f85f88120821d0fff423f6caaf6ab6cb8 Mon Sep 17 00:00:00 2001 From: imbolc Date: Sat, 15 Jun 2024 13:34:27 +0600 Subject: [PATCH 04/11] Define notifiers with macro rules --- src/vary_middleware.rs | 100 ++++++++++++----------------------------- 1 file changed, 28 insertions(+), 72 deletions(-) diff --git a/src/vary_middleware.rs b/src/vary_middleware.rs index 9349008..0904c16 100644 --- a/src/vary_middleware.rs +++ b/src/vary_middleware.rs @@ -16,18 +16,6 @@ use crate::{ const MIDDLEWARE_DOUBLE_USE: &str = "Configuration error: `axum_httpx::vary_middleware` is used twice"; -#[derive(Clone)] -pub(crate) struct HxRequestExtracted(Option>>); - -#[derive(Clone)] -pub(crate) struct HxTargetExtracted(Option>>); - -#[derive(Clone)] -pub(crate) struct HxTriggerExtracted(Option>>); - -#[derive(Clone)] -pub(crate) struct HxTriggerNameExtracted(Option>>); - pub trait Notifier { fn sender(&mut self) -> Option>; @@ -38,69 +26,37 @@ pub trait Notifier { } } -impl Notifier for HxRequestExtracted { - fn sender(&mut self) -> Option> { - self.0.take().and_then(Arc::into_inner) +macro_rules! define_notifiers { + ($($name:ident),*) => { + $( + #[derive(Clone)] + pub(crate) struct $name(Option>>); + + impl Notifier for $name { + fn sender(&mut self) -> Option> { + self.0.take().and_then(Arc::into_inner) + } + } + + impl $name { + fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { + let (tx, rx) = oneshot::channel(); + if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { + panic!("{}", MIDDLEWARE_DOUBLE_USE); + } + rx + } + } + )* } } -impl Notifier for HxTargetExtracted { - fn sender(&mut self) -> Option> { - self.0.take().and_then(Arc::into_inner) - } -} - -impl Notifier for HxTriggerExtracted { - fn sender(&mut self) -> Option> { - self.0.take().and_then(Arc::into_inner) - } -} - -impl Notifier for HxTriggerNameExtracted { - fn sender(&mut self) -> Option> { - self.0.take().and_then(Arc::into_inner) - } -} - -impl HxRequestExtracted { - fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { - let (tx, rx) = oneshot::channel(); - if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { - panic!("{}", MIDDLEWARE_DOUBLE_USE); - } - rx - } -} - -impl HxTargetExtracted { - fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { - let (tx, rx) = oneshot::channel(); - if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { - panic!("{}", MIDDLEWARE_DOUBLE_USE); - } - rx - } -} - -impl HxTriggerExtracted { - fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { - let (tx, rx) = oneshot::channel(); - if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { - panic!("{}", MIDDLEWARE_DOUBLE_USE); - } - rx - } -} - -impl HxTriggerNameExtracted { - fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { - let (tx, rx) = oneshot::channel(); - if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { - panic!("{}", MIDDLEWARE_DOUBLE_USE); - } - rx - } -} +define_notifiers!( + HxRequestExtracted, + HxTargetExtracted, + HxTriggerExtracted, + HxTriggerNameExtracted +); pub async fn vary_middleware(mut request: Request, next: Next) -> Response { let hx_request_rx = HxRequestExtracted::insert_into_extensions(request.extensions_mut()); From 535b19fff841cb22d9729696d1c095d4ecd24246 Mon Sep 17 00:00:00 2001 From: imbolc Date: Sat, 15 Jun 2024 13:40:22 +0600 Subject: [PATCH 05/11] Move insert_into_extensions into Notifier trait --- src/vary_middleware.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vary_middleware.rs b/src/vary_middleware.rs index 0904c16..2146643 100644 --- a/src/vary_middleware.rs +++ b/src/vary_middleware.rs @@ -24,6 +24,8 @@ pub trait Notifier { sender.send(()).ok(); } } + + fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()>; } macro_rules! define_notifiers { @@ -36,9 +38,7 @@ macro_rules! define_notifiers { fn sender(&mut self) -> Option> { self.0.take().and_then(Arc::into_inner) } - } - impl $name { fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { let (tx, rx) = oneshot::channel(); if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { From 693707bdface70afbe67f63b531bed67b22fe8f6 Mon Sep 17 00:00:00 2001 From: imbolc Date: Sat, 15 Jun 2024 14:08:13 +0600 Subject: [PATCH 06/11] Optimize middleware --- Cargo.toml | 1 + src/vary_middleware.rs | 56 ++++++++++++++++++++++-------------------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b59509a..34cfd32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ http = { version = "1.0", default-features = false } async-trait = "0.1" axum = "0.7" # TODO: remove tokio = { version = "1", features = ["sync"] } # TODO: hide behind a feature? +futures = "0.3" # TODO # Optional dependencies required for the `guards` feature. tower = { version = "0.4", default-features = false, optional = true } diff --git a/src/vary_middleware.rs b/src/vary_middleware.rs index 2146643..84c614b 100644 --- a/src/vary_middleware.rs +++ b/src/vary_middleware.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use axum::{extract::Request, middleware::Next, response::Response}; use axum_core::response::IntoResponse; +use futures::future::join_all; use http::{ header::{HeaderValue, VARY}, Extensions, @@ -25,7 +26,7 @@ pub trait Notifier { } } - fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()>; + fn insert(extensions: &mut Extensions) -> Receiver<()>; } macro_rules! define_notifiers { @@ -39,7 +40,7 @@ macro_rules! define_notifiers { self.0.take().and_then(Arc::into_inner) } - fn insert_into_extensions(extensions: &mut Extensions) -> Receiver<()> { + fn insert(extensions: &mut Extensions) -> Receiver<()> { let (tx, rx) = oneshot::channel(); if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { panic!("{}", MIDDLEWARE_DOUBLE_USE); @@ -59,36 +60,37 @@ define_notifiers!( ); pub async fn vary_middleware(mut request: Request, next: Next) -> Response { - let hx_request_rx = HxRequestExtracted::insert_into_extensions(request.extensions_mut()); - let hx_target_rx = HxTargetExtracted::insert_into_extensions(request.extensions_mut()); - let hx_trigger_rx = HxTriggerExtracted::insert_into_extensions(request.extensions_mut()); - let hx_trigger_name_rx = - HxTriggerNameExtracted::insert_into_extensions(request.extensions_mut()); + let exts = request.extensions_mut(); + let rx_header = [ + (HxRequestExtracted::insert(exts), HX_REQUEST_STR), + (HxTargetExtracted::insert(exts), HX_TARGET_STR), + (HxTriggerExtracted::insert(exts), HX_TRIGGER_STR), + (HxTriggerNameExtracted::insert(exts), HX_TRIGGER_NAME_STR), + ]; let mut response = next.run(request).await; - let mut used = Vec::with_capacity(4); - if hx_request_rx.await.is_ok() { - used.push(HX_REQUEST_STR) - } - if hx_target_rx.await.is_ok() { - used.push(HX_TARGET_STR) - } - if hx_trigger_rx.await.is_ok() { - used.push(HX_TRIGGER_STR) - } - if hx_trigger_name_rx.await.is_ok() { - used.push(HX_TRIGGER_NAME_STR) + let used_headers: Vec<_> = join_all( + rx_header + .into_iter() + .map(|(rx, header)| async move { rx.await.ok().map(|_| header) }), + ) + .await + .into_iter() + .flatten() + .collect(); + + if used_headers.is_empty() { + return response; } - if !used.is_empty() { - let value = match HeaderValue::from_str(&used.join(", ")) { - Ok(x) => x, - Err(e) => return HxError::from(e).into_response(), - }; - if let Err(e) = response.headers_mut().try_append(VARY, value) { - return HxError::from(e).into_response(); - } + let value = match HeaderValue::from_str(&used_headers.join(", ")) { + Ok(x) => x, + Err(e) => return HxError::from(e).into_response(), + }; + + if let Err(e) = response.headers_mut().try_append(VARY, value) { + return HxError::from(e).into_response(); } response From cbfe4782d8edc8da351c2f7b34c9afa6f97824bc Mon Sep 17 00:00:00 2001 From: imbolc Date: Sat, 15 Jun 2024 14:39:07 +0600 Subject: [PATCH 07/11] Up axum-test --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 34cfd32..b39863e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ serde_json = { version = "1", optional = true } [dev-dependencies] axum = { version = "0.7", default-features = false } -axum-test = "14" +axum-test = "15" tokio = { version = "1", features = ["full"] } tokio-test = "0.4" From 57e3e067b1c9c4084af8da6f4e2c5e19271fd72c Mon Sep 17 00:00:00 2001 From: imbolc Date: Sat, 15 Jun 2024 15:18:49 +0600 Subject: [PATCH 08/11] Feature --- Cargo.toml | 9 ++++++--- src/{vary_middleware.rs => auto_vary.rs} | 6 +++--- src/extractors.rs | 20 ++++++++++++-------- src/lib.rs | 4 ++-- 4 files changed, 23 insertions(+), 16 deletions(-) rename src/{vary_middleware.rs => auto_vary.rs} (96%) diff --git a/Cargo.toml b/Cargo.toml index b39863e..c2c3c59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,14 +15,17 @@ default = [] unstable = [] guards = ["tower", "futures-core", "pin-project-lite"] serde = ["dep:serde", "dep:serde_json"] +auto-vary = ["axum", "futures", "tokio"] [dependencies] axum-core = "0.4" http = { version = "1.0", default-features = false } async-trait = "0.1" -axum = "0.7" # TODO: remove -tokio = { version = "1", features = ["sync"] } # TODO: hide behind a feature? -futures = "0.3" # TODO + +# Optional dependencies required for the `auto-vary` feature. +axum = { version = "0.7", default-features = false, optional = true } +tokio = { version = "1", features = ["sync"], optional = true } +futures = { version = "0.3", default-features = false, optional = true } # Optional dependencies required for the `guards` feature. tower = { version = "0.4", default-features = false, optional = true } diff --git a/src/vary_middleware.rs b/src/auto_vary.rs similarity index 96% rename from src/vary_middleware.rs rename to src/auto_vary.rs index 84c614b..fd3b609 100644 --- a/src/vary_middleware.rs +++ b/src/auto_vary.rs @@ -17,7 +17,7 @@ use crate::{ const MIDDLEWARE_DOUBLE_USE: &str = "Configuration error: `axum_httpx::vary_middleware` is used twice"; -pub trait Notifier { +pub(crate) trait Notifier { fn sender(&mut self) -> Option>; fn notify(&mut self) { @@ -59,7 +59,7 @@ define_notifiers!( HxTriggerNameExtracted ); -pub async fn vary_middleware(mut request: Request, next: Next) -> Response { +pub async fn middleware(mut request: Request, next: Next) -> Response { let exts = request.extensions_mut(); let rx_header = [ (HxRequestExtracted::insert(exts), HX_REQUEST_STR), @@ -122,7 +122,7 @@ mod tests { "/multiple-extractors", get(|_: HxRequest, _: HxTarget, _: HxTrigger, _: HxTriggerName| async { () }), ) - .layer(axum::middleware::from_fn(vary_middleware)); + .layer(axum::middleware::from_fn(middleware)); axum_test::TestServer::new(app).unwrap() } diff --git a/src/extractors.rs b/src/extractors.rs index 7a90f11..5f25683 100644 --- a/src/extractors.rs +++ b/src/extractors.rs @@ -137,10 +137,11 @@ where type Rejection = std::convert::Infallible; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + #[cfg(feature = "auto-vary")] parts .extensions - .get_mut::() - .map(crate::vary_middleware::Notifier::notify); + .get_mut::() + .map(crate::auto_vary::Notifier::notify); if parts.headers.contains_key(HX_REQUEST) { return Ok(HxRequest(true)); @@ -169,10 +170,11 @@ where type Rejection = std::convert::Infallible; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + #[cfg(feature = "auto-vary")] parts .extensions - .get_mut::() - .map(crate::vary_middleware::Notifier::notify); + .get_mut::() + .map(crate::auto_vary::Notifier::notify); if let Some(target) = parts.headers.get(HX_TARGET) { if let Ok(target) = target.to_str() { @@ -203,10 +205,11 @@ where type Rejection = std::convert::Infallible; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + #[cfg(feature = "auto-vary")] parts .extensions - .get_mut::() - .map(crate::vary_middleware::Notifier::notify); + .get_mut::() + .map(crate::auto_vary::Notifier::notify); if let Some(trigger_name) = parts.headers.get(HX_TRIGGER_NAME) { if let Ok(trigger_name) = trigger_name.to_str() { @@ -237,10 +240,11 @@ where type Rejection = std::convert::Infallible; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + #[cfg(feature = "auto-vary")] parts .extensions - .get_mut::() - .map(crate::vary_middleware::Notifier::notify); + .get_mut::() + .map(crate::auto_vary::Notifier::notify); if let Some(trigger) = parts.headers.get(HX_TRIGGER) { if let Ok(trigger) = trigger.to_str() { diff --git a/src/lib.rs b/src/lib.rs index 5d4dae1..c0dc625 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,5 +23,5 @@ pub use headers::*; #[doc(inline)] pub use responders::*; -pub(crate) mod vary_middleware; -pub use vary_middleware::vary_middleware; +#[cfg(feature = "auto-vary")] +pub mod auto_vary; From 386662b0a8497ee65980a0d71cb94bc88b34d549 Mon Sep 17 00:00:00 2001 From: imbolc Date: Sat, 15 Jun 2024 17:09:52 +0600 Subject: [PATCH 09/11] Middleware as tower layer --- Cargo.toml | 11 +++-- src/auto_vary.rs | 107 +++++++++++++++++++++++++++++++---------------- src/lib.rs | 10 +++-- 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c2c3c59..01fb0eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,18 +15,13 @@ default = [] unstable = [] guards = ["tower", "futures-core", "pin-project-lite"] serde = ["dep:serde", "dep:serde_json"] -auto-vary = ["axum", "futures", "tokio"] +auto-vary = ["futures", "tokio", "tower"] [dependencies] axum-core = "0.4" http = { version = "1.0", default-features = false } async-trait = "0.1" -# Optional dependencies required for the `auto-vary` feature. -axum = { version = "0.7", default-features = false, optional = true } -tokio = { version = "1", features = ["sync"], optional = true } -futures = { version = "0.3", default-features = false, optional = true } - # Optional dependencies required for the `guards` feature. tower = { version = "0.4", default-features = false, optional = true } futures-core = { version = "0.3", optional = true } @@ -36,6 +31,10 @@ pin-project-lite = { version = "0.2", optional = true } serde = { version = "1", features = ["derive"], optional = true } serde_json = { version = "1", optional = true } +# Optional dependencies required for the `auto-vary` feature. +tokio = { version = "1", features = ["sync"], optional = true } +futures = { version = "0.3", default-features = false, optional = true } + [dev-dependencies] axum = { version = "0.7", default-features = false } axum-test = "15" diff --git a/src/auto_vary.rs b/src/auto_vary.rs index fd3b609..c48bc36 100644 --- a/src/auto_vary.rs +++ b/src/auto_vary.rs @@ -1,13 +1,19 @@ -use std::sync::Arc; +use std::{ + sync::Arc, + task::{Context, Poll}, +}; -use axum::{extract::Request, middleware::Next, response::Response}; -use axum_core::response::IntoResponse; -use futures::future::join_all; +use axum_core::{ + extract::Request, + response::{IntoResponse, Response}, +}; +use futures::future::{join_all, BoxFuture}; use http::{ header::{HeaderValue, VARY}, Extensions, }; use tokio::sync::oneshot::{self, Receiver, Sender}; +use tower::{Layer, Service}; use crate::{ headers::{HX_REQUEST_STR, HX_TARGET_STR, HX_TRIGGER_NAME_STR, HX_TRIGGER_STR}, @@ -59,41 +65,72 @@ define_notifiers!( HxTriggerNameExtracted ); -pub async fn middleware(mut request: Request, next: Next) -> Response { - let exts = request.extensions_mut(); - let rx_header = [ - (HxRequestExtracted::insert(exts), HX_REQUEST_STR), - (HxTargetExtracted::insert(exts), HX_TARGET_STR), - (HxTriggerExtracted::insert(exts), HX_TRIGGER_STR), - (HxTriggerNameExtracted::insert(exts), HX_TRIGGER_NAME_STR), - ]; +#[derive(Default, Clone)] +pub struct AutoVaryLayer; - let mut response = next.run(request).await; +impl Layer for AutoVaryLayer { + type Service = AutoVaryMiddleware; - let used_headers: Vec<_> = join_all( - rx_header + fn layer(&self, inner: S) -> Self::Service { + AutoVaryMiddleware { inner } + } +} + +#[derive(Clone)] +pub struct AutoVaryMiddleware { + inner: S, +} + +impl Service for AutoVaryMiddleware +where + S: Service + Send + 'static, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut request: Request) -> Self::Future { + let exts = request.extensions_mut(); + let rx_header = [ + (HxRequestExtracted::insert(exts), HX_REQUEST_STR), + (HxTargetExtracted::insert(exts), HX_TARGET_STR), + (HxTriggerExtracted::insert(exts), HX_TRIGGER_STR), + (HxTriggerNameExtracted::insert(exts), HX_TRIGGER_NAME_STR), + ]; + let future = self.inner.call(request); + Box::pin(async move { + let mut response: Response = future.await?; + let used_headers: Vec<_> = join_all( + rx_header + .into_iter() + .map(|(rx, header)| async move { rx.await.ok().map(|_| header) }), + ) + .await .into_iter() - .map(|(rx, header)| async move { rx.await.ok().map(|_| header) }), - ) - .await - .into_iter() - .flatten() - .collect(); + .flatten() + .collect(); - if used_headers.is_empty() { - return response; + if used_headers.is_empty() { + return Ok(response); + } + + let value = match HeaderValue::from_str(&used_headers.join(", ")) { + Ok(x) => x, + Err(e) => return Ok(HxError::from(e).into_response()), + }; + + if let Err(e) = response.headers_mut().try_append(VARY, value) { + return Ok(HxError::from(e).into_response()); + } + + Ok(response) + }) } - - let value = match HeaderValue::from_str(&used_headers.join(", ")) { - Ok(x) => x, - Err(e) => return HxError::from(e).into_response(), - }; - - if let Err(e) = response.headers_mut().try_append(VARY, value) { - return HxError::from(e).into_response(); - } - - response } #[cfg(test)] @@ -122,7 +159,7 @@ mod tests { "/multiple-extractors", get(|_: HxRequest, _: HxTarget, _: HxTrigger, _: HxTriggerName| async { () }), ) - .layer(axum::middleware::from_fn(middleware)); + .layer(AutoVaryLayer); axum_test::TestServer::new(app).unwrap() } diff --git a/src/lib.rs b/src/lib.rs index c0dc625..7560074 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,9 @@ mod error; pub use error::*; +#[cfg(feature = "auto-vary")] +#[cfg_attr(feature = "unstable", doc(cfg(feature = "auto-vary")))] +mod auto_vary; pub mod extractors; #[cfg(feature = "guards")] #[cfg_attr(feature = "unstable", doc(cfg(feature = "guards")))] @@ -12,6 +15,10 @@ pub mod guard; pub mod headers; pub mod responders; +#[cfg(feature = "auto-vary")] +#[cfg_attr(feature = "unstable", doc(cfg(feature = "auto-vary")))] +#[doc(inline)] +pub use auto_vary::AutoVaryLayer; #[doc(inline)] pub use extractors::*; #[cfg(feature = "guards")] @@ -22,6 +29,3 @@ pub use guard::*; pub use headers::*; #[doc(inline)] pub use responders::*; - -#[cfg(feature = "auto-vary")] -pub mod auto_vary; From 13fb55de9a7a5c7b066b2d7166fdc74c2caa96fa Mon Sep 17 00:00:00 2001 From: imbolc Date: Sun, 16 Jun 2024 12:43:14 +0600 Subject: [PATCH 10/11] Example --- examples/auto-vary.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 examples/auto-vary.rs diff --git a/examples/auto-vary.rs b/examples/auto-vary.rs new file mode 100644 index 0000000..b7868d5 --- /dev/null +++ b/examples/auto-vary.rs @@ -0,0 +1,40 @@ +//! Using `auto-vary` middleware +//! +//! Don't forget about the feature while running it: +//! `cargo run --features auto-vary --example auto-vary` +use std::time::Duration; + +use axum::{response::Html, routing::get, serve, Router}; +use axum_htmx::{AutoVaryLayer, HxRequest}; +use tokio::{net::TcpListener, time::sleep}; + +#[tokio::main] +async fn main() { + let app = Router::new() + .route("/", get(handler)) + // Add the middleware + .layer(AutoVaryLayer); + + let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap(); + serve(listener, app).await.unwrap(); +} + +// Our handler differentiates full-page GET requests from HTMx-based ones by looking at the `hx-request` +// requestheader. +// +// The middleware sees the usage of the `HxRequest` extractor and automatically adds the +// `Vary: hx-request` response header. +async fn handler(HxRequest(hx_request): HxRequest) -> Html<&'static str> { + if hx_request { + // For HTMx-based GET request, it returns a partial page update + sleep(Duration::from_secs(3)).await; + return Html("HTMx response"); + } + // While for a normal GET request, it returns the whole page + Html( + r#" + +

Loading ...

+ "#, + ) +} From 39e6f9028bf4a543ef78cb07446e9ad33f0a9ce3 Mon Sep 17 00:00:00 2001 From: imbolc Date: Sun, 16 Jun 2024 12:43:22 +0600 Subject: [PATCH 11/11] Docs --- README.md | 36 ++++++++++++++++++++++++++++++------ src/auto_vary.rs | 25 +++++++++++++++++-------- src/lib.rs | 4 ++-- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a7a3ee3..bee8226 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - [Getting Started](#getting-started) - [Extractors](#extractors) - [Responders](#responders) + - [Auto Caching Management](#auto-caching-management) - [Request Guards](#request-guards) - [Examples](#examples) - [Example: Extractors](#example-extractors) @@ -76,6 +77,8 @@ any of your responses. | `HX-Trigger-After-Settle` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` | | `HX-Trigger-After-Swap` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` | +### Vary Responders + Also, there are corresponding cache-related headers, which you may want to add to `GET` responses, depending on the htmx headers. @@ -85,7 +88,7 @@ you need to add `Vary: HX-Request`. That causes the cache to be keyed based on a composite of the response URL and the `HX-Request` request header - rather than being based just on the response URL._ -Refer to [caching htmx docs section](https://htmx.org/docs/#caching) for details. +Refer to [caching htmx docs section][htmx-caching] for details. | Header | Responder | |-------------------------|---------------------| @@ -94,10 +97,27 @@ Refer to [caching htmx docs section](https://htmx.org/docs/#caching) for details | `Vary: HX-Trigger` | `VaryHxTrigger` | | `Vary: HX-Trigger-Name` | `VaryHxTriggerName` | +Look at the [Auto Caching Management](#auto-caching-management) section for +automatic `Vary` headers management. + +## Auto Caching Management + +__Requires feature `auto-vary`.__ + +Manual use of [Vary Reponders](#vary-responders) adds fragility to the code, +because of the need to manually control correspondence between used extractors +and the responders. + +We provide a [middleware](crate::AutoVaryLayer) to address this issue by +automatically adding `Vary` headers when corresponding extractors are used. +For example, on extracting [`HxRequest`], the middleware automatically adds +`Vary: hx-request` header to the response. + +Look at the usage [example][auto-vary-example]. ## Request Guards -__Requires features `guards`.__ +__Requires feature `guards`.__ In addition to the extractors, there is also a route-wide layer request guard for the `HX-Request` header. This will redirect any requests without the header @@ -207,10 +227,11 @@ fn router_two() -> Router { ## Feature Flags -| Flag | Default | Description | Dependencies | -|----------|----------|------------------------------------------------------------|---------------------------------------------| -| `guards` | Disabled | Adds request guard layers. | `tower`, `futures-core`, `pin-project-lite` | -| `serde` | Disabled | Adds serde support for the `HxEvent` and `LocationOptions` | `serde`, `serde_json` | +| Flag | Default | Description | Dependencies | +|-------------|----------|------------------------------------------------------------|---------------------------------------------| +| `auto-vary` | Disabled | A middleware to address [HTMx caching issue][htmx-caching] | `futures`, `tokio`, `tower` | +| `guards` | Disabled | Adds request guard layers. | `tower`, `futures-core`, `pin-project-lite` | +| `serde` | Disabled | Adds serde support for the `HxEvent` and `LocationOptions` | `serde`, `serde_json` | ## Contributing @@ -233,3 +254,6 @@ cargo +nightly test --all-features - **[Apache License, Version 2.0](/LICENSE-APACHE)** at your option. + +[htmx-caching]: https://htmx.org/docs/#caching +[auto-vary-example]: https://github.com/robertwayne/axum-htmx/blob/main/examples/auto-vary.rs diff --git a/src/auto_vary.rs b/src/auto_vary.rs index c48bc36..c8c0729 100644 --- a/src/auto_vary.rs +++ b/src/auto_vary.rs @@ -1,3 +1,6 @@ +//! A middleware to automatically add a `Vary` header when needed to address +//! [HTMx caching issue](https://htmx.org/docs/#caching) + use std::{ sync::Arc, task::{Context, Poll}, @@ -19,10 +22,24 @@ use crate::{ headers::{HX_REQUEST_STR, HX_TARGET_STR, HX_TRIGGER_NAME_STR, HX_TRIGGER_STR}, HxError, }; +#[cfg(doc)] +use crate::{HxRequest, HxTarget, HxTrigger, HxTriggerName}; const MIDDLEWARE_DOUBLE_USE: &str = "Configuration error: `axum_httpx::vary_middleware` is used twice"; +/// Addresses [HTMx caching issue](https://htmx.org/docs/#caching) +/// by automatically adding a corresponding `Vary` header when [`HxRequest`], [`HxTarget`], +/// [`HxTrigger`], [`HxTriggerName`] or their combination is used. +#[derive(Clone)] +pub struct AutoVaryLayer; + +/// Tower service for [`AutoVaryLayer`] +#[derive(Clone)] +pub struct AutoVaryMiddleware { + inner: S, +} + pub(crate) trait Notifier { fn sender(&mut self) -> Option>; @@ -65,9 +82,6 @@ define_notifiers!( HxTriggerNameExtracted ); -#[derive(Default, Clone)] -pub struct AutoVaryLayer; - impl Layer for AutoVaryLayer { type Service = AutoVaryMiddleware; @@ -76,11 +90,6 @@ impl Layer for AutoVaryLayer { } } -#[derive(Clone)] -pub struct AutoVaryMiddleware { - inner: S, -} - impl Service for AutoVaryMiddleware where S: Service + Send + 'static, diff --git a/src/lib.rs b/src/lib.rs index 7560074..fc1bd36 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,7 +7,7 @@ pub use error::*; #[cfg(feature = "auto-vary")] #[cfg_attr(feature = "unstable", doc(cfg(feature = "auto-vary")))] -mod auto_vary; +pub mod auto_vary; pub mod extractors; #[cfg(feature = "guards")] #[cfg_attr(feature = "unstable", doc(cfg(feature = "guards")))] @@ -18,7 +18,7 @@ pub mod responders; #[cfg(feature = "auto-vary")] #[cfg_attr(feature = "unstable", doc(cfg(feature = "auto-vary")))] #[doc(inline)] -pub use auto_vary::AutoVaryLayer; +pub use auto_vary::*; #[doc(inline)] pub use extractors::*; #[cfg(feature = "guards")]