diff --git a/src/responders.rs b/src/responders.rs index 0b40e9e..beebc10 100644 --- a/src/responders.rs +++ b/src/responders.rs @@ -7,9 +7,10 @@ use http::{header::InvalidHeaderValue, HeaderValue, StatusCode, Uri}; use crate::headers; -#[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,89 +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 { - /// Uri of the new location. - pub uri: Uri, - /// Extra options. - #[cfg(feature = "serde")] - #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] - pub options: serde::LocationOptions, -} - -impl HxLocation { - pub fn from_uri(uri: Uri) -> Self { - Self { - #[cfg(feature = "serde")] - options: serde::LocationOptions::default(), - uri, - } - } - - #[cfg(feature = "serde")] - #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] - pub fn from_uri_with_options(uri: Uri, options: serde::LocationOptions) -> Self { - Self { uri, options } - } - - #[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: serde::LocationOptions, - } - - let loc_with_opts = LocWithOpts { - path: self.uri.to_string(), - opts: self.options, - }; - Ok(serde_json::to_string(&loc_with_opts)?) - } -} - -impl<'a> TryFrom<&'a str> for HxLocation { - type Error = ::Err; - - fn try_from(value: &'a str) -> Result { - Ok(Self::from_uri(Uri::from_str(value)?)) - } -} - -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) - } -} - /// The `HX-Push-Url` header. /// /// Pushes a new url into the history stack. @@ -286,174 +204,6 @@ impl IntoResponseParts for HxReselect { } } -/// Represents a client-side event carrying optional data. -#[derive(Debug, Clone, ::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<::serde_json::Value>, -} - -impl HxEvent { - /// Creates new event with no associated data. - pub fn new(name: String) -> Self { - Self { - name: name.to_string(), - data: None, - } - } - - /// Creates new event with event data. - #[cfg(feature = "serde")] - #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] - pub fn new_with_data( - name: impl ToString, - data: T, - ) -> Result { - let data = serde_json::to_value(data)?; - - Ok(Self { - name: name.to_string(), - data: Some(data), - }) - } -} - -#[cfg(feature = "serde")] -fn events_to_header_value(events: Vec) -> Result { - use std::collections::HashMap; - - 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) -} - -/// 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: Into, -{ - fn from(value: T) -> Self { - Self(value.into_iter().map(Into::into).collect()) - } -} - -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 -/// 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: Into, -{ - fn from(value: T) -> Self { - Self(value.into_iter().map(Into::into).collect()) - } -} - -impl IntoResponseParts for HxResponseTriggerAfterSettle { - 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-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: Into, -{ - fn from(value: T) -> Self { - Self(value.into_iter().map(Into::into).collect()) - } -} - -impl IntoResponseParts for HxResponseTriggerAfterSwap { - 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) - } -} - /// Values of the `hx-swap` attribute. // serde::Serialize is implemented in responders/serde.rs #[derive(Debug, Copy, Clone)] @@ -557,51 +307,3 @@ impl IntoResponse for HxError { } } } - -#[cfg(test)] -mod tests { - use http::HeaderValue; - use serde_json::json; - - use crate::{responders::events_to_header_value, HxEvent}; - - #[test] - #[cfg(feature = "serde")] - fn test_serialize_location() { - use crate::{serde::LocationOptions, HxLocation, SwapOption::InnerHtml}; - - 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(InnerHtml), - ..Default::default() - }, - ); - assert_eq!( - loc.into_header_with_options().unwrap(), - r#"{"path":"/foo","event":"click","swap":"innerHTML"}"# - ); - } - - #[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/location.rs b/src/responders/location.rs new file mode 100644 index 0000000..8ec740b --- /dev/null +++ b/src/responders/location.rs @@ -0,0 +1,175 @@ +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 { + pub fn from_uri(uri: Uri) -> Self { + Self { + #[cfg(feature = "serde")] + options: LocationOptions::default(), + uri, + } + } + + #[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 } + } + + #[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<'a> TryFrom<&'a str> for HxLocation { + type Error = ::Err; + + fn try_from(value: &'a str) -> Result { + Ok(Self::from_uri(Uri::from_str(value)?)) + } +} + +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 18f997e..0000000 --- a/src/responders/serde.rs +++ /dev/null @@ -1,59 +0,0 @@ -use serde::Serialize; -use serde_json::Value; - -use crate::SwapOption; - -/// 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 -#[derive(Debug, Clone, 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, -} - -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 - } -} diff --git a/src/responders/trigger.rs b/src/responders/trigger.rs new file mode 100644 index 0000000..647a75c --- /dev/null +++ b/src/responders/trigger.rs @@ -0,0 +1,214 @@ +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 event 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) +} + +/// The `HX-Trigger` header. +/// +/// Allows you to trigger client-side events. +/// +/// 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: Into, +{ + fn from(value: T) -> Self { + Self(value.into_iter().map(Into::into).collect()) + } +} + +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. +/// +/// 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: Into, +{ + fn from(value: T) -> Self { + Self(value.into_iter().map(Into::into).collect()) + } +} + +impl IntoResponseParts for HxResponseTriggerAfterSettle { + 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-Swap` header. +/// +/// Allows you to trigger client-side events after the swap step. +/// +/// 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: Into, +{ + fn from(value: T) -> Self { + Self(value.into_iter().map(Into::into).collect()) + } +} + +impl IntoResponseParts for HxResponseTriggerAfterSwap { + 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) + } +} + +#[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)); + } +}