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