axum_htmx/responders/
trigger.rs

1use axum_core::response::{IntoResponseParts, ResponseParts};
2
3use crate::{HxError, headers};
4
5/// Represents a client-side event carrying optional data.
6#[derive(Debug, Clone)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize))]
8pub struct HxEvent {
9    pub name: String,
10    #[serde(skip_serializing_if = "Option::is_none")]
11    #[cfg(feature = "serde")]
12    #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
13    pub data: Option<serde_json::Value>,
14}
15
16impl HxEvent {
17    /// Creates new event with no associated data.
18    pub fn new(name: impl AsRef<str>) -> Self {
19        Self {
20            name: name.as_ref().to_owned(),
21            #[cfg(feature = "serde")]
22            data: None,
23        }
24    }
25
26    /// Creates new event with data.
27    #[cfg(feature = "serde")]
28    #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))]
29    pub fn new_with_data<T: ::serde::Serialize>(
30        name: impl AsRef<str>,
31        data: T,
32    ) -> Result<Self, serde_json::Error> {
33        let data = serde_json::to_value(data)?;
34
35        Ok(Self {
36            name: name.as_ref().to_owned(),
37            #[cfg(feature = "serde")]
38            data: Some(data),
39        })
40    }
41}
42
43impl<N: AsRef<str>> From<N> for HxEvent {
44    fn from(name: N) -> Self {
45        Self {
46            name: name.as_ref().to_owned(),
47            #[cfg(feature = "serde")]
48            data: None,
49        }
50    }
51}
52
53#[cfg(not(feature = "serde"))]
54fn events_to_header_value(events: Vec<HxEvent>) -> Result<http::HeaderValue, HxError> {
55    let header = events
56        .into_iter()
57        .map(|HxEvent { name }| name)
58        .collect::<Vec<_>>()
59        .join(", ");
60
61    http::HeaderValue::from_str(&header).map_err(Into::into)
62}
63
64#[cfg(feature = "serde")]
65fn events_to_header_value(events: Vec<HxEvent>) -> Result<http::HeaderValue, HxError> {
66    use std::collections::HashMap;
67
68    use http::HeaderValue;
69    use serde_json::Value;
70
71    let with_data = events.iter().any(|e| e.data.is_some());
72
73    let header_value = if with_data {
74        // at least one event contains data so the header_value needs to be json
75        // encoded.
76        let header_value = events
77            .into_iter()
78            .map(|e| (e.name, e.data.unwrap_or_default()))
79            .collect::<HashMap<String, Value>>();
80
81        serde_json::to_string(&header_value)?
82    } else {
83        // no event contains data, the event names can be put in the header
84        // value separated by a comma.
85        events
86            .into_iter()
87            .map(|e| e.name)
88            .reduce(|acc, e| acc + ", " + &e)
89            .unwrap_or_default()
90    };
91
92    HeaderValue::from_maybe_shared(header_value).map_err(HxError::from)
93}
94
95/// Describes when should event be triggered.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum TriggerMode {
99    Normal,
100    AfterSettle,
101    AfterSwap,
102}
103
104/// The `HX-Trigger*` header.
105///
106/// Allows you to trigger client-side events. Corresponds to `HX-Trigger`,
107/// `HX-Trigger-After-Settle` and `HX-Trigger-After-Swap` headers. To change
108/// when events trigger use appropriate `mode`.
109///
110/// Will fail if the supplied events contain or produce characters that are not
111/// visible ASCII (32-127) when serializing to JSON.
112///
113/// See <https://htmx.org/headers/hx-trigger/> for more information.
114///
115/// Note: An `HxResponseTrigger` implements `IntoResponseParts` and should be
116/// used before any other response object would consume the response parts.
117#[derive(Debug, Clone)]
118pub struct HxResponseTrigger {
119    pub mode: TriggerMode,
120    pub events: Vec<HxEvent>,
121}
122
123impl HxResponseTrigger {
124    /// Creates new [trigger](https://htmx.org/headers/hx-trigger/) with
125    /// specified mode and events.
126    pub fn new<T: Into<HxEvent>>(mode: TriggerMode, events: impl IntoIterator<Item = T>) -> Self {
127        Self {
128            mode,
129            events: events.into_iter().map(Into::into).collect(),
130        }
131    }
132
133    /// Creates new [normal](https://htmx.org/headers/hx-trigger/) trigger from
134    /// events.
135    ///
136    /// See `HxResponseTrigger` for more information.
137    pub fn normal<T: Into<HxEvent>>(events: impl IntoIterator<Item = T>) -> Self {
138        Self::new(TriggerMode::Normal, events)
139    }
140
141    /// Creates new [after settle](https://htmx.org/headers/hx-trigger/) trigger
142    /// from events.
143    ///
144    /// See `HxResponseTrigger` for more information.
145    pub fn after_settle<T: Into<HxEvent>>(events: impl IntoIterator<Item = T>) -> Self {
146        Self::new(TriggerMode::AfterSettle, events)
147    }
148
149    /// Creates new [after swap](https://htmx.org/headers/hx-trigger/) trigger
150    /// from events.
151    ///
152    /// See `HxResponseTrigger` for more information.
153    pub fn after_swap<T: Into<HxEvent>>(events: impl IntoIterator<Item = T>) -> Self {
154        Self::new(TriggerMode::AfterSwap, events)
155    }
156}
157
158impl<T> From<(TriggerMode, T)> for HxResponseTrigger
159where
160    T: IntoIterator,
161    T::Item: Into<HxEvent>,
162{
163    fn from((mode, events): (TriggerMode, T)) -> Self {
164        Self {
165            mode,
166            events: events.into_iter().map(Into::into).collect(),
167        }
168    }
169}
170
171impl IntoResponseParts for HxResponseTrigger {
172    type Error = HxError;
173
174    fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
175        if !self.events.is_empty() {
176            let header = match self.mode {
177                TriggerMode::Normal => headers::HX_TRIGGER,
178                TriggerMode::AfterSettle => headers::HX_TRIGGER_AFTER_SETTLE,
179                TriggerMode::AfterSwap => headers::HX_TRIGGER_AFTER_SETTLE,
180            };
181
182            res.headers_mut()
183                .insert(header, events_to_header_value(self.events)?);
184        }
185
186        Ok(res)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use http::HeaderValue;
193    use serde_json::json;
194
195    use super::*;
196
197    #[test]
198    fn valid_event_to_header_encoding() {
199        let evt = HxEvent::new_with_data(
200            "my-event",
201            json!({"level": "info", "message": {
202                "body": "This is a test message.",
203                "title": "Hello, world!",
204            }}),
205        )
206        .unwrap();
207
208        let header_value = events_to_header_value(vec![evt]).unwrap();
209
210        let expected_value = r#"{"my-event":{"level":"info","message":{"body":"This is a test message.","title":"Hello, world!"}}}"#;
211
212        assert_eq!(header_value, HeaderValue::from_static(expected_value));
213
214        let value =
215            events_to_header_value(HxResponseTrigger::normal(["foo", "bar"]).events).unwrap();
216        assert_eq!(value, HeaderValue::from_static("foo, bar"));
217    }
218}