diff --git a/Cargo.toml b/Cargo.toml index d3c3655..8866016 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,9 @@ serde_json = { version = "1", optional = true } [dev-dependencies] axum = { version = "0.7", default-features = false } +axum-test = "14" +tokio = { version = "1", features = ["full"] } +tokio-test = "0.4" [package.metadata.docs.rs] all-features = true diff --git a/README.md b/README.md index a176207..a7a3ee3 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,25 @@ any of your responses. | `HX-Trigger-After-Settle` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` | | `HX-Trigger-After-Swap` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` | +Also, there are corresponding cache-related headers, which you may want to add to +`GET` responses, depending on the htmx headers. + +_For example, if your server renders the full HTML when the `HX-Request` header is +missing or `false`, and it renders a fragment of that HTML when `HX-Request: true`, +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. + +| Header | Responder | +|-------------------------|---------------------| +| `Vary: HX-Request` | `VaryHxRequest` | +| `Vary: HX-Target` | `VaryHxTarget` | +| `Vary: HX-Trigger` | `VaryHxTrigger` | +| `Vary: HX-Trigger-Name` | `VaryHxTriggerName` | + + ## Request Guards __Requires features `guards`.__ @@ -200,6 +219,12 @@ Contributions are always welcome! If you have an idea for a feature or find a bug, let me know. PR's are appreciated, but if it's not a small change, please open an issue first so we're all on the same page! +### Testing + +```sh +cargo +nightly test --all-features +``` + ## License `axum-htmx` is dual-licensed under either diff --git a/src/error.rs b/src/error.rs index d6607f0..fcf4af4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,11 +1,15 @@ use std::{error, fmt}; use axum_core::response::IntoResponse; -use http::{header::InvalidHeaderValue, StatusCode}; +use http::{ + header::{InvalidHeaderValue, MaxSizeReached}, + StatusCode, +}; #[derive(Debug)] pub enum HxError { InvalidHeaderValue(InvalidHeaderValue), + TooManyResponseHeaders(MaxSizeReached), #[cfg(feature = "serde")] #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] @@ -18,6 +22,12 @@ impl From for HxError { } } +impl From for HxError { + fn from(value: MaxSizeReached) -> Self { + Self::TooManyResponseHeaders(value) + } +} + #[cfg(feature = "serde")] #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] impl From for HxError { @@ -30,6 +40,7 @@ impl fmt::Display for HxError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { HxError::InvalidHeaderValue(_) => write!(f, "Invalid header value"), + HxError::TooManyResponseHeaders(_) => write!(f, "Too many response headers"), #[cfg(feature = "serde")] HxError::Json(_) => write!(f, "Json"), } @@ -40,6 +51,7 @@ impl error::Error for HxError { fn source(&self) -> Option<&(dyn error::Error + 'static)> { match self { HxError::InvalidHeaderValue(ref e) => Some(e), + HxError::TooManyResponseHeaders(ref e) => Some(e), #[cfg(feature = "serde")] HxError::Json(ref e) => Some(e), } diff --git a/src/guard.rs b/src/guard.rs index 73044d8..79899e6 100644 --- a/src/guard.rs +++ b/src/guard.rs @@ -48,7 +48,7 @@ impl<'a, S> Layer for HxRequestGuardLayer<'a> { } } -/// Tower service that implementes redirecting to non-partial routes. +/// Tower service that implements redirecting to non-partial routes. #[derive(Debug, Clone)] pub struct HxRequestGuard<'a, S> { inner: S, diff --git a/src/responders.rs b/src/responders.rs index fdc06e4..e7de8d2 100644 --- a/src/responders.rs +++ b/src/responders.rs @@ -11,6 +11,8 @@ mod location; pub use location::*; mod trigger; pub use trigger::*; +mod vary; +pub use vary::*; const HX_SWAP_INNER_HTML: &str = "innerHTML"; const HX_SWAP_OUTER_HTML: &str = "outerHTML"; diff --git a/src/responders/vary.rs b/src/responders/vary.rs new file mode 100644 index 0000000..03866da --- /dev/null +++ b/src/responders/vary.rs @@ -0,0 +1,141 @@ +use axum_core::response::{IntoResponseParts, ResponseParts}; +use http::header::{HeaderValue, VARY}; + +use crate::{extractors, headers, HxError}; + +const HX_REQUEST: HeaderValue = HeaderValue::from_static(headers::HX_REQUEST); +const HX_TARGET: HeaderValue = HeaderValue::from_static(headers::HX_TARGET); +const HX_TRIGGER: HeaderValue = HeaderValue::from_static(headers::HX_TRIGGER); +const HX_TRIGGER_NAME: HeaderValue = HeaderValue::from_static(headers::HX_TRIGGER_NAME); + +/// The `Vary: HX-Request` header. +/// +/// You may want to add this header to the response if your handler responds differently based on +/// the `HX-Request` request header. +/// +/// For example, if your server renders the full HTML when the `HX-Request` header is missing or +/// `false`, and it renders a fragment of that HTML when `HX-Request: true`. +/// +/// You probably need this only for `GET` requests, as other HTTP methods are not cached by default. +/// +/// See for more information. +#[derive(Debug, Clone)] +pub struct VaryHxRequest; + +impl IntoResponseParts for VaryHxRequest { + type Error = HxError; + + fn into_response_parts(self, mut res: ResponseParts) -> Result { + res.headers_mut().try_append(VARY, HX_REQUEST)?; + + Ok(res) + } +} + +impl extractors::HxRequest { + /// Convenience method to create the corresponding `Vary` response header + pub fn vary_response() -> VaryHxRequest { + VaryHxRequest + } +} + +/// The `Vary: HX-Target` header. +/// +/// You may want to add this header to the response if your handler responds differently based on +/// the `HX-Target` request header. +/// +/// You probably need this only for `GET` requests, as other HTTP methods are not cached by default. +/// +/// See for more information. +#[derive(Debug, Clone)] +pub struct VaryHxTarget; + +impl IntoResponseParts for VaryHxTarget { + type Error = HxError; + + fn into_response_parts(self, mut res: ResponseParts) -> Result { + res.headers_mut().try_append(VARY, HX_TARGET)?; + + Ok(res) + } +} + +impl extractors::HxTarget { + /// Convenience method to create the corresponding `Vary` response header + pub fn vary_response() -> VaryHxTarget { + VaryHxTarget + } +} + +/// The `Vary: HX-Trigger` header. +/// +/// You may want to add this header to the response if your handler responds differently based on +/// the `HX-Trigger` request header. +/// +/// You probably need this only for `GET` requests, as other HTTP methods are not cached by default. +/// +/// See for more information. +#[derive(Debug, Clone)] +pub struct VaryHxTrigger; + +impl IntoResponseParts for VaryHxTrigger { + type Error = HxError; + + fn into_response_parts(self, mut res: ResponseParts) -> Result { + res.headers_mut().try_append(VARY, HX_TRIGGER)?; + + Ok(res) + } +} + +impl extractors::HxTrigger { + /// Convenience method to create the corresponding `Vary` response header + pub fn vary_response() -> VaryHxTrigger { + VaryHxTrigger + } +} + +/// The `Vary: HX-Trigger-Name` header. +/// +/// You may want to add this header to the response if your handler responds differently based on +/// the `HX-Trigger-Name` request header. +/// +/// You probably need this only for `GET` requests, as other HTTP methods are not cached by default. +/// +/// See for more information. +#[derive(Debug, Clone)] +pub struct VaryHxTriggerName; + +impl IntoResponseParts for VaryHxTriggerName { + type Error = HxError; + + fn into_response_parts(self, mut res: ResponseParts) -> Result { + res.headers_mut().try_append(VARY, HX_TRIGGER_NAME)?; + + Ok(res) + } +} + +impl extractors::HxTriggerName { + /// Convenience method to create the corresponding `Vary` response header + pub fn vary_response() -> VaryHxTriggerName { + VaryHxTriggerName + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{routing::get, Router}; + use std::collections::hash_set::HashSet; + + #[tokio::test] + async fn multiple_headers() { + let app = Router::new().route("/", get(|| async { (VaryHxRequest, VaryHxTarget, "foo") })); + let server = axum_test::TestServer::new(app).unwrap(); + + let resp = server.get("/").await; + let values: HashSet = resp.iter_headers_by_name("vary").cloned().collect(); + assert_eq!(values, HashSet::from([HX_REQUEST, HX_TARGET])); + } +}