diff --git a/src/responders.rs b/src/responders.rs index 99aef61..0b40e9e 100644 --- a/src/responders.rs +++ b/src/responders.rs @@ -1,6 +1,6 @@ //! 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}; @@ -32,15 +32,71 @@ const HX_SWAP_NONE: &str = "none"; /// /// See for more information. #[derive(Debug, Clone)] -pub struct HxLocation(pub Uri); +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(self.0.to_string())?, + HeaderValue::from_maybe_shared(header)?, ); Ok(res) @@ -71,6 +127,14 @@ impl IntoResponseParts for HxPushUrl { } } +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 +157,14 @@ impl IntoResponseParts for HxRedirect { } } +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. @@ -142,6 +214,14 @@ impl IntoResponseParts for HxReplaceUrl { } } +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. @@ -206,6 +286,71 @@ 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 @@ -217,15 +362,15 @@ impl IntoResponseParts for HxReselect { /// /// See for more information. #[derive(Debug, Clone)] -pub struct HxResponseTrigger(pub Vec); +pub struct HxResponseTrigger(pub Vec); impl From for HxResponseTrigger where T: IntoIterator, - T::Item: ToString, + T::Item: Into, { fn from(value: T) -> Self { - Self(value.into_iter().map(|s| s.to_string()).collect()) + Self(value.into_iter().map(Into::into).collect()) } } @@ -233,15 +378,8 @@ 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(), - )?, - ); + res.headers_mut() + .insert(headers::HX_TRIGGER, events_to_header_value(self.0)?); Ok(res) } @@ -259,15 +397,15 @@ impl IntoResponseParts for HxResponseTrigger { /// /// See for more information. #[derive(Debug, Clone)] -pub struct HxResponseTriggerAfterSettle(pub Vec); +pub struct HxResponseTriggerAfterSettle(pub Vec); impl From for HxResponseTriggerAfterSettle where T: IntoIterator, - T::Item: ToString, + T::Item: Into, { fn from(value: T) -> Self { - Self(value.into_iter().map(|s| s.to_string()).collect()) + Self(value.into_iter().map(Into::into).collect()) } } @@ -275,15 +413,8 @@ 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(), - )?, - ); + res.headers_mut() + .insert(headers::HX_TRIGGER, events_to_header_value(self.0)?); Ok(res) } @@ -300,15 +431,15 @@ impl IntoResponseParts for HxResponseTriggerAfterSettle { /// /// See for more information. #[derive(Debug, Clone)] -pub struct HxResponseTriggerAfterSwap(pub Vec); +pub struct HxResponseTriggerAfterSwap(pub Vec); impl From for HxResponseTriggerAfterSwap where T: IntoIterator, - T::Item: ToString, + T::Item: Into, { fn from(value: T) -> Self { - Self(value.into_iter().map(|s| s.to_string()).collect()) + Self(value.into_iter().map(Into::into).collect()) } } @@ -316,15 +447,8 @@ 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(), - )?, - ); + res.headers_mut() + .insert(headers::HX_TRIGGER, events_to_header_value(self.0)?); Ok(res) } @@ -353,6 +477,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 { @@ -373,7 +523,8 @@ pub enum HxError { InvalidHeaderValue(InvalidHeaderValue), #[cfg(feature = "serde")] - Serialization(serde_json::Error), + #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] + Json(serde_json::Error), } impl From for HxError { @@ -383,9 +534,10 @@ impl From for HxError { } #[cfg(feature = "serde")] +#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] impl From for HxError { fn from(value: serde_json::Error) -> Self { - Self::Serialization(value) + Self::Json(value) } } @@ -397,7 +549,7 @@ impl IntoResponse for HxError { } #[cfg(feature = "serde")] - Self::Serialization(_) => ( + Self::Json(_) => ( StatusCode::INTERNAL_SERVER_ERROR, "failed to serialize event", ) @@ -405,3 +557,51 @@ 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/serde.rs b/src/responders/serde.rs index 7bfb875..18f997e 100644 --- a/src/responders/serde.rs +++ b/src/responders/serde.rs @@ -1,54 +1,47 @@ -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, -}; +use crate::SwapOption; -/// The `HX-Location` header. +/// More options for `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, +/// - `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 HxLocation { - pub fn from_uri(uri: &Uri) -> Self { - Self { - path: uri.to_string(), +impl LocationOptions { + pub(super) fn is_default(&self) -> bool { + let Self { source: None, event: None, handler: None, @@ -56,204 +49,11 @@ impl HxLocation { 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)?)? + } = self + else { + return false; }; - 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)); + true } }