Merge pull request #9 from ItsEthra/code-dedup

Merged serde responders to remove code 

- Removed responders::serde, now all triggers in responders module use HxEvent which has additional data fields when serde feature is enabled.
- Removed responders::serde::HxLocation, now responders::HxLocation is a struct with a uri field and additional options field when serde feature is enabled.
- HxResponseTrigger* responders are now in responders/trigger.rs, HxLocation responder is now in responders/location.rs.
- Implemented TryFrom<&str> for uri-like responders.
- Renamed HxError::Serialization to HxError::Json.
- Added test for HxLocation serialization.
- Do not add HxResponseTrigger* header if there are no events.
This commit is contained in:
Rob 2023-12-03 15:18:12 -05:00 committed by GitHub
commit 1b3febbf16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 628 additions and 496 deletions

View file

@ -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

View file

@ -74,11 +74,9 @@ any of your responses.
| `HX-Reswap` | `HxReswap` | `axum_htmx::responders::SwapOption` |
| `HX-Retarget` | `HxRetarget` | `String` |
| `HX-Reselect` | `HxReselect` | `String` |
| `HX-Trigger` | `HxResponseTrigger` | `String` or `axum_htmx::serde::HxEvent`* |
| `HX-Trigger-After-Settle` | `HxResponseTriggerAfterSettle` | `String` or `axum_htmx::serde::HxEvent`* |
| `HX-Trigger-After-Swap` | `HxResponseTriggerAfterSwap` | `String` or `axum_htmx::serde::HxEvent`* |
_* requires the `serde` feature flag to be enabled._
| `HX-Trigger` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` |
| `HX-Trigger-After-Settle` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` |
| `HX-Trigger-After-Swap` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` |
## Request Guards
@ -137,7 +135,7 @@ use axum_htmx::HxResponseTrigger;
async fn index() -> (&'static str, HxResponseTrigger) {
(
"Hello, world!",
HxResponseTrigger(vec!["my-event".to_string()]),
HxResponseTrigger::from(["my-event", "second-event"]),
)
}
```
@ -146,16 +144,16 @@ async fn index() -> (&'static str, HxResponseTrigger) {
can use via the `serde` feature flag and the `HxEvent` type.
```rust
use axum_htmx::serde::HxEvent;
use serde_json::json;
// Note that we are using `HxResponseTrigger` from the `axum_htmx::serde` module
// instead of the root module.
use axum_htmx::serde::HxResponseTrigger;
use axum_htmx::{HxEvent, HxResponseTrigger};
async fn index() -> (&'static str, HxResponseTrigger) {
let event = HxEvent::new_with_data(
"my-event",
// May be any object that implements `serde::Serialize`
json!({"level": "info", "message": {
"title": "Hello, world!",
"body": "This is a test message.",
@ -188,10 +186,10 @@ fn router_two() -> Router {
## Feature Flags
<!-- markdownlint-disable -->
| 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` |
<!-- markdownlint-enable -->
## Contributing

45
src/error.rs Normal file
View file

@ -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<InvalidHeaderValue> for HxError {
fn from(value: InvalidHeaderValue) -> Self {
Self::InvalidHeaderValue(value)
}
}
#[cfg(feature = "serde")]
#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
impl From<serde_json::Error> 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()
}
}

View file

@ -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<String>);
pub struct HxCurrentUrl(pub Option<http::Uri>);
#[async_trait]
impl<S> FromRequestParts<S> for HxCurrentUrl
@ -56,9 +56,11 @@ where
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
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::<http::Uri>().ok());
return Ok(HxCurrentUrl(url));
}
return Ok(HxCurrentUrl(None));

View file

@ -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<Result<(), Self::Error>> {
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<Output = Result<Response<B>, E>>,
B: Default,
{
type Output = Result<Response<B>, E>;
impl<'a, F, B, E> Future for ResponseFuture<'a, F>
where
F: Future<Output = Result<Response<B>, E>>,
B: Default,
{
type Output = Result<Response<B>, E>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
let response: Response<B> = ready!(this.response_future.poll(cx))?;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
let response: Response<B> = 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))
}
}
}
}

View file

@ -2,6 +2,9 @@
#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
mod error;
pub use error::*;
pub mod extractors;
#[cfg(feature = "guards")]
#[cfg_attr(feature = "unstable", doc(cfg(feature = "guards")))]
@ -9,9 +12,13 @@ pub mod guard;
pub mod headers;
pub mod responders;
#[doc(inline)]
pub use extractors::*;
#[cfg(feature = "guards")]
#[cfg_attr(feature = "unstable", doc(cfg(feature = "guards")))]
#[doc(inline)]
pub use guard::*;
#[doc(inline)]
pub use headers::*;
#[doc(inline)]
pub use responders::*;

View file

@ -1,15 +1,16 @@
//! Axum responses for htmx response headers.
use std::convert::Infallible;
use std::{convert::Infallible, str::FromStr};
use axum_core::response::{IntoResponse, IntoResponseParts, ResponseParts};
use http::{header::InvalidHeaderValue, HeaderValue, StatusCode, Uri};
use axum_core::response::{IntoResponseParts, ResponseParts};
use http::{HeaderValue, Uri};
use crate::headers;
use crate::{headers, HxError};
#[cfg(feature = "serde")]
#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
pub mod serde;
mod location;
pub use location::*;
mod trigger;
pub use trigger::*;
const HX_SWAP_INNER_HTML: &str = "innerHTML";
const HX_SWAP_OUTER_HTML: &str = "outerHTML";
@ -20,33 +21,6 @@ const HX_SWAP_AFTER_END: &str = "afterend";
const HX_SWAP_DELETE: &str = "delete";
const HX_SWAP_NONE: &str = "none";
/// The `HX-Location` header.
///
/// This response header can be used to trigger a client side redirection
/// without reloading the whole page. If you intend to redirect to a specific
/// target on the page, you must enable the `serde` feature flag and use
/// `axum_htmx::responders::serde::HxLocation` instead.
///
/// Will fail if the supplied Uri contains characters that are not visible ASCII
/// (32-127).
///
/// See <https://htmx.org/headers/hx-location/> for more information.
#[derive(Debug, Clone)]
pub struct HxLocation(pub Uri);
impl IntoResponseParts for HxLocation {
type Error = HxError;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
res.headers_mut().insert(
headers::HX_LOCATION,
HeaderValue::from_maybe_shared(self.0.to_string())?,
);
Ok(res)
}
}
/// The `HX-Push-Url` header.
///
/// Pushes a new url into the history stack.
@ -71,6 +45,20 @@ impl IntoResponseParts for HxPushUrl {
}
}
impl From<Uri> for HxPushUrl {
fn from(uri: Uri) -> Self {
Self(uri)
}
}
impl<'a> TryFrom<&'a str> for HxPushUrl {
type Error = <Uri as FromStr>::Err;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
Ok(Self(value.parse()?))
}
}
/// The `HX-Redirect` header.
///
/// Can be used to do a client-side redirect to a new location.
@ -93,6 +81,20 @@ impl IntoResponseParts for HxRedirect {
}
}
impl From<Uri> for HxRedirect {
fn from(uri: Uri) -> Self {
Self(uri)
}
}
impl<'a> TryFrom<&'a str> for HxRedirect {
type Error = <Uri as FromStr>::Err;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
Ok(Self(value.parse()?))
}
}
/// The `HX-Refresh`header.
///
/// If set to `true` the client-side will do a full refresh of the page.
@ -101,6 +103,12 @@ impl IntoResponseParts for HxRedirect {
#[derive(Debug, Copy, Clone)]
pub struct HxRefresh(pub bool);
impl From<bool> for HxRefresh {
fn from(value: bool) -> Self {
Self(value)
}
}
impl IntoResponseParts for HxRefresh {
type Error = Infallible;
@ -142,6 +150,20 @@ impl IntoResponseParts for HxReplaceUrl {
}
}
impl From<Uri> for HxReplaceUrl {
fn from(uri: Uri) -> Self {
Self(uri)
}
}
impl<'a> TryFrom<&'a str> for HxReplaceUrl {
type Error = <Uri as FromStr>::Err;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
Ok(Self(value.parse()?))
}
}
/// The `HX-Reswap` header.
///
/// Allows you to specidy how the response will be swapped.
@ -160,6 +182,12 @@ impl IntoResponseParts for HxReswap {
}
}
impl From<SwapOption> for HxReswap {
fn from(value: SwapOption) -> Self {
Self(value)
}
}
/// The `HX-Retarget` header.
///
/// A CSS selector that updates the target of the content update to a different
@ -183,6 +211,12 @@ impl IntoResponseParts for HxRetarget {
}
}
impl<T: Into<String>> From<T> for HxRetarget {
fn from(value: T) -> Self {
Self(value.into())
}
}
/// The `HX-Reselect` header.
///
/// A CSS selector that allows you to choose which part of the response is used
@ -206,127 +240,9 @@ impl IntoResponseParts for HxReselect {
}
}
/// The `HX-Trigger` header.
///
/// Allows you to trigger client-side events. If you intend to add data to your
/// events, you must enable the `serde` feature flag and use
/// `axum_htmx::responders::serde::HxResponseTrigger` instead.
///
/// Will fail if the supplied events contain or produce characters that are not
/// visible ASCII (32-127) when serializing to JSON.
///
/// See <https://htmx.org/headers/hx-trigger/> for more information.
#[derive(Debug, Clone)]
pub struct HxResponseTrigger(pub Vec<String>);
impl<T> From<T> for HxResponseTrigger
where
T: IntoIterator,
T::Item: ToString,
{
impl<T: Into<String>> From<T> for HxReselect {
fn from(value: T) -> Self {
Self(value.into_iter().map(|s| s.to_string()).collect())
}
}
impl IntoResponseParts for HxResponseTrigger {
type Error = HxError;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
res.headers_mut().insert(
headers::HX_TRIGGER,
HeaderValue::from_maybe_shared(
self.0
.into_iter()
.reduce(|acc, e| acc + ", " + &e)
.unwrap_or_default(),
)?,
);
Ok(res)
}
}
/// The `HX-Trigger-After-Settle` header.
///
/// Allows you to trigger client-side events after the settle step. If you
/// intend to add data to your events, you must enable the `serde` feature flag
/// and use `axum_htmx::responders::serde::HxResponseTriggerAfterSettle`
/// instead.
///
/// Will fail if the supplied events contain or produce characters that are not
/// visible ASCII (32-127) when serializing to JSON.
///
/// See <https://htmx.org/headers/hx-trigger/> for more information.
#[derive(Debug, Clone)]
pub struct HxResponseTriggerAfterSettle(pub Vec<String>);
impl<T> From<T> for HxResponseTriggerAfterSettle
where
T: IntoIterator,
T::Item: ToString,
{
fn from(value: T) -> Self {
Self(value.into_iter().map(|s| s.to_string()).collect())
}
}
impl IntoResponseParts for HxResponseTriggerAfterSettle {
type Error = HxError;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
res.headers_mut().insert(
headers::HX_TRIGGER_AFTER_SETTLE,
HeaderValue::from_maybe_shared(
self.0
.into_iter()
.reduce(|acc, e| acc + ", " + &e)
.unwrap_or_default(),
)?,
);
Ok(res)
}
}
/// The `HX-Trigger-After-Swap` header.
///
/// Allows you to trigger client-side events after the swap step. If you intend
/// to add data to your events, you must enable the `serde` feature flag and use
/// `axum_htmx::responders::serde::HxResponseTriggerAfterSwap` instead.
///
/// Will fail if the supplied events contain or produce characters that are not
/// visible ASCII (32-127) when serializing to JSON.
///
/// See <https://htmx.org/headers/hx-trigger/> for more information.
#[derive(Debug, Clone)]
pub struct HxResponseTriggerAfterSwap(pub Vec<String>);
impl<T> From<T> for HxResponseTriggerAfterSwap
where
T: IntoIterator,
T::Item: ToString,
{
fn from(value: T) -> Self {
Self(value.into_iter().map(|s| s.to_string()).collect())
}
}
impl IntoResponseParts for HxResponseTriggerAfterSwap {
type Error = HxError;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
res.headers_mut().insert(
headers::HX_TRIGGER_AFTER_SWAP,
HeaderValue::from_maybe_shared(
self.0
.into_iter()
.reduce(|acc, e| acc + ", " + &e)
.unwrap_or_default(),
)?,
);
Ok(res)
Self(value.into())
}
}
@ -353,6 +269,32 @@ pub enum SwapOption {
None,
}
// can be removed and automatically derived when
// https://github.com/serde-rs/serde/issues/2485 is implemented
#[cfg(feature = "serde")]
impl ::serde::Serialize for SwapOption {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<SwapOption> for HeaderValue {
fn from(value: SwapOption) -> Self {
match value {
@ -367,41 +309,3 @@ impl From<SwapOption> for HeaderValue {
}
}
}
#[derive(Debug)]
pub enum HxError {
InvalidHeaderValue(InvalidHeaderValue),
#[cfg(feature = "serde")]
Serialization(serde_json::Error),
}
impl From<InvalidHeaderValue> for HxError {
fn from(value: InvalidHeaderValue) -> Self {
Self::InvalidHeaderValue(value)
}
}
#[cfg(feature = "serde")]
impl From<serde_json::Error> for HxError {
fn from(value: serde_json::Error) -> Self {
Self::Serialization(value)
}
}
impl IntoResponse for HxError {
fn into_response(self) -> axum_core::response::Response {
match self {
Self::InvalidHeaderValue(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "invalid header value").into_response()
}
#[cfg(feature = "serde")]
Self::Serialization(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"failed to serialize event",
)
.into_response(),
}
}
}

224
src/responders/location.rs Normal file
View file

@ -0,0 +1,224 @@
use std::str::FromStr;
use axum_core::response::{IntoResponseParts, ResponseParts};
use http::{HeaderValue, Uri};
use crate::{headers, HxError};
/// The `HX-Location` header.
///
/// This response header can be used to trigger a client side redirection
/// without reloading the whole page. If you intend to redirect to a specific
/// target on the page, you must enable the `serde` feature flag and specify
/// [`LocationOptions`].
///
/// Will fail if the supplied Uri contains characters that are not visible ASCII
/// (32-127).
///
/// See <https://htmx.org/headers/hx-location/> for more information.
#[derive(Debug, Clone)]
pub struct HxLocation {
/// Uri of the new location.
pub uri: Uri,
/// Extra options.
#[cfg(feature = "serde")]
#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
pub options: LocationOptions,
}
impl HxLocation {
/// Creates location from [`Uri`] without any options.
pub fn from_uri(uri: Uri) -> Self {
Self {
#[cfg(feature = "serde")]
options: LocationOptions::default(),
uri,
}
}
/// Creates location from [`Uri`] and options.
#[cfg(feature = "serde")]
#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
pub fn from_uri_with_options(uri: Uri, options: LocationOptions) -> Self {
Self { uri, options }
}
/// Parses `uri` and sets it as location.
#[allow(clippy::should_implement_trait)]
pub fn from_str(uri: impl AsRef<str>) -> Result<Self, http::uri::InvalidUri> {
Ok(Self {
#[cfg(feature = "serde")]
options: LocationOptions::default(),
uri: uri.as_ref().parse::<Uri>()?,
})
}
/// 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<str>,
options: LocationOptions,
) -> Result<Self, http::uri::InvalidUri> {
Ok(Self {
options,
uri: uri.as_ref().parse::<Uri>()?,
})
}
#[cfg(feature = "serde")]
fn into_header_with_options(self) -> Result<String, HxError> {
if self.options.is_default() {
return Ok(self.uri.to_string());
}
#[derive(::serde::Serialize)]
struct LocWithOpts {
path: String,
#[serde(flatten)]
opts: LocationOptions,
}
let loc_with_opts = LocWithOpts {
path: self.uri.to_string(),
opts: self.options,
};
Ok(serde_json::to_string(&loc_with_opts)?)
}
}
impl From<Uri> 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 = <Uri as FromStr>::Err;
fn try_from(uri: &'a str) -> Result<Self, Self::Error> {
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 = <Uri as FromStr>::Err;
fn try_from((uri, options): (&'a str, LocationOptions)) -> Result<Self, Self::Error> {
Self::from_str_with_options(uri, options)
}
}
impl IntoResponseParts for HxLocation {
type Error = HxError;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
#[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<String>,
/// An event that "triggered" the request.
#[serde(skip_serializing_if = "Option::is_none")]
pub event: Option<String>,
/// A callback that will handle the response HTML.
#[serde(skip_serializing_if = "Option::is_none")]
pub handler: Option<String>,
/// The target to swap the response into.
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
/// How the response will be swapped in relative to the target.
#[serde(skip_serializing_if = "Option::is_none")]
pub swap: Option<crate::SwapOption>,
/// Values to submit with the request.
#[serde(skip_serializing_if = "Option::is_none")]
pub values: Option<serde_json::Value>,
/// Headers to submit with the request.
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<serde_json::Value>,
}
#[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"}"#
);
}
}

View file

@ -1,259 +0,0 @@
use std::collections::HashMap;
use axum_core::response::{IntoResponseParts, ResponseParts};
use http::{HeaderValue, Uri};
use serde::Serialize;
use serde_json::Value;
use crate::{
headers,
responders::{
HX_SWAP_AFTER_BEGIN, HX_SWAP_AFTER_END, HX_SWAP_BEFORE_BEGIN, HX_SWAP_BEFORE_END,
HX_SWAP_DELETE, HX_SWAP_INNER_HTML, HX_SWAP_NONE, HX_SWAP_OUTER_HTML,
},
HxError, SwapOption,
};
/// The `HX-Location` header.
///
/// This response header can be used to trigger a client side redirection
/// without reloading the whole page. If you only intend to redirect to the
/// `document.body`, as opposed to a specific target, you can use
/// `axum_htmx::HxResponseLocation` instead.
///
/// Will fail if the supplied data contains or produces characters that are not
/// visible ASCII (32-127) when serializing to JSON.
///
/// See <https://htmx.org/headers/hx-location/> for more information.
#[derive(Debug, Clone, Serialize)]
pub struct HxLocation {
/// Url to load the response from.
pub path: String,
/// The source element of the request.
pub source: Option<String>,
/// An event that "triggered" the request.
pub event: Option<String>,
/// A callback that will handle the response HTML.
pub handler: Option<String>,
/// The target to swap the response into.
pub target: Option<String>,
/// How the response will be swapped in relative to the target.
pub swap: Option<SwapOption>,
/// Values to submit with the request.
pub values: Option<Value>,
/// Headers to submit with the request.
pub headers: Option<Value>,
}
impl HxLocation {
pub fn from_uri(uri: &Uri) -> Self {
Self {
path: uri.to_string(),
source: None,
event: None,
handler: None,
target: None,
swap: None,
values: None,
headers: None,
}
}
}
impl IntoResponseParts for HxLocation {
type Error = HxError;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
let header_value = if self.source.is_none()
&& self.event.is_none()
&& self.handler.is_none()
&& self.target.is_none()
&& self.swap.is_none()
&& self.values.is_none()
&& self.headers.is_none()
{
HeaderValue::from_str(&self.path)?
} else {
HeaderValue::from_maybe_shared(serde_json::to_string(&self)?)?
};
res.headers_mut().insert(headers::HX_LOCATION, header_value);
Ok(res)
}
}
/// The `HX-Trigger` header.
///
/// Allows you to trigger client-side events. If you only need to send bare
/// events, you can use `axum_htmx::HxResponseTrigger` instead.
///
/// Will fail if the supplied events contain or produce characters that are not
/// visible ASCII (32-127) when serializing to JSON.
///
/// See <https://htmx.org/headers/hx-trigger/> for more information.
#[derive(Debug, Clone)]
pub struct HxResponseTrigger(pub Vec<HxEvent>);
impl IntoResponseParts for HxResponseTrigger {
type Error = HxError;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
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 <https://htmx.org/headers/hx-trigger/> for more information.
#[derive(Debug, Clone)]
pub struct HxResponseTriggerAfterSettle(pub Vec<HxEvent>);
impl IntoResponseParts for HxResponseTriggerAfterSettle {
type Error = HxError;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
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 <https://htmx.org/headers/hx-trigger/> for more information.
#[derive(Debug, Clone)]
pub struct HxResponseTriggerAfterSwap(pub Vec<HxEvent>);
impl IntoResponseParts for HxResponseTriggerAfterSwap {
type Error = HxError;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
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<Value>,
}
impl HxEvent {
pub fn new<T: Serialize>(name: String) -> Self {
Self {
name: name.to_string(),
data: None,
}
}
pub fn new_with_data<T: Serialize>(name: &str, data: T) -> Result<Self, serde_json::Error> {
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<HxEvent>) -> Result<HeaderValue, HxError> {
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::<HashMap<String, Value>>();
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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));
}
}

204
src/responders/trigger.rs Normal file
View file

@ -0,0 +1,204 @@
use axum_core::response::{IntoResponseParts, ResponseParts};
use crate::{headers, HxError};
/// Represents a client-side event carrying optional data.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct HxEvent {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg(feature = "serde")]
#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
pub data: Option<serde_json::Value>,
}
impl HxEvent {
/// Creates new event with no associated data.
pub fn new(name: String) -> Self {
Self {
name: name.to_string(),
#[cfg(feature = "serde")]
data: None,
}
}
/// Creates new event with data.
#[cfg(feature = "serde")]
#[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
pub fn new_with_data<T: ::serde::Serialize>(
name: impl AsRef<str>,
data: T,
) -> Result<Self, serde_json::Error> {
let data = serde_json::to_value(data)?;
Ok(Self {
name: name.as_ref().to_owned(),
#[cfg(feature = "serde")]
data: Some(data),
})
}
}
impl<N: AsRef<str>> From<N> 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<HxEvent>) -> Result<http::HeaderValue, HxError> {
let header = events
.into_iter()
.map(|HxEvent { name }| name)
.collect::<Vec<_>>()
.join(", ");
http::HeaderValue::from_str(&header).map_err(Into::into)
}
#[cfg(feature = "serde")]
fn events_to_header_value(events: Vec<HxEvent>) -> Result<http::HeaderValue, HxError> {
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::<HashMap<String, Value>>();
serde_json::to_string(&header_value)?
} else {
// no event contains data, the event names can be put in the header
// value separated by a comma.
events
.into_iter()
.map(|e| e.name)
.reduce(|acc, e| acc + ", " + &e)
.unwrap_or_default()
};
HeaderValue::from_maybe_shared(header_value).map_err(HxError::from)
}
/// Describes when should event be triggered.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TriggerMode {
Normal,
AfterSettle,
AfterSwap,
}
/// The `HX-Trigger*` header.
///
/// Allows you to trigger client-side events.
/// Corresponds to `HX-Trigger`, `HX-Trigger-After-Settle` and `HX-Trigger-After-Swap` headers.
/// To change when events trigger use appropriate `mode`.
///
/// Will fail if the supplied events contain or produce characters that are not
/// visible ASCII (32-127) when serializing to JSON.
///
/// See <https://htmx.org/headers/hx-trigger/> for more information.
#[derive(Debug, Clone)]
pub struct HxResponseTrigger {
pub mode: TriggerMode,
pub events: Vec<HxEvent>,
}
impl HxResponseTrigger {
/// Creates new [trigger](https://htmx.org/headers/hx-trigger/) with specified mode and events.
pub fn new<T: Into<HxEvent>>(mode: TriggerMode, events: impl IntoIterator<Item = T>) -> 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<T: Into<HxEvent>>(events: impl IntoIterator<Item = T>) -> Self {
Self::new(TriggerMode::Normal, events)
}
/// Creates new [after settle](https://htmx.org/headers/hx-trigger/) trigger from events.
pub fn after_settle<T: Into<HxEvent>>(events: impl IntoIterator<Item = T>) -> Self {
Self::new(TriggerMode::AfterSettle, events)
}
/// Creates new [after swap](https://htmx.org/headers/hx-trigger/) trigger from events.
pub fn after_swap<T: Into<HxEvent>>(events: impl IntoIterator<Item = T>) -> Self {
Self::new(TriggerMode::AfterSwap, events)
}
}
impl<T> From<(TriggerMode, T)> for HxResponseTrigger
where
T: IntoIterator,
T::Item: Into<HxEvent>,
{
fn from((mode, events): (TriggerMode, T)) -> Self {
Self {
mode,
events: events.into_iter().map(Into::into).collect(),
}
}
}
impl IntoResponseParts for HxResponseTrigger {
type Error = HxError;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
if !self.events.is_empty() {
let header = match self.mode {
TriggerMode::Normal => headers::HX_TRIGGER,
TriggerMode::AfterSettle => headers::HX_TRIGGER_AFTER_SETTLE,
TriggerMode::AfterSwap => headers::HX_TRIGGER_AFTER_SETTLE,
};
res.headers_mut()
.insert(header, events_to_header_value(self.events)?);
}
Ok(res)
}
}
#[cfg(test)]
mod tests {
use http::HeaderValue;
use serde_json::json;
use super::*;
#[test]
fn valid_event_to_header_encoding() {
let evt = HxEvent::new_with_data(
"my-event",
json!({"level": "info", "message": {
"body": "This is a test message.",
"title": "Hello, world!",
}}),
)
.unwrap();
let header_value = events_to_header_value(vec![evt]).unwrap();
let expected_value = r#"{"my-event":{"level":"info","message":{"body":"This is a test message.","title":"Hello, world!"}}}"#;
assert_eq!(header_value, HeaderValue::from_static(expected_value));
let value =
events_to_header_value(HxResponseTrigger::normal(["foo", "bar"]).events).unwrap();
assert_eq!(value, HeaderValue::from_static("foo, bar"));
}
}