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
14macro_rules! collection {
15    ($($k:expr => $v:expr),* $(,)?) => {{
16        use std::iter::{Iterator, IntoIterator};
17        Iterator::collect(IntoIterator::into_iter([$(($k, $v),)*]))
18    }};
19}
20
21#[derive(Clone)]
22pub struct Htmx {
23    inner: Rc<RefCell<HtmxInner>>,
24    pub is_htmx: bool,
25    pub boosted: bool,
26    pub history_restore_request: bool,
27}
28
29#[derive(Clone, PartialEq, Eq, Hash)]
30pub enum TriggerType {
31    Standard,
32    AfterSettle,
33    AfterSwap,
34}
35
36pub enum SwapType {
37    InnerHtml,
38    OuterHtml,
39    BeforeBegin,
40    AfterBegin,
41    BeforeEnd,
42    AfterEnd,
43    Delete,
44    None,
45}
46
47enum DataType {
48    String(Option<String>),
49    Bool(bool),
50}
51
52impl fmt::Display for SwapType {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            SwapType::InnerHtml => write!(f, "innerHTML"),
56            SwapType::OuterHtml => write!(f, "outerHTML"),
57            SwapType::BeforeBegin => write!(f, "beforebegin"),
58            SwapType::AfterBegin => write!(f, "afterbegin"),
59            SwapType::BeforeEnd => write!(f, "beforeend"),
60            SwapType::AfterEnd => write!(f, "afterend"),
61            SwapType::Delete => write!(f, "delete"),
62            SwapType::None => write!(f, "none"),
63        }
64    }
65}
66
67struct HtmxInner {
68    standard_triggers: IndexMap<String, Option<String>>,
69    after_settle_triggers: IndexMap<String, Option<String>>,
70    after_swap_triggers: IndexMap<String, Option<String>>,
71    response_headers: IndexMap<String, String>,
72    request_headers: IndexMap<String, DataType>,
73    simple_trigger: HashMap<TriggerType, bool>,
74}
75
76impl HtmxInner {
77    pub fn new(req: &HttpRequest) -> HtmxInner {
78        let request_headers = collection![
79            RequestHeaders::HX_REQUEST.to_string() => DataType::Bool(req.headers().get(RequestHeaders::HX_REQUEST).as_bool()),
80            RequestHeaders::HX_BOOSTED.to_string() => DataType::Bool(req.headers().get(RequestHeaders::HX_BOOSTED).as_bool()),
81            RequestHeaders::HX_CURRENT_URL.to_string() => DataType::String(req.headers().get(RequestHeaders::HX_CURRENT_URL).as_option_string()),
82            RequestHeaders::HX_HISTORY_RESTORE_REQUEST.to_string() => DataType::Bool(req.headers().get(RequestHeaders::HX_HISTORY_RESTORE_REQUEST).as_bool()),
83            RequestHeaders::HX_PROMPT.to_string() => DataType::String(req.headers().get(RequestHeaders::HX_PROMPT).as_option_string()),
84            RequestHeaders::HX_TARGET.to_string() => DataType::String(req.headers().get(RequestHeaders::HX_TARGET).as_option_string()),
85            RequestHeaders::HX_TRIGGER.to_string() => DataType::String(req.headers().get(RequestHeaders::HX_TRIGGER).as_option_string()),
86            RequestHeaders::HX_TRIGGER_NAME.to_string() => DataType::String(req.headers().get(RequestHeaders::HX_TRIGGER_NAME).as_option_string()),
87        ];
88
89        HtmxInner {
90            request_headers,
91            response_headers: IndexMap::new(),
92            standard_triggers: IndexMap::new(),
93            after_settle_triggers: IndexMap::new(),
94            after_swap_triggers: IndexMap::new(),
95            simple_trigger: HashMap::new(),
96        }
97    }
98
99    fn get_bool_header(&self, header_name: &str) -> bool {
100        self.request_headers
101            .get(header_name)
102            .map(|data_type| match data_type {
103                DataType::Bool(b) => *b,
104                _ => false,
105            })
106            .unwrap_or(false)
107    }
108
109    fn get_string_header(&self, header_name: &str) -> Option<String> {
110        self.request_headers
111            .get(header_name)
112            .map(|data_type| match data_type {
113                DataType::String(s) => {
114                    if let Some(s) = s {
115                        Some(s.clone())
116                    } else {
117                        None
118                    }
119                },
120                _ => None,
121            })
122            .unwrap_or(None)
123    }
124}
125
126impl Htmx {
127    fn from_inner(inner: Rc<RefCell<HtmxInner>>) -> Htmx {
128        let is_htmx = inner.borrow().get_bool_header(RequestHeaders::HX_REQUEST);
129        let boosted = inner.borrow().get_bool_header(RequestHeaders::HX_BOOSTED);
130        let history_restore_request = inner.borrow().get_bool_header(RequestHeaders::HX_HISTORY_RESTORE_REQUEST);
131
132        Htmx {
133            inner,
134            is_htmx,
135            boosted,
136            history_restore_request,
137        }
138    }
139
140    pub fn new(req: &ServiceRequest) -> Htmx {
141        let req = req.request();
142        let inner = Rc::new(RefCell::new(HtmxInner::new(req)));
143        Htmx::from_inner(inner)
144    }
145
146    pub fn current_url(&self) -> Option<String> {
147        self.inner.borrow().get_string_header(RequestHeaders::HX_CURRENT_URL)
148    }
149
150    pub fn prompt(&self) -> Option<String> {
151        self.inner.borrow().get_string_header(RequestHeaders::HX_PROMPT)
152    }
153
154    pub fn target(&self) -> Option<String> {
155        self.inner.borrow().get_string_header(RequestHeaders::HX_TARGET)
156    }
157
158    pub fn trigger(&self) -> Option<String> {
159        self.inner.borrow().get_string_header(RequestHeaders::HX_TRIGGER)
160    }
161
162    pub fn trigger_name(&self) -> Option<String> {
163        self.inner.borrow().get_string_header(RequestHeaders::HX_TRIGGER_NAME)
164    }
165
166    pub fn trigger_event(&self, name: String, message: Option<String>, trigger_type: Option<TriggerType>) {
167        let trigger_type = trigger_type.unwrap_or(TriggerType::Standard);
168        match trigger_type {
169            TriggerType::Standard => {
170                if message != None {
171                    _ = self.inner.borrow_mut().simple_trigger.entry(TriggerType::Standard).or_insert(false);
172                }
173                self.inner.borrow_mut().standard_triggers.insert(name, message);
174            }
175            TriggerType::AfterSettle => {
176                if message != None {
177                    _ = self.inner.borrow_mut().simple_trigger.entry(TriggerType::AfterSettle).or_insert(false);
178                }
179                self.inner
180                    .borrow_mut()
181                    .after_settle_triggers
182                    .insert(name, message);
183            }
184            TriggerType::AfterSwap => {
185                if message != None {
186                    _ = self.inner.borrow_mut().simple_trigger.entry(TriggerType::AfterSwap).or_insert(false);
187                }
188                self.inner
189                    .borrow_mut()
190                    .after_swap_triggers
191                    .insert(name, message);
192            }
193        }
194    }
195
196    pub fn redirect(&self, path: String) {
197        self.inner
198            .borrow_mut()
199            .response_headers
200            .insert(ResponseHeaders::HX_REDIRECT.to_string(), path);
201    }
202
203    pub fn redirect_with_swap(&self, path: String) {
204        self.inner
205            .borrow_mut()
206            .response_headers
207            .insert(ResponseHeaders::HX_LOCATION.to_string(), path);
208    }
209
210    pub fn refresh(&self) {
211        self.inner
212            .borrow_mut()
213            .response_headers
214            .insert(ResponseHeaders::HX_REFRESH.to_string(), "true".to_string());
215    }
216
217    pub fn push_url(&self, path: String) {
218        self.inner
219            .borrow_mut()
220            .response_headers
221            .insert(ResponseHeaders::HX_PUSH_URL.to_string(), path);
222    }
223
224    pub fn replace_url(&self, path: String) {
225        self.inner
226            .borrow_mut()
227            .response_headers
228            .insert(ResponseHeaders::HX_REPLACE_URL.to_string(), path);
229    }
230
231    pub fn reswap(&self, swap_type: SwapType) {
232        self.inner.borrow_mut().response_headers.insert(
233            ResponseHeaders::HX_RESWAP.to_string(),
234            swap_type.to_string(),
235        );
236    }
237
238    pub fn retarget(&self, selector: String) {
239        self.inner.borrow_mut().response_headers.insert(
240            ResponseHeaders::HX_RETARGET.to_string(),
241            selector.to_string(),
242        );
243    }
244
245    pub fn reselect(&self, selector: String) {
246        self.inner.borrow_mut().response_headers.insert(
247            ResponseHeaders::HX_RESELECT.to_string(),
248            selector.to_string(),
249        );
250    }
251
252    pub(crate) fn get_triggers(&self, trigger_type: TriggerType) -> IndexMap<String, Option<String>> {
253        match trigger_type {
254            TriggerType::Standard => self.inner.borrow().standard_triggers.clone(),
255            TriggerType::AfterSettle => self.inner.borrow().after_settle_triggers.clone(),
256            TriggerType::AfterSwap => self.inner.borrow().after_swap_triggers.clone(),
257        }
258    }
259
260    pub(crate) fn is_simple_trigger(&self, trigger_type: TriggerType) -> bool {
261        match trigger_type {
262            TriggerType::Standard => *self.inner.borrow().simple_trigger.get(&TriggerType::Standard).unwrap_or(&true),
263            TriggerType::AfterSettle => *self.inner.borrow().simple_trigger.get(&TriggerType::AfterSettle).unwrap_or(&true),
264            TriggerType::AfterSwap => *self.inner.borrow().simple_trigger.get(&TriggerType::AfterSwap).unwrap_or(&true),
265        }
266    }
267
268    pub(crate) fn get_response_headers(&self) -> IndexMap<String, String> {
269        self.inner.borrow().response_headers.clone()
270    }
271}
272
273impl FromRequest for Htmx {
274    type Error = Error;
275    type Future = Ready<Result<Htmx, Error>>;
276
277    #[inline]
278    fn from_request(req: &actix_web::HttpRequest, _: &mut Payload) -> Self::Future {
279        if let Some(htmx) = req.extensions_mut().get::<Htmx>() {
280            return ready(Ok(htmx.clone()));
281        }
282
283        let inner = Rc::new(RefCell::new(HtmxInner::new(req)));
284
285        ready(Ok(Htmx::from_inner(inner)))
286    }
287}
288
289trait AsBool {
290    fn as_bool(&self) -> bool;
291}
292
293trait AsOptionString {
294    fn as_option_string(&self) -> Option<String>;
295}
296
297impl AsBool for Option<&HeaderValue> {
298    fn as_bool(&self) -> bool {
299        match self {
300            Some(header) => {
301                if let Ok(header) = header.to_str() {
302                    header.parse::<bool>().unwrap_or(false)
303                } else {
304                    false
305                }
306            }
307            None => false,
308        }
309    }
310}
311
312impl AsOptionString for Option<&HeaderValue> {
313    fn as_option_string(&self) -> Option<String> {
314        match self {
315            Some(header) => {
316                if let Ok(header) = header.to_str() {
317                    Some(header.to_string())
318                } else {
319                    None
320                }
321            }
322            None => None,
323        }
324    }
325}