actix_htmx/
lib.rs

1//! # actix-htmx
2//!
3//! `actix-htmx` provides a comprehensive solution for building dynamic web applications with htmx and Actix Web.
4//! It offers type-safe access to htmx request headers, easy response manipulation, and powerful event triggering capabilities.
5//!
6//! ## Features
7//!
8//! - **Request Detection**: Automatically detect htmx requests, boosted requests, and history restore requests
9//! - **Header Access**: Type-safe access to all htmx request headers (current URL, target, trigger, prompt, etc.)
10//! - **Event Triggering**: Trigger custom JavaScript events with optional data at different lifecycle stages
11//! - **Response Control**: Full control over htmx behaviour with response headers (redirect, refresh, swap, retarget, etc.)
12//! - **Type Safety**: Fully typed API leveraging Rust's type system for correctness
13//! - **Zero Configuration**: Works out of the box with sensible defaults
14//! - **Performance**: Minimal overhead with efficient header processing
15//!
16//! # Getting Started
17//! Register [`HtmxMiddleware`] on your `App` and use the [`Htmx`] extractor in your handlers:
18//!
19//! ```no_run
20//! use actix_htmx::{Htmx, HtmxMiddleware};
21//! use actix_web::{web, App, HttpResponse, HttpServer, Responder};
22//!
23//! #[actix_web::main]
24//! async fn main() -> std::io::Result<()> {
25//!     HttpServer::new(|| {
26//!         App::new()
27//!             .wrap(HtmxMiddleware)
28//!             .route("/", web::get().to(index))
29//!     })
30//!     .bind("127.0.0.1:8080")?
31//!     .run()
32//!     .await
33//! }
34//!
35//! async fn index(htmx: Htmx) -> impl Responder {
36//!     if htmx.is_htmx {
37//!         // This is an htmx request - return partial HTML
38//!         HttpResponse::Ok().body("<div>Partial content for htmx</div>")
39//!     } else {
40//!         // Regular request - return full page
41//!         HttpResponse::Ok().body("<html><body><div>Full page content</div></body></html>")
42//!     }
43//! }
44//! ```
45
46mod headers;
47mod htmx;
48mod middleware;
49
50pub use self::{
51    htmx::{Htmx, SwapType, TriggerType},
52    middleware::HtmxMiddleware,
53};
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use crate::headers::ResponseHeaders;
59    use actix_web::http::header::HeaderValue;
60    use actix_web::{
61        http::header::HeaderName,
62        test::{self, TestRequest},
63        web, App, HttpResponse,
64    };
65
66    #[actix_web::test]
67    async fn test_htmx_middleware_basic() {
68        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
69            "/test",
70            web::get().to(|htmx: Htmx| async move {
71                htmx.trigger_event(
72                    "test-event".to_string(),
73                    Some("test-value".to_string()),
74                    Some(TriggerType::Standard),
75                );
76                HttpResponse::Ok().finish()
77            }),
78        ))
79        .await;
80
81        let req = TestRequest::get()
82            .uri("/test")
83            .insert_header((HeaderName::from_static("hx-request"), "true"))
84            .to_request();
85
86        let resp = test::call_service(&app, req).await;
87        assert!(resp.status().is_success());
88
89        let trigger_header = resp
90            .headers()
91            .get(HeaderName::from_static(ResponseHeaders::HX_TRIGGER))
92            .unwrap();
93        assert!(trigger_header
94            .to_str()
95            .unwrap()
96            .contains(r#""test-event": "test-value""#));
97    }
98
99    #[actix_web::test]
100    async fn test_htmx_middleware_after_settle() {
101        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
102            "/test",
103            web::get().to(|htmx: Htmx| async move {
104                htmx.trigger_event(
105                    "settle-event".to_string(),
106                    None,
107                    Some(TriggerType::AfterSettle),
108                );
109                HttpResponse::Ok().finish()
110            }),
111        ))
112        .await;
113
114        let req = TestRequest::get()
115            .uri("/test")
116            .insert_header((HeaderName::from_static("hx-request"), "true"))
117            .to_request();
118
119        let resp = test::call_service(&app, req).await;
120        assert!(resp.status().is_success());
121
122        let settle_header = resp
123            .headers()
124            .get(HeaderName::from_static(
125                ResponseHeaders::HX_TRIGGER_AFTER_SETTLE,
126            ))
127            .unwrap();
128
129        assert!(settle_header.to_str().unwrap().contains("settle-event"));
130    }
131
132    #[actix_web::test]
133    async fn test_htmx_request_detection() {
134        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
135            "/test",
136            web::get().to(|htmx: Htmx| async move {
137                assert!(htmx.is_htmx);
138                HttpResponse::Ok().finish()
139            }),
140        ))
141        .await;
142
143        let req = TestRequest::get()
144            .uri("/test")
145            .insert_header((HeaderName::from_static("hx-request"), "true"))
146            .to_request();
147
148        let resp = test::call_service(&app, req).await;
149        assert!(resp.status().is_success());
150    }
151
152    #[actix_web::test]
153    async fn test_non_htmx_request() {
154        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
155            "/test",
156            web::get().to(|htmx: Htmx| async move {
157                assert!(!htmx.is_htmx);
158                HttpResponse::Ok().finish()
159            }),
160        ))
161        .await;
162
163        let req = TestRequest::get().uri("/test").to_request();
164        let resp = test::call_service(&app, req).await;
165        assert!(resp.status().is_success());
166    }
167
168    #[actix_web::test]
169    async fn test_boosted_request() {
170        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
171            "/test",
172            web::get().to(|htmx: Htmx| async move {
173                assert!(htmx.boosted);
174                HttpResponse::Ok().finish()
175            }),
176        ))
177        .await;
178
179        let req = TestRequest::get()
180            .uri("/test")
181            .insert_header((HeaderName::from_static("hx-boosted"), "true"))
182            .to_request();
183
184        let resp = test::call_service(&app, req).await;
185        assert!(resp.status().is_success());
186    }
187
188    #[actix_web::test]
189    async fn test_htmx_reswap() {
190        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
191            "/test",
192            web::get().to(|htmx: Htmx| async move {
193                htmx.reswap(SwapType::Delete);
194                HttpResponse::Ok().finish()
195            }),
196        ))
197        .await;
198
199        let req = TestRequest::get()
200            .uri("/test")
201            .insert_header((HeaderName::from_static("hx-request"), "true"))
202            .to_request();
203
204        let resp = test::call_service(&app, req).await;
205        assert!(resp.status().is_success());
206
207        let reswap_header = resp
208            .headers()
209            .get(HeaderName::from_static(ResponseHeaders::HX_RESWAP))
210            .unwrap();
211
212        assert_eq!(reswap_header.to_str().unwrap(), "delete");
213    }
214
215    #[actix_web::test]
216    async fn test_multiple_triggers() {
217        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
218            "/test",
219            web::get().to(|htmx: Htmx| async move {
220                htmx.trigger_event(
221                    "event1".to_string(),
222                    Some("value1".to_string()),
223                    Some(TriggerType::Standard),
224                );
225                htmx.trigger_event(
226                    "event2".to_string(),
227                    Some("value2".to_string()),
228                    Some(TriggerType::Standard),
229                );
230                HttpResponse::Ok().finish()
231            }),
232        ))
233        .await;
234
235        let req = TestRequest::get()
236            .uri("/test")
237            .insert_header((HeaderName::from_static("hx-request"), "true"))
238            .to_request();
239
240        let resp = test::call_service(&app, req).await;
241        assert!(resp.status().is_success());
242
243        let trigger_header = resp
244            .headers()
245            .get(HeaderName::from_static(ResponseHeaders::HX_TRIGGER))
246            .unwrap()
247            .to_str()
248            .unwrap();
249
250        assert!(trigger_header.contains("event1"));
251        assert!(trigger_header.contains("value1"));
252        assert!(trigger_header.contains("event2"));
253        assert!(trigger_header.contains("value2"));
254    }
255
256    #[actix_web::test]
257    async fn test_multiple_trigger_types() {
258        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
259            "/test",
260            web::get().to(|htmx: Htmx| async move {
261                htmx.trigger_event(
262                    "standard".to_string(),
263                    Some("value1".to_string()),
264                    Some(TriggerType::Standard),
265                );
266                htmx.trigger_event(
267                    "after_settle".to_string(),
268                    Some("value2".to_string()),
269                    Some(TriggerType::AfterSettle),
270                );
271                htmx.trigger_event(
272                    "after_swap".to_string(),
273                    Some("value3".to_string()),
274                    Some(TriggerType::AfterSwap),
275                );
276                HttpResponse::Ok().finish()
277            }),
278        ))
279        .await;
280
281        let req = TestRequest::get()
282            .uri("/test")
283            .insert_header((HeaderName::from_static("hx-request"), "true"))
284            .to_request();
285
286        let resp = test::call_service(&app, req).await;
287        assert!(resp.status().is_success());
288
289        // Check standard trigger
290        let standard_header = resp
291            .headers()
292            .get(HeaderName::from_static(ResponseHeaders::HX_TRIGGER))
293            .unwrap()
294            .to_str()
295            .unwrap();
296        assert!(standard_header.contains("standard"));
297        assert!(standard_header.contains("value1"));
298
299        // Check after settle trigger
300        let after_settle_header = resp
301            .headers()
302            .get(HeaderName::from_static(
303                ResponseHeaders::HX_TRIGGER_AFTER_SETTLE,
304            ))
305            .unwrap()
306            .to_str()
307            .unwrap();
308        assert!(after_settle_header.contains("after_settle"));
309        assert!(after_settle_header.contains("value2"));
310
311        // Check after swap trigger
312        let after_swap_header = resp
313            .headers()
314            .get(HeaderName::from_static(
315                ResponseHeaders::HX_TRIGGER_AFTER_SWAP,
316            ))
317            .unwrap()
318            .to_str()
319            .unwrap();
320        assert!(after_swap_header.contains("after_swap"));
321        assert!(after_swap_header.contains("value3"));
322    }
323
324    #[actix_web::test]
325    async fn test_htmx_redirect() {
326        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
327            "/test",
328            web::get().to(|htmx: Htmx| async move {
329                htmx.redirect("/new-location".to_string());
330                HttpResponse::Ok().finish()
331            }),
332        ))
333        .await;
334
335        let req = TestRequest::get()
336            .uri("/test")
337            .insert_header((HeaderName::from_static("hx-request"), "true"))
338            .to_request();
339
340        let resp = test::call_service(&app, req).await;
341        assert!(resp.status().is_success());
342
343        let redirect_header = resp
344            .headers()
345            .get(HeaderName::from_static(ResponseHeaders::HX_REDIRECT))
346            .unwrap();
347
348        assert_eq!(redirect_header.to_str().unwrap(), "/new-location");
349    }
350
351    #[actix_web::test]
352    async fn test_htmx_redirect_with_swap() {
353        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
354            "/test",
355            web::get().to(|htmx: Htmx| async move {
356                htmx.redirect_with_swap("/new-location".to_string());
357                HttpResponse::Ok().finish()
358            }),
359        ))
360        .await;
361
362        let req = TestRequest::get()
363            .uri("/test")
364            .insert_header((HeaderName::from_static("hx-request"), "true"))
365            .to_request();
366
367        let resp = test::call_service(&app, req).await;
368        assert!(resp.status().is_success());
369
370        let location_header = resp
371            .headers()
372            .get(HeaderName::from_static(ResponseHeaders::HX_LOCATION))
373            .unwrap();
374
375        assert_eq!(location_header.to_str().unwrap(), "/new-location");
376    }
377
378    #[actix_web::test]
379    async fn test_url_methods() {
380        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
381            "/test",
382            web::get().to(|htmx: Htmx| async move {
383                htmx.push_url("/pushed-url".to_string());
384                htmx.replace_url("/replaced-url".to_string());
385                HttpResponse::Ok().finish()
386            }),
387        ))
388        .await;
389
390        let req = TestRequest::get()
391            .uri("/test")
392            .insert_header((HeaderName::from_static("hx-request"), "true"))
393            .to_request();
394
395        let resp = test::call_service(&app, req).await;
396        assert!(resp.status().is_success());
397
398        let push_url = resp
399            .headers()
400            .get(HeaderName::from_static(ResponseHeaders::HX_PUSH_URL))
401            .unwrap();
402        assert_eq!(push_url.to_str().unwrap(), "/pushed-url");
403
404        let replace_url = resp
405            .headers()
406            .get(HeaderName::from_static(ResponseHeaders::HX_REPLACE_URL))
407            .unwrap();
408        assert_eq!(replace_url.to_str().unwrap(), "/replaced-url");
409    }
410
411    #[actix_web::test]
412    async fn test_target_methods() {
413        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
414            "/test",
415            web::get().to(|htmx: Htmx| async move {
416                htmx.retarget("#new-target".to_string());
417                htmx.reselect("#new-selection".to_string());
418                HttpResponse::Ok().finish()
419            }),
420        ))
421        .await;
422
423        let req = TestRequest::get()
424            .uri("/test")
425            .insert_header((HeaderName::from_static("hx-request"), "true"))
426            .to_request();
427
428        let resp = test::call_service(&app, req).await;
429        assert!(resp.status().is_success());
430
431        let retarget = resp
432            .headers()
433            .get(HeaderName::from_static(ResponseHeaders::HX_RETARGET))
434            .unwrap();
435        assert_eq!(retarget.to_str().unwrap(), "#new-target");
436
437        let reselect = resp
438            .headers()
439            .get(HeaderName::from_static(ResponseHeaders::HX_RESELECT))
440            .unwrap();
441        assert_eq!(reselect.to_str().unwrap(), "#new-selection");
442    }
443
444    #[actix_web::test]
445    async fn test_request_information() {
446        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
447            "/test",
448            web::get().to(|htmx: Htmx| async move {
449                assert_eq!(htmx.current_url().unwrap(), "http://example.com");
450                assert_eq!(htmx.prompt().unwrap(), "test prompt");
451                assert_eq!(htmx.target().unwrap(), "#target");
452                assert_eq!(htmx.trigger().unwrap(), "click");
453                assert_eq!(htmx.trigger_name().unwrap(), "button1");
454                HttpResponse::Ok().finish()
455            }),
456        ))
457        .await;
458
459        let req = TestRequest::get()
460            .uri("/test")
461            .insert_header((HeaderName::from_static("hx-request"), "true"))
462            .insert_header((
463                HeaderName::from_static("hx-current-url"),
464                "http://example.com",
465            ))
466            .insert_header((HeaderName::from_static("hx-prompt"), "test prompt"))
467            .insert_header((HeaderName::from_static("hx-target"), "#target"))
468            .insert_header((HeaderName::from_static("hx-trigger"), "click"))
469            .insert_header((HeaderName::from_static("hx-trigger-name"), "button1"))
470            .to_request();
471
472        let resp = test::call_service(&app, req).await;
473        assert!(resp.status().is_success());
474    }
475
476    #[actix_web::test]
477    async fn test_refresh() {
478        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
479            "/test",
480            web::get().to(|htmx: Htmx| async move {
481                htmx.refresh();
482                HttpResponse::Ok().finish()
483            }),
484        ))
485        .await;
486
487        let req = TestRequest::get()
488            .uri("/test")
489            .insert_header((HeaderName::from_static("hx-request"), "true"))
490            .to_request();
491
492        let resp = test::call_service(&app, req).await;
493        assert!(resp.status().is_success());
494
495        let refresh = resp
496            .headers()
497            .get(HeaderName::from_static(ResponseHeaders::HX_REFRESH))
498            .unwrap();
499        assert_eq!(refresh.to_str().unwrap(), "true");
500    }
501
502    #[actix_web::test]
503    async fn test_malformed_headers() {
504        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
505            "/test",
506            web::get().to(|htmx: Htmx| async move {
507                // Should not panic and return None for malformed headers
508                assert_eq!(htmx.current_url(), None);
509                assert_eq!(htmx.prompt(), None);
510                assert_eq!(htmx.target(), None);
511                // Should not panic and should return false
512                assert_eq!(htmx.is_htmx, false);
513                HttpResponse::Ok().finish()
514            }),
515        ))
516        .await;
517
518        let req = TestRequest::get()
519            .uri("/test")
520            .insert_header((
521                HeaderName::from_static("hx-current-url"),
522                HeaderValue::from_bytes(b"\xFF\xFF").unwrap(),
523            ))
524            .insert_header((
525                HeaderName::from_static("hx-prompt"),
526                HeaderValue::from_bytes(b"\xFF\xFF").unwrap(),
527            ))
528            .insert_header((
529                HeaderName::from_static("hx-target"),
530                HeaderValue::from_bytes(b"\xFF\xFF").unwrap(),
531            ))
532            .insert_header((
533                HeaderName::from_static("hx-request"),
534                HeaderValue::from_bytes(b"\xFF\xFF").unwrap(),
535            ))
536            .to_request();
537
538        let resp = test::call_service(&app, req).await;
539        assert!(resp.status().is_success());
540    }
541
542    #[actix_web::test]
543    async fn test_from_request_with_extensions() {
544        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
545            "/test",
546            web::get().to(|htmx1: Htmx, htmx2: Htmx| async move {
547                // Both instances should be the same when retrieved from extensions
548                assert_eq!(htmx1.is_htmx, htmx2.is_htmx);
549                assert_eq!(htmx1.boosted, htmx2.boosted);
550                HttpResponse::Ok().finish()
551            }),
552        ))
553        .await;
554
555        let req = TestRequest::get()
556            .uri("/test")
557            .insert_header((HeaderName::from_static("hx-request"), "true"))
558            .to_request();
559
560        let resp = test::call_service(&app, req).await;
561        assert!(resp.status().is_success());
562    }
563}