actix_htmx/
htmx.rs

1use actix_web::dev::{Payload, ServiceRequest};
2use actix_web::error::Error;
3use actix_web::http::header::HeaderValue;
4use actix_web::{FromRequest, HttpMessage, HttpRequest};
5use futures_util::future::{ready, Ready};
6use indexmap::IndexMap;
7use std::cell::RefCell;
8use std::collections::HashMap;
9use std::fmt;
10use std::rc::Rc;
11
12use crate::headers::{RequestHeaders, ResponseHeaders};
13
14/// Provides access to htmx request information and methods for setting htmx response headers.
15///
16/// The [`Htmx`] struct serves two main purposes:
17/// 1. As an extractor, providing information about the current htmx request
18/// 2. For managing htmx response headers
19///
20/// # Request Information
21///
22/// Access information about the current request:
23/// ```rust
24/// use actix_web::{get, HttpResponse, Responder};
25/// use actix_htmx::Htmx;
26///
27/// #[get("/")]
28/// async fn handler(htmx: Htmx) -> impl Responder {
29///     if htmx.is_htmx {
30///         // This is an htmx request
31///         println!("Target element: {}", htmx.target().unwrap_or_default());
32///         println!("Trigger element: {}", htmx.trigger().unwrap_or_default());
33///     }
34///     // ...
35///     HttpResponse::Ok()
36/// }
37/// ```
38///
39/// # Response Headers
40///
41/// Set htmx response headers for client-side behaviour:
42/// ```rust
43/// use actix_web::{post, HttpResponse, Responder};
44/// use actix_htmx::{Htmx, TriggerType, SwapType};
45///
46/// #[post("/create")]
47/// async fn create(htmx: Htmx) -> impl Responder {
48///     // Trigger a client-side event
49///     htmx.trigger_event(
50///         "itemCreated".to_string(),
51///         Some(r#"{"id": 123}"#.to_string()),
52///         Some(TriggerType::Standard)
53///     );
54///
55///     // Change how content is swapped
56///     htmx.reswap(SwapType::OuterHtml);
57///
58///     // Redirect after the request
59///     htmx.redirect("/items".to_string());
60///
61///     // ...
62///     HttpResponse::Ok()
63/// }
64/// ```
65///
66#[derive(Clone)]
67pub struct Htmx {
68    inner: Rc<RefCell<HtmxInner>>,
69    /// True if the request was made by htmx (has the `hx-request` header)
70    pub is_htmx: bool,
71    /// True if the request was made by a boosted element (has the `hx-boosted` header)
72    pub boosted: bool,
73    /// True if this is a history restore request (has the `hx-history-restore-request` header)
74    pub history_restore_request: bool,
75}
76
77macro_rules! collection {
78    ($($k:expr => $v:expr),* $(,)?) => {{
79        use std::iter::{Iterator, IntoIterator};
80        Iterator::collect(IntoIterator::into_iter([$(($k, $v),)*]))
81    }};
82}
83
84/// Specifies when an htmx event should be triggered.
85///
86/// Events can be triggered at different points in the htmx request lifecycle.
87#[derive(Clone, PartialEq, Eq, Hash)]
88pub enum TriggerType {
89    Standard,
90    AfterSettle,
91    AfterSwap,
92}
93
94/// Specifies how htmx should swap content into the target element.
95///
96/// These correspond to the different swap strategies available in htmx.
97pub enum SwapType {
98    /// Replace the inner HTML of the target element (default)
99    InnerHtml,
100    /// Replace the entire target element
101    OuterHtml,
102    /// Insert content before the target element
103    BeforeBegin,
104    /// Insert content at the beginning of the target element
105    AfterBegin,
106    /// Insert content at the end of the target element
107    BeforeEnd,
108    /// Insert content after the target element
109    AfterEnd,
110    /// Delete the target element
111    Delete,
112    /// Don't swap any content
113    None,
114}
115
116enum DataType {
117    String(Option<String>),
118    Bool(bool),
119}
120
121impl fmt::Display for SwapType {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            SwapType::InnerHtml => write!(f, "innerHTML"),
125            SwapType::OuterHtml => write!(f, "outerHTML"),
126            SwapType::BeforeBegin => write!(f, "beforebegin"),
127            SwapType::AfterBegin => write!(f, "afterbegin"),
128            SwapType::BeforeEnd => write!(f, "beforeend"),
129            SwapType::AfterEnd => write!(f, "afterend"),
130            SwapType::Delete => write!(f, "delete"),
131            SwapType::None => write!(f, "none"),
132        }
133    }
134}
135
136struct HtmxInner {
137    standard_triggers: IndexMap<String, Option<String>>,
138    after_settle_triggers: IndexMap<String, Option<String>>,
139    after_swap_triggers: IndexMap<String, Option<String>>,
140    response_headers: IndexMap<String, String>,
141    request_headers: IndexMap<String, DataType>,
142    simple_trigger: HashMap<TriggerType, bool>,
143}
144
145impl HtmxInner {
146    pub fn new(req: &HttpRequest) -> HtmxInner {
147        let request_headers = collection![
148            RequestHeaders::HX_REQUEST.to_string() => DataType::Bool(req.headers().get(RequestHeaders::HX_REQUEST).as_bool()),
149            RequestHeaders::HX_BOOSTED.to_string() => DataType::Bool(req.headers().get(RequestHeaders::HX_BOOSTED).as_bool()),
150            RequestHeaders::HX_CURRENT_URL.to_string() => DataType::String(req.headers().get(RequestHeaders::HX_CURRENT_URL).as_option_string()),
151            RequestHeaders::HX_HISTORY_RESTORE_REQUEST.to_string() => DataType::Bool(req.headers().get(RequestHeaders::HX_HISTORY_RESTORE_REQUEST).as_bool()),
152            RequestHeaders::HX_PROMPT.to_string() => DataType::String(req.headers().get(RequestHeaders::HX_PROMPT).as_option_string()),
153            RequestHeaders::HX_TARGET.to_string() => DataType::String(req.headers().get(RequestHeaders::HX_TARGET).as_option_string()),
154            RequestHeaders::HX_TRIGGER.to_string() => DataType::String(req.headers().get(RequestHeaders::HX_TRIGGER).as_option_string()),
155            RequestHeaders::HX_TRIGGER_NAME.to_string() => DataType::String(req.headers().get(RequestHeaders::HX_TRIGGER_NAME).as_option_string()),
156        ];
157
158        HtmxInner {
159            request_headers,
160            response_headers: IndexMap::new(),
161            standard_triggers: IndexMap::new(),
162            after_settle_triggers: IndexMap::new(),
163            after_swap_triggers: IndexMap::new(),
164            simple_trigger: HashMap::new(),
165        }
166    }
167
168    fn get_bool_header(&self, header_name: &str) -> bool {
169        self.request_headers
170            .get(header_name)
171            .map(|data_type| match data_type {
172                DataType::Bool(b) => *b,
173                _ => false,
174            })
175            .unwrap_or(false)
176    }
177
178    fn get_string_header(&self, header_name: &str) -> Option<String> {
179        self.request_headers
180            .get(header_name)
181            .map(|data_type| match data_type {
182                DataType::String(s) => s.clone(),
183                _ => None,
184            })
185            .unwrap_or(None)
186    }
187}
188
189impl Htmx {
190    fn from_inner(inner: Rc<RefCell<HtmxInner>>) -> Htmx {
191        let is_htmx = inner.borrow().get_bool_header(RequestHeaders::HX_REQUEST);
192        let boosted = inner.borrow().get_bool_header(RequestHeaders::HX_BOOSTED);
193        let history_restore_request = inner
194            .borrow()
195            .get_bool_header(RequestHeaders::HX_HISTORY_RESTORE_REQUEST);
196
197        Htmx {
198            inner,
199            is_htmx,
200            boosted,
201            history_restore_request,
202        }
203    }
204
205    pub fn new(req: &ServiceRequest) -> Htmx {
206        let req = req.request();
207        let inner = Rc::new(RefCell::new(HtmxInner::new(req)));
208        Htmx::from_inner(inner)
209    }
210
211    /// Get the current URL from the `hx-current-url` header.
212    ///
213    /// This header is sent by htmx and contains the current URL of the page.
214    pub fn current_url(&self) -> Option<String> {
215        self.inner
216            .borrow()
217            .get_string_header(RequestHeaders::HX_CURRENT_URL)
218    }
219
220    /// Get the user's response to an `hx-prompt` from the `hx-prompt` header.
221    ///
222    /// This header contains the user's input when an htmx request includes a prompt.
223    pub fn prompt(&self) -> Option<String> {
224        self.inner
225            .borrow()
226            .get_string_header(RequestHeaders::HX_PROMPT)
227    }
228
229    /// Get the ID of the target element from the `hx-target` header.
230    ///
231    /// This header contains the ID of the element that will be updated with the response.
232    pub fn target(&self) -> Option<String> {
233        self.inner
234            .borrow()
235            .get_string_header(RequestHeaders::HX_TARGET)
236    }
237
238    /// Get the ID of the element that triggered the request from the `hx-trigger` header.
239    ///
240    /// This header contains the ID of the element that initiated the htmx request.
241    pub fn trigger(&self) -> Option<String> {
242        self.inner
243            .borrow()
244            .get_string_header(RequestHeaders::HX_TRIGGER)
245    }
246
247    /// Get the name of the element that triggered the request from the `hx-trigger-name` header.
248    ///
249    /// This header contains the name attribute of the element that initiated the htmx request.
250    pub fn trigger_name(&self) -> Option<String> {
251        self.inner
252            .borrow()
253            .get_string_header(RequestHeaders::HX_TRIGGER_NAME)
254    }
255
256    /// Trigger a custom JavaScript event on the client side.
257    ///
258    /// This method allows you to trigger custom events that can be listened to with JavaScript.
259    /// Events can include optional data and can be triggered at different points in the htmx lifecycle.
260    ///
261    /// # Arguments
262    ///
263    /// * `name` - The name of the event to trigger
264    /// * `message` - Optional data to send with the event (typically JSON)
265    /// * `trigger_type` - When to trigger the event (defaults to `Standard`)
266    ///
267    /// # Example
268    ///
269    /// ```rust
270    /// use actix_htmx::{Htmx, TriggerType};
271    ///
272    /// fn handler(htmx: Htmx) {
273    ///     // Simple event without data
274    ///     htmx.trigger_event("item-deleted".to_string(), None, None);
275    ///
276    ///     // Event with JSON data
277    ///     htmx.trigger_event(
278    ///         "notification".to_string(),
279    ///         Some(r#"{"message": "Success!", "type": "info"}"#.to_string()),
280    ///         Some(TriggerType::Standard)
281    ///     );
282    /// }
283    /// ```
284    pub fn trigger_event(
285        &self,
286        name: String,
287        message: Option<String>,
288        trigger_type: Option<TriggerType>,
289    ) {
290        let trigger_type = trigger_type.unwrap_or(TriggerType::Standard);
291        match trigger_type {
292            TriggerType::Standard => {
293                if message.is_some() {
294                    _ = self
295                        .inner
296                        .borrow_mut()
297                        .simple_trigger
298                        .entry(TriggerType::Standard)
299                        .or_insert(false);
300                }
301                self.inner
302                    .borrow_mut()
303                    .standard_triggers
304                    .insert(name, message);
305            }
306            TriggerType::AfterSettle => {
307                if message.is_some() {
308                    _ = self
309                        .inner
310                        .borrow_mut()
311                        .simple_trigger
312                        .entry(TriggerType::AfterSettle)
313                        .or_insert(false);
314                }
315                self.inner
316                    .borrow_mut()
317                    .after_settle_triggers
318                    .insert(name, message);
319            }
320            TriggerType::AfterSwap => {
321                if message.is_some() {
322                    _ = self
323                        .inner
324                        .borrow_mut()
325                        .simple_trigger
326                        .entry(TriggerType::AfterSwap)
327                        .or_insert(false);
328                }
329                self.inner
330                    .borrow_mut()
331                    .after_swap_triggers
332                    .insert(name, message);
333            }
334        }
335    }
336
337    /// Redirect to a new page with a full page reload.
338    ///
339    /// This sets the `hx-redirect` header, which causes htmx to perform a client-side redirect
340    /// to the specified URL with a full page reload.
341    pub fn redirect(&self, path: String) {
342        self.inner
343            .borrow_mut()
344            .response_headers
345            .insert(ResponseHeaders::HX_REDIRECT.to_string(), path);
346    }
347
348    /// Redirect to a new page using htmx (no full page reload).
349    ///
350    /// This sets the `hx-location` header, which causes htmx to make a new request
351    /// to the specified URL and swap the response into the current page.
352    pub fn redirect_with_swap(&self, path: String) {
353        self.inner
354            .borrow_mut()
355            .response_headers
356            .insert(ResponseHeaders::HX_LOCATION.to_string(), path);
357    }
358
359    /// Refresh the current page.
360    ///
361    /// This sets the `hx-refresh` header, which causes htmx to refresh the entire page.
362    pub fn refresh(&self) {
363        self.inner
364            .borrow_mut()
365            .response_headers
366            .insert(ResponseHeaders::HX_REFRESH.to_string(), "true".to_string());
367    }
368
369    /// Update the browser URL without causing a navigation.
370    ///
371    /// This sets the `hx-push-url` header, which updates the browser's address bar
372    /// and adds an entry to the browser history.
373    pub fn push_url(&self, path: String) {
374        self.inner
375            .borrow_mut()
376            .response_headers
377            .insert(ResponseHeaders::HX_PUSH_URL.to_string(), path);
378    }
379
380    /// Replace the current URL in the browser history.
381    ///
382    /// This sets the `hx-replace-url` header, which updates the browser's address bar
383    /// without adding a new entry to the browser history.
384    pub fn replace_url(&self, path: String) {
385        self.inner
386            .borrow_mut()
387            .response_headers
388            .insert(ResponseHeaders::HX_REPLACE_URL.to_string(), path);
389    }
390
391    /// Change how htmx swaps content into the target element.
392    ///
393    /// This sets the `hx-reswap` header, which overrides the default swap behaviour
394    /// for this response.
395    pub fn reswap(&self, swap_type: SwapType) {
396        self.inner.borrow_mut().response_headers.insert(
397            ResponseHeaders::HX_RESWAP.to_string(),
398            swap_type.to_string(),
399        );
400    }
401
402    /// Change the target element for content swapping.
403    ///
404    /// This sets the `hx-retarget` header, which changes which element
405    /// the response content will be swapped into.
406    pub fn retarget(&self, selector: String) {
407        self.inner.borrow_mut().response_headers.insert(
408            ResponseHeaders::HX_RETARGET.to_string(),
409            selector.to_string(),
410        );
411    }
412
413    /// Select specific content from the response to swap.
414    ///
415    /// This sets the `hx-reselect` header, which allows you to select
416    /// a subset of the response content to swap into the target.
417    pub fn reselect(&self, selector: String) {
418        self.inner.borrow_mut().response_headers.insert(
419            ResponseHeaders::HX_RESELECT.to_string(),
420            selector.to_string(),
421        );
422    }
423
424    pub(crate) fn get_triggers(
425        &self,
426        trigger_type: TriggerType,
427    ) -> IndexMap<String, Option<String>> {
428        match trigger_type {
429            TriggerType::Standard => self.inner.borrow().standard_triggers.clone(),
430            TriggerType::AfterSettle => self.inner.borrow().after_settle_triggers.clone(),
431            TriggerType::AfterSwap => self.inner.borrow().after_swap_triggers.clone(),
432        }
433    }
434
435    pub(crate) fn is_simple_trigger(&self, trigger_type: TriggerType) -> bool {
436        match trigger_type {
437            TriggerType::Standard => *self
438                .inner
439                .borrow()
440                .simple_trigger
441                .get(&TriggerType::Standard)
442                .unwrap_or(&true),
443            TriggerType::AfterSettle => *self
444                .inner
445                .borrow()
446                .simple_trigger
447                .get(&TriggerType::AfterSettle)
448                .unwrap_or(&true),
449            TriggerType::AfterSwap => *self
450                .inner
451                .borrow()
452                .simple_trigger
453                .get(&TriggerType::AfterSwap)
454                .unwrap_or(&true),
455        }
456    }
457
458    pub(crate) fn get_response_headers(&self) -> IndexMap<String, String> {
459        self.inner.borrow().response_headers.clone()
460    }
461}
462
463impl FromRequest for Htmx {
464    type Error = Error;
465    type Future = Ready<Result<Htmx, Error>>;
466
467    #[inline]
468    fn from_request(req: &actix_web::HttpRequest, _: &mut Payload) -> Self::Future {
469        if let Some(htmx) = req.extensions_mut().get::<Htmx>() {
470            return ready(Ok(htmx.clone()));
471        }
472
473        let inner = Rc::new(RefCell::new(HtmxInner::new(req)));
474
475        ready(Ok(Htmx::from_inner(inner)))
476    }
477}
478
479trait AsBool {
480    fn as_bool(&self) -> bool;
481}
482
483trait AsOptionString {
484    fn as_option_string(&self) -> Option<String>;
485}
486
487impl AsBool for Option<&HeaderValue> {
488    fn as_bool(&self) -> bool {
489        match self {
490            Some(header) => {
491                if let Ok(header) = header.to_str() {
492                    header.parse::<bool>().unwrap_or(false)
493                } else {
494                    false
495                }
496            }
497            None => false,
498        }
499    }
500}
501
502impl AsOptionString for Option<&HeaderValue> {
503    fn as_option_string(&self) -> Option<String> {
504        match self {
505            Some(header) => {
506                if let Ok(header) = header.to_str() {
507                    Some(header.to_string())
508                } else {
509                    None
510                }
511            }
512            None => None,
513        }
514    }
515}