From a22339936174542f19e9e21432e6ce6692fe4d08 Mon Sep 17 00:00:00 2001 From: ItsEthra <107059409+ItsEthra@users.noreply.github.com> Date: Fri, 1 Dec 2023 21:40:34 +0300 Subject: [PATCH 01/11] Code dedup --- src/responders.rs | 284 ++++++++++++++++++++++++++++++++++------ src/responders/serde.rs | 254 ++++------------------------------- 2 files changed, 269 insertions(+), 269 deletions(-) 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 } } From 1dfff29673e3beb13e741883292ebad2c942c248 Mon Sep 17 00:00:00 2001 From: ItsEthra <107059409+ItsEthra@users.noreply.github.com> Date: Fri, 1 Dec 2023 22:05:31 +0300 Subject: [PATCH 02/11] Moved HxLocation && HxTrigger* into different modules --- src/responders.rs | 306 +------------------------------------ src/responders/location.rs | 175 +++++++++++++++++++++ src/responders/serde.rs | 59 ------- src/responders/trigger.rs | 214 ++++++++++++++++++++++++++ 4 files changed, 393 insertions(+), 361 deletions(-) create mode 100644 src/responders/location.rs delete mode 100644 src/responders/serde.rs create mode 100644 src/responders/trigger.rs 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)); + } +} From f07518d5a28bf617d43b07d0147918c1772707db Mon Sep 17 00:00:00 2001 From: ItsEthra <107059409+ItsEthra@users.noreply.github.com> Date: Fri, 1 Dec 2023 22:15:10 +0300 Subject: [PATCH 03/11] Fixed docs --- Cargo.toml | 3 +++ README.md | 22 ++++++++++------------ src/responders/trigger.rs | 23 ++++++++++++++++------- 3 files changed, 29 insertions(+), 19 deletions(-) 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..1ced135 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` | `HxResponseTriggerAfterSettle` | `axum_htmx::serde::HxEvent` | +| `HX-Trigger-After-Swap` | `HxResponseTriggerAfterSwap` | `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/responders/trigger.rs b/src/responders/trigger.rs index 647a75c..82cb71f 100644 --- a/src/responders/trigger.rs +++ b/src/responders/trigger.rs @@ -23,7 +23,7 @@ impl HxEvent { } } - /// Creates new event with event data. + /// Creates new event with data. #[cfg(feature = "serde")] #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] pub fn new_with_data( @@ -116,8 +116,10 @@ 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)?); + if !self.0.is_empty() { + res.headers_mut() + .insert(headers::HX_TRIGGER, events_to_header_value(self.0)?); + } Ok(res) } @@ -148,8 +150,10 @@ 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)?); + if !self.0.is_empty() { + res.headers_mut() + .insert(headers::HX_TRIGGER, events_to_header_value(self.0)?); + } Ok(res) } @@ -180,8 +184,10 @@ 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)?); + if !self.0.is_empty() { + res.headers_mut() + .insert(headers::HX_TRIGGER, events_to_header_value(self.0)?); + } Ok(res) } @@ -210,5 +216,8 @@ mod tests { 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::from(["foo", "bar"]).0).unwrap(); + assert_eq!(value, HeaderValue::from_static("foo, bar")); } } From a04e131a692fcdf18d3fc6f3dabaa3ff1c8194f5 Mon Sep 17 00:00:00 2001 From: ItsEthra <107059409+ItsEthra@users.noreply.github.com> Date: Sun, 3 Dec 2023 15:03:08 +0300 Subject: [PATCH 04/11] Combined HxResposeTrigger* into one struct --- README.md | 6 +- src/responders/trigger.rs | 128 ++++++++++++++------------------------ 2 files changed, 51 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 1ced135..51cf392 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ any of your responses. | `HX-Reswap` | `HxReswap` | `axum_htmx::responders::SwapOption` | | `HX-Retarget` | `HxRetarget` | `String` | | `HX-Reselect` | `HxReselect` | `String` | -| `HX-Trigger` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` | -| `HX-Trigger-After-Settle` | `HxResponseTriggerAfterSettle` | `axum_htmx::serde::HxEvent` | -| `HX-Trigger-After-Swap` | `HxResponseTriggerAfterSwap` | `axum_htmx::serde::HxEvent` | +| `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 diff --git a/src/responders/trigger.rs b/src/responders/trigger.rs index 82cb71f..343d216 100644 --- a/src/responders/trigger.rs +++ b/src/responders/trigger.rs @@ -91,24 +91,53 @@ fn events_to_header_value(events: Vec) -> Result for more information. #[derive(Debug, Clone)] -pub struct HxResponseTrigger(pub Vec); +pub struct HxResponseTrigger { + pub mode: TriggerMode, + pub events: 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 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) } } @@ -116,77 +145,15 @@ impl IntoResponseParts for HxResponseTrigger { type Error = HxError; fn into_response_parts(self, mut res: ResponseParts) -> Result { - if !self.0.is_empty() { + 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(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 { - if !self.0.is_empty() { - 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 { - if !self.0.is_empty() { - res.headers_mut() - .insert(headers::HX_TRIGGER, events_to_header_value(self.0)?); + .insert(header, events_to_header_value(self.events)?); } Ok(res) @@ -217,7 +184,8 @@ mod tests { assert_eq!(header_value, HeaderValue::from_static(expected_value)); - let value = events_to_header_value(HxResponseTrigger::from(["foo", "bar"]).0).unwrap(); + let value = + events_to_header_value(HxResponseTrigger::normal(["foo", "bar"]).events).unwrap(); assert_eq!(value, HeaderValue::from_static("foo, bar")); } } From 140a74c0715c2004ebf6af5948983a52ce540f4f Mon Sep 17 00:00:00 2001 From: ItsEthra <107059409+ItsEthra@users.noreply.github.com> Date: Sun, 3 Dec 2023 15:13:07 +0300 Subject: [PATCH 05/11] Use http::Uri in HxCurrentUrl --- src/extractors.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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)); From d7a8ee55b1ee3428e32c9136c8fc1f0d9c71644a Mon Sep 17 00:00:00 2001 From: ItsEthra <107059409+ItsEthra@users.noreply.github.com> Date: Sun, 3 Dec 2023 15:16:33 +0300 Subject: [PATCH 06/11] Hidden ResponseFuture type --- src/guard.rs | 58 ++++++++++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 27 deletions(-) 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)) + } } } } From 523cbb03710dd234dc8f963afaad0c66521cf8ae Mon Sep 17 00:00:00 2001 From: ItsEthra <107059409+ItsEthra@users.noreply.github.com> Date: Sun, 3 Dec 2023 18:54:58 +0300 Subject: [PATCH 07/11] Added helper methods to HxLocation --- src/responders/location.rs | 53 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/src/responders/location.rs b/src/responders/location.rs index 8ec740b..39b5a7d 100644 --- a/src/responders/location.rs +++ b/src/responders/location.rs @@ -27,6 +27,7 @@ pub struct HxLocation { } impl HxLocation { + /// Creates location from [`Uri`] without any options. pub fn from_uri(uri: Uri) -> Self { Self { #[cfg(feature = "serde")] @@ -35,12 +36,36 @@ impl HxLocation { } } + /// 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() { @@ -62,11 +87,35 @@ impl HxLocation { } } +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(value: &'a str) -> Result { - Ok(Self::from_uri(Uri::from_str(value)?)) + 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) } } From 9c894b8d1904f327e00af52235c8527cf4167242 Mon Sep 17 00:00:00 2001 From: ItsEthra <107059409+ItsEthra@users.noreply.github.com> Date: Sun, 3 Dec 2023 19:33:31 +0300 Subject: [PATCH 08/11] Added more from impls for responders --- src/responders.rs | 42 +++++++++++++++++++++++++++++++++++++++ src/responders/trigger.rs | 13 ++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/responders.rs b/src/responders.rs index beebc10..67b5019 100644 --- a/src/responders.rs +++ b/src/responders.rs @@ -45,6 +45,12 @@ 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; @@ -75,6 +81,12 @@ 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; @@ -91,6 +103,12 @@ impl<'a> TryFrom<&'a str> 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; @@ -132,6 +150,12 @@ 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; @@ -158,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 @@ -181,6 +211,12 @@ impl IntoResponseParts for HxRetarget { } } +impl From for HxRetarget { + fn from(value: String) -> Self { + Self(value) + } +} + /// The `HX-Reselect` header. /// /// A CSS selector that allows you to choose which part of the response is used @@ -204,6 +240,12 @@ impl IntoResponseParts for HxReselect { } } +impl From for HxReselect { + fn from(value: String) -> Self { + Self(value) + } +} + /// Values of the `hx-swap` attribute. // serde::Serialize is implemented in responders/serde.rs #[derive(Debug, Copy, Clone)] diff --git a/src/responders/trigger.rs b/src/responders/trigger.rs index 343d216..e96440b 100644 --- a/src/responders/trigger.rs +++ b/src/responders/trigger.rs @@ -141,6 +141,19 @@ impl HxResponseTrigger { } } +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; From 8e1094e043d04ea1d029c9096c0a1afb6f83a8e0 Mon Sep 17 00:00:00 2001 From: ItsEthra <107059409+ItsEthra@users.noreply.github.com> Date: Sun, 3 Dec 2023 21:48:16 +0300 Subject: [PATCH 09/11] Generalized response impls --- src/responders.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/responders.rs b/src/responders.rs index 67b5019..20a6160 100644 --- a/src/responders.rs +++ b/src/responders.rs @@ -211,9 +211,9 @@ impl IntoResponseParts for HxRetarget { } } -impl From for HxRetarget { - fn from(value: String) -> Self { - Self(value) +impl> From for HxRetarget { + fn from(value: T) -> Self { + Self(value.into()) } } @@ -240,9 +240,9 @@ impl IntoResponseParts for HxReselect { } } -impl From for HxReselect { - fn from(value: String) -> Self { - Self(value) +impl> From for HxReselect { + fn from(value: T) -> Self { + Self(value.into()) } } From c1c54a17f58561710c1b8f4485eda330bac30cd4 Mon Sep 17 00:00:00 2001 From: ItsEthra <107059409+ItsEthra@users.noreply.github.com> Date: Sun, 3 Dec 2023 22:38:43 +0300 Subject: [PATCH 10/11] Inlined reexports --- src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index cd77122..a57fa29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,9 +9,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::*; From 2d3b94d37d86384157916f29c423d5020a75d96f Mon Sep 17 00:00:00 2001 From: ItsEthra <107059409+ItsEthra@users.noreply.github.com> Date: Sun, 3 Dec 2023 19:50:16 +0300 Subject: [PATCH 11/11] Moved HxError && implemented Error trait --- src/error.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 3 +++ src/responders.rs | 46 +++------------------------------------------- 3 files changed, 51 insertions(+), 43 deletions(-) create mode 100644 src/error.rs 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/lib.rs b/src/lib.rs index a57fa29..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")))] diff --git a/src/responders.rs b/src/responders.rs index 20a6160..fdc06e4 100644 --- a/src/responders.rs +++ b/src/responders.rs @@ -2,10 +2,10 @@ 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}; mod location; pub use location::*; @@ -309,43 +309,3 @@ impl From for HeaderValue { } } } - -#[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 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::Json(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "failed to serialize event", - ) - .into_response(), - } - } -}