diff --git a/Cargo.toml b/Cargo.toml index 4882769..6a2cbf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,5 +30,8 @@ pin-project-lite = { version = "0.2", optional = true } serde = { version = "1", features = ["derive"], optional = true } serde_json = { version = "1", optional = true } +[dev-dependencies] +axum = { version = "0.7.1", default-features = false } + [package.metadata.docs.rs] all-features = true diff --git a/README.md b/README.md index e49a677..51cf392 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,9 @@ any of your responses. | `HX-Reswap` | `HxReswap` | `axum_htmx::responders::SwapOption` | | `HX-Retarget` | `HxRetarget` | `String` | | `HX-Reselect` | `HxReselect` | `String` | -| `HX-Trigger` | `HxResponseTrigger` | `String` or `axum_htmx::serde::HxEvent`* | -| `HX-Trigger-After-Settle` | `HxResponseTriggerAfterSettle` | `String` or `axum_htmx::serde::HxEvent`* | -| `HX-Trigger-After-Swap` | `HxResponseTriggerAfterSwap` | `String` or `axum_htmx::serde::HxEvent`* | - -_* requires the `serde` feature flag to be enabled._ +| `HX-Trigger` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` | +| `HX-Trigger-After-Settle` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` | +| `HX-Trigger-After-Swap` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` | ## Request Guards @@ -137,7 +135,7 @@ use axum_htmx::HxResponseTrigger; async fn index() -> (&'static str, HxResponseTrigger) { ( "Hello, world!", - HxResponseTrigger(vec!["my-event".to_string()]), + HxResponseTrigger::from(["my-event", "second-event"]), ) } ``` @@ -146,16 +144,16 @@ async fn index() -> (&'static str, HxResponseTrigger) { can use via the `serde` feature flag and the `HxEvent` type. ```rust -use axum_htmx::serde::HxEvent; use serde_json::json; // Note that we are using `HxResponseTrigger` from the `axum_htmx::serde` module // instead of the root module. -use axum_htmx::serde::HxResponseTrigger; +use axum_htmx::{HxEvent, HxResponseTrigger}; async fn index() -> (&'static str, HxResponseTrigger) { let event = HxEvent::new_with_data( "my-event", + // May be any object that implements `serde::Serialize` json!({"level": "info", "message": { "title": "Hello, world!", "body": "This is a test message.", @@ -188,10 +186,10 @@ 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 `HxResponseTrigger*` and `HxLocation` response headers. | `serde`, `serde_json` | +| 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` | ## Contributing diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..d89d096 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,45 @@ +use std::{error, fmt}; + +use axum_core::response::IntoResponse; +use http::{header::InvalidHeaderValue, StatusCode}; + +#[derive(Debug)] +pub enum HxError { + InvalidHeaderValue(InvalidHeaderValue), + + #[cfg(feature = "serde")] + #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] + Json(serde_json::Error), +} + +impl From for HxError { + fn from(value: InvalidHeaderValue) -> Self { + Self::InvalidHeaderValue(value) + } +} + +#[cfg(feature = "serde")] +#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] +impl From for HxError { + fn from(value: serde_json::Error) -> Self { + Self::Json(value) + } +} + +impl fmt::Display for HxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HxError::InvalidHeaderValue(err) => write!(f, "Invalid header value: {err}"), + #[cfg(feature = "serde")] + HxError::Json(err) => write!(f, "Json: {err}"), + } + } +} + +impl error::Error for HxError {} + +impl IntoResponse for HxError { + fn into_response(self) -> axum_core::response::Response { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() + } +} diff --git a/src/extractors.rs b/src/extractors.rs index 7e7304f..bb366f1 100644 --- a/src/extractors.rs +++ b/src/extractors.rs @@ -42,10 +42,10 @@ where /// This is set on every request made by htmx itself. As its name implies, it /// just contains the current url. /// -/// This extractor will always return a value. If the header is not present, it -/// will return `None`. +/// This extractor will always return a value. If the header is not present, or extractor fails to parse the url +/// it will return `None`. #[derive(Debug, Clone)] -pub struct HxCurrentUrl(pub Option); +pub struct HxCurrentUrl(pub Option); #[async_trait] impl FromRequestParts for HxCurrentUrl @@ -56,9 +56,11 @@ where async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { if let Some(url) = parts.headers.get(HX_CURRENT_URL) { - if let Ok(url) = url.to_str() { - return Ok(HxCurrentUrl(Some(url.to_string()))); - } + let url = url + .to_str() + .ok() + .and_then(|url| url.parse::().ok()); + return Ok(HxCurrentUrl(url)); } return Ok(HxCurrentUrl(None)); diff --git a/src/guard.rs b/src/guard.rs index d4a8421..73044d8 100644 --- a/src/guard.rs +++ b/src/guard.rs @@ -63,7 +63,7 @@ where { type Response = S::Response; type Error = S::Error; - type Future = ResponseFuture<'a, S::Future>; + type Future = private::ResponseFuture<'a, S::Future>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { self.inner.poll_ready(cx) @@ -77,7 +77,7 @@ where let response_future = self.inner.call(req); - ResponseFuture { + private::ResponseFuture { response_future, hx_request: self.hx_request, layer: self.layer.clone(), @@ -85,36 +85,40 @@ where } } -pin_project! { - pub struct ResponseFuture<'a, F> { - #[pin] - response_future: F, - hx_request: bool, - layer: HxRequestGuardLayer<'a>, +mod private { + use super::*; + + pin_project! { + pub struct ResponseFuture<'a, F> { + #[pin] + pub(super) response_future: F, + pub(super) hx_request: bool, + pub(super) layer: HxRequestGuardLayer<'a>, + } } -} -impl<'a, F, B, E> Future for ResponseFuture<'a, F> -where - F: Future, E>>, - B: Default, -{ - type Output = Result, E>; + impl<'a, F, B, E> Future for ResponseFuture<'a, F> + where + F: Future, E>>, + B: Default, + { + type Output = Result, E>; - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.project(); - let response: Response = ready!(this.response_future.poll(cx))?; + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + let response: Response = ready!(this.response_future.poll(cx))?; - match *this.hx_request { - true => Poll::Ready(Ok(response)), - false => { - let res = Response::builder() - .status(StatusCode::SEE_OTHER) - .header(LOCATION, this.layer.redirect_to) - .body(B::default()) - .expect("failed to build response"); + match *this.hx_request { + true => Poll::Ready(Ok(response)), + false => { + let res = Response::builder() + .status(StatusCode::SEE_OTHER) + .header(LOCATION, this.layer.redirect_to) + .body(B::default()) + .expect("failed to build response"); - Poll::Ready(Ok(res)) + Poll::Ready(Ok(res)) + } } } } diff --git a/src/lib.rs b/src/lib.rs index cd77122..439ff68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,9 @@ #![doc = include_str!("../README.md")] #![forbid(unsafe_code)] +mod error; +pub use error::*; + pub mod extractors; #[cfg(feature = "guards")] #[cfg_attr(feature = "unstable", doc(cfg(feature = "guards")))] @@ -9,9 +12,13 @@ pub mod guard; pub mod headers; pub mod responders; +#[doc(inline)] pub use extractors::*; #[cfg(feature = "guards")] #[cfg_attr(feature = "unstable", doc(cfg(feature = "guards")))] +#[doc(inline)] pub use guard::*; +#[doc(inline)] pub use headers::*; +#[doc(inline)] pub use responders::*; diff --git a/src/responders.rs b/src/responders.rs index 99aef61..fdc06e4 100644 --- a/src/responders.rs +++ b/src/responders.rs @@ -1,15 +1,16 @@ //! Axum responses for htmx response headers. -use std::convert::Infallible; +use std::{convert::Infallible, str::FromStr}; -use axum_core::response::{IntoResponse, IntoResponseParts, ResponseParts}; -use http::{header::InvalidHeaderValue, HeaderValue, StatusCode, Uri}; +use axum_core::response::{IntoResponseParts, ResponseParts}; +use http::{HeaderValue, Uri}; -use crate::headers; +use crate::{headers, HxError}; -#[cfg(feature = "serde")] -#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] -pub mod serde; +mod location; +pub use location::*; +mod trigger; +pub use trigger::*; const HX_SWAP_INNER_HTML: &str = "innerHTML"; const HX_SWAP_OUTER_HTML: &str = "outerHTML"; @@ -20,33 +21,6 @@ const HX_SWAP_AFTER_END: &str = "afterend"; const HX_SWAP_DELETE: &str = "delete"; const HX_SWAP_NONE: &str = "none"; -/// The `HX-Location` header. -/// -/// This response header can be used to trigger a client side redirection -/// without reloading the whole page. If you intend to redirect to a specific -/// target on the page, you must enable the `serde` feature flag and use -/// `axum_htmx::responders::serde::HxLocation` instead. -/// -/// Will fail if the supplied Uri contains characters that are not visible ASCII -/// (32-127). -/// -/// See for more information. -#[derive(Debug, Clone)] -pub struct HxLocation(pub Uri); - -impl IntoResponseParts for HxLocation { - type Error = HxError; - - fn into_response_parts(self, mut res: ResponseParts) -> Result { - res.headers_mut().insert( - headers::HX_LOCATION, - HeaderValue::from_maybe_shared(self.0.to_string())?, - ); - - Ok(res) - } -} - /// The `HX-Push-Url` header. /// /// Pushes a new url into the history stack. @@ -71,6 +45,20 @@ impl IntoResponseParts for HxPushUrl { } } +impl From for HxPushUrl { + fn from(uri: Uri) -> Self { + Self(uri) + } +} + +impl<'a> TryFrom<&'a str> for HxPushUrl { + type Error = ::Err; + + fn try_from(value: &'a str) -> Result { + Ok(Self(value.parse()?)) + } +} + /// The `HX-Redirect` header. /// /// Can be used to do a client-side redirect to a new location. @@ -93,6 +81,20 @@ impl IntoResponseParts for HxRedirect { } } +impl From for HxRedirect { + fn from(uri: Uri) -> Self { + Self(uri) + } +} + +impl<'a> TryFrom<&'a str> for HxRedirect { + type Error = ::Err; + + fn try_from(value: &'a str) -> Result { + Ok(Self(value.parse()?)) + } +} + /// The `HX-Refresh`header. /// /// If set to `true` the client-side will do a full refresh of the page. @@ -101,6 +103,12 @@ impl IntoResponseParts for HxRedirect { #[derive(Debug, Copy, Clone)] pub struct HxRefresh(pub bool); +impl From for HxRefresh { + fn from(value: bool) -> Self { + Self(value) + } +} + impl IntoResponseParts for HxRefresh { type Error = Infallible; @@ -142,6 +150,20 @@ impl IntoResponseParts for HxReplaceUrl { } } +impl From for HxReplaceUrl { + fn from(uri: Uri) -> Self { + Self(uri) + } +} + +impl<'a> TryFrom<&'a str> for HxReplaceUrl { + type Error = ::Err; + + fn try_from(value: &'a str) -> Result { + Ok(Self(value.parse()?)) + } +} + /// The `HX-Reswap` header. /// /// Allows you to specidy how the response will be swapped. @@ -160,6 +182,12 @@ impl IntoResponseParts for HxReswap { } } +impl From for HxReswap { + fn from(value: SwapOption) -> Self { + Self(value) + } +} + /// The `HX-Retarget` header. /// /// A CSS selector that updates the target of the content update to a different @@ -183,6 +211,12 @@ impl IntoResponseParts for HxRetarget { } } +impl> From for HxRetarget { + fn from(value: T) -> Self { + Self(value.into()) + } +} + /// The `HX-Reselect` header. /// /// A CSS selector that allows you to choose which part of the response is used @@ -206,127 +240,9 @@ impl IntoResponseParts for HxReselect { } } -/// The `HX-Trigger` header. -/// -/// Allows you to trigger client-side events. If you intend to add data to your -/// events, you must enable the `serde` feature flag and use -/// `axum_htmx::responders::serde::HxResponseTrigger` instead. -/// -/// Will fail if the supplied events contain or produce characters that are not -/// visible ASCII (32-127) when serializing to JSON. -/// -/// See for more information. -#[derive(Debug, Clone)] -pub struct HxResponseTrigger(pub Vec); - -impl From for HxResponseTrigger -where - T: IntoIterator, - T::Item: ToString, -{ +impl> From for HxReselect { fn from(value: T) -> Self { - Self(value.into_iter().map(|s| s.to_string()).collect()) - } -} - -impl IntoResponseParts for HxResponseTrigger { - type Error = HxError; - - fn into_response_parts(self, mut res: ResponseParts) -> Result { - res.headers_mut().insert( - headers::HX_TRIGGER, - HeaderValue::from_maybe_shared( - self.0 - .into_iter() - .reduce(|acc, e| acc + ", " + &e) - .unwrap_or_default(), - )?, - ); - - Ok(res) - } -} - -/// The `HX-Trigger-After-Settle` header. -/// -/// Allows you to trigger client-side events after the settle step. If you -/// intend to add data to your events, you must enable the `serde` feature flag -/// and use `axum_htmx::responders::serde::HxResponseTriggerAfterSettle` -/// instead. -/// -/// Will fail if the supplied events contain or produce characters that are not -/// visible ASCII (32-127) when serializing to JSON. -/// -/// See for more information. -#[derive(Debug, Clone)] -pub struct HxResponseTriggerAfterSettle(pub Vec); - -impl From for HxResponseTriggerAfterSettle -where - T: IntoIterator, - T::Item: ToString, -{ - fn from(value: T) -> Self { - Self(value.into_iter().map(|s| s.to_string()).collect()) - } -} - -impl IntoResponseParts for HxResponseTriggerAfterSettle { - type Error = HxError; - - fn into_response_parts(self, mut res: ResponseParts) -> Result { - res.headers_mut().insert( - headers::HX_TRIGGER_AFTER_SETTLE, - HeaderValue::from_maybe_shared( - self.0 - .into_iter() - .reduce(|acc, e| acc + ", " + &e) - .unwrap_or_default(), - )?, - ); - - Ok(res) - } -} - -/// The `HX-Trigger-After-Swap` header. -/// -/// Allows you to trigger client-side events after the swap step. If you intend -/// to add data to your events, you must enable the `serde` feature flag and use -/// `axum_htmx::responders::serde::HxResponseTriggerAfterSwap` instead. -/// -/// Will fail if the supplied events contain or produce characters that are not -/// visible ASCII (32-127) when serializing to JSON. -/// -/// See for more information. -#[derive(Debug, Clone)] -pub struct HxResponseTriggerAfterSwap(pub Vec); - -impl From for HxResponseTriggerAfterSwap -where - T: IntoIterator, - T::Item: ToString, -{ - fn from(value: T) -> Self { - Self(value.into_iter().map(|s| s.to_string()).collect()) - } -} - -impl IntoResponseParts for HxResponseTriggerAfterSwap { - type Error = HxError; - - fn into_response_parts(self, mut res: ResponseParts) -> Result { - res.headers_mut().insert( - headers::HX_TRIGGER_AFTER_SWAP, - HeaderValue::from_maybe_shared( - self.0 - .into_iter() - .reduce(|acc, e| acc + ", " + &e) - .unwrap_or_default(), - )?, - ); - - Ok(res) + Self(value.into()) } } @@ -353,6 +269,32 @@ pub enum SwapOption { None, } +// can be removed and automatically derived when +// https://github.com/serde-rs/serde/issues/2485 is implemented +#[cfg(feature = "serde")] +impl ::serde::Serialize for SwapOption { + fn serialize(&self, serializer: S) -> Result + where + S: ::serde::Serializer, + { + const UNIT_NAME: &str = "SwapOption"; + match self { + Self::InnerHtml => serializer.serialize_unit_variant(UNIT_NAME, 0, HX_SWAP_INNER_HTML), + Self::OuterHtml => serializer.serialize_unit_variant(UNIT_NAME, 1, HX_SWAP_OUTER_HTML), + Self::BeforeBegin => { + serializer.serialize_unit_variant(UNIT_NAME, 2, HX_SWAP_BEFORE_BEGIN) + } + Self::AfterBegin => { + serializer.serialize_unit_variant(UNIT_NAME, 3, HX_SWAP_AFTER_BEGIN) + } + Self::BeforeEnd => serializer.serialize_unit_variant(UNIT_NAME, 4, HX_SWAP_BEFORE_END), + Self::AfterEnd => serializer.serialize_unit_variant(UNIT_NAME, 5, HX_SWAP_AFTER_END), + Self::Delete => serializer.serialize_unit_variant(UNIT_NAME, 6, HX_SWAP_DELETE), + Self::None => serializer.serialize_unit_variant(UNIT_NAME, 7, HX_SWAP_NONE), + } + } +} + impl From for HeaderValue { fn from(value: SwapOption) -> Self { match value { @@ -367,41 +309,3 @@ impl From for HeaderValue { } } } - -#[derive(Debug)] -pub enum HxError { - InvalidHeaderValue(InvalidHeaderValue), - - #[cfg(feature = "serde")] - Serialization(serde_json::Error), -} - -impl From for HxError { - fn from(value: InvalidHeaderValue) -> Self { - Self::InvalidHeaderValue(value) - } -} - -#[cfg(feature = "serde")] -impl From for HxError { - fn from(value: serde_json::Error) -> Self { - Self::Serialization(value) - } -} - -impl IntoResponse for HxError { - fn into_response(self) -> axum_core::response::Response { - match self { - Self::InvalidHeaderValue(_) => { - (StatusCode::INTERNAL_SERVER_ERROR, "invalid header value").into_response() - } - - #[cfg(feature = "serde")] - Self::Serialization(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "failed to serialize event", - ) - .into_response(), - } - } -} diff --git a/src/responders/location.rs b/src/responders/location.rs new file mode 100644 index 0000000..39b5a7d --- /dev/null +++ b/src/responders/location.rs @@ -0,0 +1,224 @@ +use std::str::FromStr; + +use axum_core::response::{IntoResponseParts, ResponseParts}; +use http::{HeaderValue, Uri}; + +use crate::{headers, HxError}; + +/// The `HX-Location` header. +/// +/// This response header can be used to trigger a client side redirection +/// without reloading the whole page. If you intend to redirect to a specific +/// target on the page, you must enable the `serde` feature flag and specify +/// [`LocationOptions`]. +/// +/// Will fail if the supplied Uri contains characters that are not visible ASCII +/// (32-127). +/// +/// See for more information. +#[derive(Debug, Clone)] +pub struct HxLocation { + /// Uri of the new location. + pub uri: Uri, + /// Extra options. + #[cfg(feature = "serde")] + #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] + pub options: LocationOptions, +} + +impl HxLocation { + /// Creates location from [`Uri`] without any options. + pub fn from_uri(uri: Uri) -> Self { + Self { + #[cfg(feature = "serde")] + options: LocationOptions::default(), + uri, + } + } + + /// Creates location from [`Uri`] and options. + #[cfg(feature = "serde")] + #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] + pub fn from_uri_with_options(uri: Uri, options: LocationOptions) -> Self { + Self { uri, options } + } + + /// Parses `uri` and sets it as location. + #[allow(clippy::should_implement_trait)] + pub fn from_str(uri: impl AsRef) -> Result { + Ok(Self { + #[cfg(feature = "serde")] + options: LocationOptions::default(), + uri: uri.as_ref().parse::()?, + }) + } + + /// Parses `uri` and sets it as location with additional options. + #[cfg(feature = "serde")] + #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] + pub fn from_str_with_options( + uri: impl AsRef, + options: LocationOptions, + ) -> Result { + Ok(Self { + options, + uri: uri.as_ref().parse::()?, + }) + } + + #[cfg(feature = "serde")] + fn into_header_with_options(self) -> Result { + if self.options.is_default() { + return Ok(self.uri.to_string()); + } + + #[derive(::serde::Serialize)] + struct LocWithOpts { + path: String, + #[serde(flatten)] + opts: LocationOptions, + } + + let loc_with_opts = LocWithOpts { + path: self.uri.to_string(), + opts: self.options, + }; + Ok(serde_json::to_string(&loc_with_opts)?) + } +} + +impl From for HxLocation { + fn from(uri: Uri) -> Self { + Self::from_uri(uri) + } +} + +#[cfg(feature = "serde")] +#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] +impl From<(Uri, LocationOptions)> for HxLocation { + fn from((uri, options): (Uri, LocationOptions)) -> Self { + Self::from_uri_with_options(uri, options) + } +} + +impl<'a> TryFrom<&'a str> for HxLocation { + type Error = ::Err; + + fn try_from(uri: &'a str) -> Result { + Self::from_str(uri) + } +} + +#[cfg(feature = "serde")] +#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] +impl<'a> TryFrom<(&'a str, LocationOptions)> for HxLocation { + type Error = ::Err; + + fn try_from((uri, options): (&'a str, LocationOptions)) -> Result { + Self::from_str_with_options(uri, options) + } +} + +impl IntoResponseParts for HxLocation { + type Error = HxError; + + fn into_response_parts(self, mut res: ResponseParts) -> Result { + #[cfg(feature = "serde")] + let header = self.into_header_with_options()?; + #[cfg(not(feature = "serde"))] + let header = self.uri.to_string(); + + res.headers_mut().insert( + headers::HX_LOCATION, + HeaderValue::from_maybe_shared(header)?, + ); + + Ok(res) + } +} + +/// More options for `HX-Location` header. +/// +/// - `source` - the source element of the request +/// - `event` - an event that “triggered” the request +/// - `handler` - a callback that will handle the response HTML +/// - `target` - the target to swap the response into +/// - `swap` - how the response will be swapped in relative to the target +/// - `values` - values to submit with the request +/// - `headers` - headers to submit with the request +/// - `select` - allows you to select the content you want swapped from a response +#[cfg(feature = "serde")] +#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] +#[derive(Debug, Clone, serde::Serialize, Default)] +#[non_exhaustive] +pub struct LocationOptions { + /// The source element of the request. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + /// An event that "triggered" the request. + #[serde(skip_serializing_if = "Option::is_none")] + pub event: Option, + /// A callback that will handle the response HTML. + #[serde(skip_serializing_if = "Option::is_none")] + pub handler: Option, + /// The target to swap the response into. + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + /// How the response will be swapped in relative to the target. + #[serde(skip_serializing_if = "Option::is_none")] + pub swap: Option, + /// Values to submit with the request. + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option, + /// Headers to submit with the request. + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, +} + +#[cfg(feature = "serde")] +#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] +impl LocationOptions { + pub(super) fn is_default(&self) -> bool { + let Self { + source: None, + event: None, + handler: None, + target: None, + swap: None, + values: None, + headers: None, + } = self + else { + return false; + }; + + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(feature = "serde")] + fn test_serialize_location() { + use crate::SwapOption; + + let loc = HxLocation::try_from("/foo").unwrap(); + assert_eq!(loc.into_header_with_options().unwrap(), "/foo"); + + let loc = HxLocation::from_uri_with_options( + "/foo".parse().unwrap(), + LocationOptions { + event: Some("click".into()), + swap: Some(SwapOption::InnerHtml), + ..Default::default() + }, + ); + assert_eq!( + loc.into_header_with_options().unwrap(), + r#"{"path":"/foo","event":"click","swap":"innerHTML"}"# + ); + } +} diff --git a/src/responders/serde.rs b/src/responders/serde.rs deleted file mode 100644 index 7bfb875..0000000 --- a/src/responders/serde.rs +++ /dev/null @@ -1,259 +0,0 @@ -use std::collections::HashMap; - -use axum_core::response::{IntoResponseParts, ResponseParts}; -use http::{HeaderValue, Uri}; -use serde::Serialize; -use serde_json::Value; - -use crate::{ - headers, - responders::{ - HX_SWAP_AFTER_BEGIN, HX_SWAP_AFTER_END, HX_SWAP_BEFORE_BEGIN, HX_SWAP_BEFORE_END, - HX_SWAP_DELETE, HX_SWAP_INNER_HTML, HX_SWAP_NONE, HX_SWAP_OUTER_HTML, - }, - HxError, SwapOption, -}; - -/// The `HX-Location` header. -/// -/// This response header can be used to trigger a client side redirection -/// without reloading the whole page. If you only intend to redirect to the -/// `document.body`, as opposed to a specific target, you can use -/// `axum_htmx::HxResponseLocation` instead. -/// -/// Will fail if the supplied data contains or produces characters that are not -/// visible ASCII (32-127) when serializing to JSON. -/// -/// See for more information. -#[derive(Debug, Clone, Serialize)] -pub struct HxLocation { - /// Url to load the response from. - pub path: String, - /// The source element of the request. - pub source: Option, - /// An event that "triggered" the request. - pub event: Option, - /// A callback that will handle the response HTML. - pub handler: Option, - /// The target to swap the response into. - pub target: Option, - /// How the response will be swapped in relative to the target. - pub swap: Option, - /// Values to submit with the request. - pub values: Option, - /// Headers to submit with the request. - pub headers: Option, -} - -impl HxLocation { - pub fn from_uri(uri: &Uri) -> Self { - Self { - path: uri.to_string(), - source: None, - event: None, - handler: None, - target: None, - swap: None, - values: None, - headers: None, - } - } -} - -impl IntoResponseParts for HxLocation { - type Error = HxError; - - fn into_response_parts(self, mut res: ResponseParts) -> Result { - let header_value = if self.source.is_none() - && self.event.is_none() - && self.handler.is_none() - && self.target.is_none() - && self.swap.is_none() - && self.values.is_none() - && self.headers.is_none() - { - HeaderValue::from_str(&self.path)? - } else { - HeaderValue::from_maybe_shared(serde_json::to_string(&self)?)? - }; - - res.headers_mut().insert(headers::HX_LOCATION, header_value); - - Ok(res) - } -} - -/// The `HX-Trigger` header. -/// -/// Allows you to trigger client-side events. If you only need to send bare -/// events, you can use `axum_htmx::HxResponseTrigger` instead. -/// -/// Will fail if the supplied events contain or produce characters that are not -/// visible ASCII (32-127) when serializing to JSON. -/// -/// See for more information. -#[derive(Debug, Clone)] -pub struct HxResponseTrigger(pub Vec); - -impl IntoResponseParts for HxResponseTrigger { - type Error = HxError; - - fn into_response_parts(self, mut res: ResponseParts) -> Result { - res.headers_mut() - .insert(headers::HX_TRIGGER, events_to_header_value(self.0)?); - - Ok(res) - } -} - -/// The `HX-Trigger-After-Settle` header. -/// -/// Allows you to trigger client-side events after the settle step. If you only -/// intend to send bare events, you can use -/// `axum_htmx::HxResponseTriggerAfterSettle` instead. -/// -/// Will fail if the supplied events contain or produce characters that are not -/// visible ASCII (32-127) when serializing to JSON. -/// -/// See for more information. -#[derive(Debug, Clone)] -pub struct HxResponseTriggerAfterSettle(pub Vec); - -impl IntoResponseParts for HxResponseTriggerAfterSettle { - type Error = HxError; - - fn into_response_parts(self, mut res: ResponseParts) -> Result { - res.headers_mut().insert( - headers::HX_TRIGGER_AFTER_SETTLE, - events_to_header_value(self.0)?, - ); - - Ok(res) - } -} - -/// The `HX-Trigger-After-Swap` header. -/// -/// Allows you to trigger client-side events after the swap step. If you only -/// need to send bare events, you can use -/// `axum_htmx::HxResponseTriggerAfterSwao` instead. -/// -/// Will fail if the supplied events contain or produce characters that are not -/// visible ASCII (32-127) when serializing to JSON. -/// -/// See for more information. -#[derive(Debug, Clone)] -pub struct HxResponseTriggerAfterSwap(pub Vec); - -impl IntoResponseParts for HxResponseTriggerAfterSwap { - type Error = HxError; - - fn into_response_parts(self, mut res: ResponseParts) -> Result { - res.headers_mut().insert( - headers::HX_TRIGGER_AFTER_SWAP, - events_to_header_value(self.0)?, - ); - - Ok(res) - } -} - -/// Represents a client-side event carrying optional data. -#[derive(Debug, Clone, Serialize)] -pub struct HxEvent { - pub name: String, - pub data: Option, -} - -impl HxEvent { - pub fn new(name: String) -> Self { - Self { - name: name.to_string(), - data: None, - } - } - - pub fn new_with_data(name: &str, data: T) -> Result { - let data = serde_json::to_value(data)?; - - Ok(Self { - name: name.to_string(), - data: Some(data), - }) - } -} - -pub(crate) fn events_to_header_value(events: Vec) -> Result { - let with_data = events.iter().any(|e| e.data.is_some()); - - let header_value = if with_data { - // at least one event contains data so the header_value needs to be json - // encoded. - let header_value = events - .into_iter() - .map(|e| (e.name, e.data.unwrap_or_default())) - .collect::>(); - - serde_json::to_string(&header_value)? - } else { - // no event contains data, the event names can be put in the header - // value separated by a comma. - events - .into_iter() - .map(|e| e.name) - .reduce(|acc, e| acc + ", " + &e) - .unwrap_or_default() - }; - - HeaderValue::from_maybe_shared(header_value).map_err(HxError::from) -} - -// can be removed and automatically derived when -// https://github.com/serde-rs/serde/issues/2485 is implemented -impl serde::Serialize for SwapOption { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - const UNIT_NAME: &str = "SwapOption"; - match self { - Self::InnerHtml => serializer.serialize_unit_variant(UNIT_NAME, 0, HX_SWAP_INNER_HTML), - Self::OuterHtml => serializer.serialize_unit_variant(UNIT_NAME, 1, HX_SWAP_OUTER_HTML), - Self::BeforeBegin => { - serializer.serialize_unit_variant(UNIT_NAME, 2, HX_SWAP_BEFORE_BEGIN) - } - Self::AfterBegin => { - serializer.serialize_unit_variant(UNIT_NAME, 3, HX_SWAP_AFTER_BEGIN) - } - Self::BeforeEnd => serializer.serialize_unit_variant(UNIT_NAME, 4, HX_SWAP_BEFORE_END), - Self::AfterEnd => serializer.serialize_unit_variant(UNIT_NAME, 5, HX_SWAP_AFTER_END), - Self::Delete => serializer.serialize_unit_variant(UNIT_NAME, 6, HX_SWAP_DELETE), - Self::None => serializer.serialize_unit_variant(UNIT_NAME, 7, HX_SWAP_NONE), - } - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn valid_event_to_header_encoding() { - let evt = HxEvent::new_with_data( - "my-event", - json!({"level": "info", "message": { - "body": "This is a test message.", - "title": "Hello, world!", - }}), - ) - .unwrap(); - - let header_value = events_to_header_value(vec![evt]).unwrap(); - - let expected_value = r#"{"my-event":{"level":"info","message":{"body":"This is a test message.","title":"Hello, world!"}}}"#; - - assert_eq!(header_value, HeaderValue::from_static(expected_value)); - } -} diff --git a/src/responders/trigger.rs b/src/responders/trigger.rs new file mode 100644 index 0000000..e96440b --- /dev/null +++ b/src/responders/trigger.rs @@ -0,0 +1,204 @@ +use axum_core::response::{IntoResponseParts, ResponseParts}; + +use crate::{headers, HxError}; + +/// Represents a client-side event carrying optional data. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct HxEvent { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg(feature = "serde")] + #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] + pub data: Option, +} + +impl HxEvent { + /// Creates new event with no associated data. + pub fn new(name: String) -> Self { + Self { + name: name.to_string(), + #[cfg(feature = "serde")] + data: None, + } + } + + /// Creates new event with data. + #[cfg(feature = "serde")] + #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] + pub fn new_with_data( + name: impl AsRef, + data: T, + ) -> Result { + let data = serde_json::to_value(data)?; + + Ok(Self { + name: name.as_ref().to_owned(), + #[cfg(feature = "serde")] + data: Some(data), + }) + } +} + +impl> From for HxEvent { + fn from(name: N) -> Self { + Self { + name: name.as_ref().to_owned(), + #[cfg(feature = "serde")] + data: None, + } + } +} + +#[cfg(not(feature = "serde"))] +fn events_to_header_value(events: Vec) -> Result { + let header = events + .into_iter() + .map(|HxEvent { name }| name) + .collect::>() + .join(", "); + http::HeaderValue::from_str(&header).map_err(Into::into) +} + +#[cfg(feature = "serde")] +fn events_to_header_value(events: Vec) -> Result { + use std::collections::HashMap; + + use http::HeaderValue; + use serde_json::Value; + + let with_data = events.iter().any(|e| e.data.is_some()); + + let header_value = if with_data { + // at least one event contains data so the header_value needs to be json + // encoded. + let header_value = events + .into_iter() + .map(|e| (e.name, e.data.unwrap_or_default())) + .collect::>(); + + serde_json::to_string(&header_value)? + } else { + // no event contains data, the event names can be put in the header + // value separated by a comma. + events + .into_iter() + .map(|e| e.name) + .reduce(|acc, e| acc + ", " + &e) + .unwrap_or_default() + }; + + HeaderValue::from_maybe_shared(header_value).map_err(HxError::from) +} + +/// Describes when should event be triggered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TriggerMode { + Normal, + AfterSettle, + AfterSwap, +} + +/// The `HX-Trigger*` header. +/// +/// Allows you to trigger client-side events. +/// Corresponds to `HX-Trigger`, `HX-Trigger-After-Settle` and `HX-Trigger-After-Swap` headers. +/// To change when events trigger use appropriate `mode`. +/// +/// Will fail if the supplied events contain or produce characters that are not +/// visible ASCII (32-127) when serializing to JSON. +/// +/// See for more information. +#[derive(Debug, Clone)] +pub struct HxResponseTrigger { + pub mode: TriggerMode, + pub events: Vec, +} + +impl HxResponseTrigger { + /// Creates new [trigger](https://htmx.org/headers/hx-trigger/) with specified mode and events. + pub fn new>(mode: TriggerMode, events: impl IntoIterator) -> Self { + Self { + mode, + events: events.into_iter().map(Into::into).collect(), + } + } + + /// Creates new [normal](https://htmx.org/headers/hx-trigger/) trigger from events. + pub fn normal>(events: impl IntoIterator) -> Self { + Self::new(TriggerMode::Normal, events) + } + + /// Creates new [after settle](https://htmx.org/headers/hx-trigger/) trigger from events. + pub fn after_settle>(events: impl IntoIterator) -> Self { + Self::new(TriggerMode::AfterSettle, events) + } + + /// Creates new [after swap](https://htmx.org/headers/hx-trigger/) trigger from events. + pub fn after_swap>(events: impl IntoIterator) -> Self { + Self::new(TriggerMode::AfterSwap, events) + } +} + +impl From<(TriggerMode, T)> for HxResponseTrigger +where + T: IntoIterator, + T::Item: Into, +{ + fn from((mode, events): (TriggerMode, T)) -> Self { + Self { + mode, + events: events.into_iter().map(Into::into).collect(), + } + } +} + +impl IntoResponseParts for HxResponseTrigger { + type Error = HxError; + + fn into_response_parts(self, mut res: ResponseParts) -> Result { + if !self.events.is_empty() { + let header = match self.mode { + TriggerMode::Normal => headers::HX_TRIGGER, + TriggerMode::AfterSettle => headers::HX_TRIGGER_AFTER_SETTLE, + TriggerMode::AfterSwap => headers::HX_TRIGGER_AFTER_SETTLE, + }; + + res.headers_mut() + .insert(header, events_to_header_value(self.events)?); + } + + Ok(res) + } +} + +#[cfg(test)] +mod tests { + use http::HeaderValue; + use serde_json::json; + + use super::*; + + #[test] + fn valid_event_to_header_encoding() { + let evt = HxEvent::new_with_data( + "my-event", + json!({"level": "info", "message": { + "body": "This is a test message.", + "title": "Hello, world!", + }}), + ) + .unwrap(); + + let header_value = events_to_header_value(vec![evt]).unwrap(); + + let expected_value = r#"{"my-event":{"level":"info","message":{"body":"This is a test message.","title":"Hello, world!"}}}"#; + + assert_eq!(header_value, HeaderValue::from_static(expected_value)); + + let value = + events_to_header_value(HxResponseTrigger::normal(["foo", "bar"]).events).unwrap(); + assert_eq!(value, HeaderValue::from_static("foo, bar")); + } +}