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 location;
49mod middleware;
50mod trigger_payload;
51
52pub use self::{
53    htmx::{Htmx, SwapType, TriggerType},
54    location::HxLocation,
55    middleware::HtmxMiddleware,
56    trigger_payload::TriggerPayload,
57};
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::{headers::ResponseHeaders, HxLocation};
63    use actix_web::http::header::HeaderValue;
64    use actix_web::{
65        http::header::HeaderName,
66        test::{self, TestRequest},
67        web, App, HttpResponse,
68    };
69    use serde::Serialize;
70    use serde_json::{json, Value};
71
72    #[derive(Serialize)]
73    struct LocationValues {
74        id: u32,
75    }
76
77    #[actix_web::test]
78    async fn test_htmx_middleware_basic() {
79        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
80            "/test",
81            web::get().to(|htmx: Htmx| async move {
82                htmx.trigger_event(
83                    "test-event",
84                    Some(TriggerPayload::text("test-value")),
85                    Some(TriggerType::Standard),
86                );
87                HttpResponse::Ok().finish()
88            }),
89        ))
90        .await;
91
92        let req = TestRequest::get()
93            .uri("/test")
94            .insert_header((HeaderName::from_static("hx-request"), "true"))
95            .to_request();
96
97        let resp = test::call_service(&app, req).await;
98        assert!(resp.status().is_success());
99
100        let trigger_header = resp
101            .headers()
102            .get(HeaderName::from_static(ResponseHeaders::HX_TRIGGER))
103            .unwrap();
104        let trigger_json: Value = serde_json::from_str(trigger_header.to_str().unwrap()).unwrap();
105        assert_eq!(trigger_json["test-event"], "test-value");
106    }
107
108    #[actix_web::test]
109    async fn test_htmx_middleware_after_settle() {
110        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
111            "/test",
112            web::get().to(|htmx: Htmx| async move {
113                htmx.trigger_event("settle-event", None, Some(TriggerType::AfterSettle));
114                HttpResponse::Ok().finish()
115            }),
116        ))
117        .await;
118
119        let req = TestRequest::get()
120            .uri("/test")
121            .insert_header((HeaderName::from_static("hx-request"), "true"))
122            .to_request();
123
124        let resp = test::call_service(&app, req).await;
125        assert!(resp.status().is_success());
126
127        let settle_header = resp
128            .headers()
129            .get(HeaderName::from_static(
130                ResponseHeaders::HX_TRIGGER_AFTER_SETTLE,
131            ))
132            .unwrap();
133
134        assert!(settle_header.to_str().unwrap().contains("settle-event"));
135    }
136
137    #[actix_web::test]
138    async fn test_htmx_request_detection() {
139        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
140            "/test",
141            web::get().to(|htmx: Htmx| async move {
142                assert!(htmx.is_htmx);
143                HttpResponse::Ok().finish()
144            }),
145        ))
146        .await;
147
148        let req = TestRequest::get()
149            .uri("/test")
150            .insert_header((HeaderName::from_static("hx-request"), "true"))
151            .to_request();
152
153        let resp = test::call_service(&app, req).await;
154        assert!(resp.status().is_success());
155    }
156
157    #[actix_web::test]
158    async fn test_non_htmx_request() {
159        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
160            "/test",
161            web::get().to(|htmx: Htmx| async move {
162                assert!(!htmx.is_htmx);
163                HttpResponse::Ok().finish()
164            }),
165        ))
166        .await;
167
168        let req = TestRequest::get().uri("/test").to_request();
169        let resp = test::call_service(&app, req).await;
170        assert!(resp.status().is_success());
171    }
172
173    #[actix_web::test]
174    async fn test_boosted_request() {
175        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
176            "/test",
177            web::get().to(|htmx: Htmx| async move {
178                assert!(htmx.boosted);
179                HttpResponse::Ok().finish()
180            }),
181        ))
182        .await;
183
184        let req = TestRequest::get()
185            .uri("/test")
186            .insert_header((HeaderName::from_static("hx-boosted"), "true"))
187            .to_request();
188
189        let resp = test::call_service(&app, req).await;
190        assert!(resp.status().is_success());
191    }
192
193    #[actix_web::test]
194    async fn test_htmx_reswap() {
195        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
196            "/test",
197            web::get().to(|htmx: Htmx| async move {
198                htmx.reswap(SwapType::Delete);
199                HttpResponse::Ok().finish()
200            }),
201        ))
202        .await;
203
204        let req = TestRequest::get()
205            .uri("/test")
206            .insert_header((HeaderName::from_static("hx-request"), "true"))
207            .to_request();
208
209        let resp = test::call_service(&app, req).await;
210        assert!(resp.status().is_success());
211
212        let reswap_header = resp
213            .headers()
214            .get(HeaderName::from_static(ResponseHeaders::HX_RESWAP))
215            .unwrap();
216
217        assert_eq!(reswap_header.to_str().unwrap(), "delete");
218    }
219
220    #[actix_web::test]
221    async fn test_multiple_triggers() {
222        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
223            "/test",
224            web::get().to(|htmx: Htmx| async move {
225                htmx.trigger_event(
226                    "event1",
227                    Some(TriggerPayload::text("value1")),
228                    Some(TriggerType::Standard),
229                );
230                htmx.trigger_event(
231                    "event2",
232                    Some(TriggerPayload::text("value2")),
233                    Some(TriggerType::Standard),
234                );
235                HttpResponse::Ok().finish()
236            }),
237        ))
238        .await;
239
240        let req = TestRequest::get()
241            .uri("/test")
242            .insert_header((HeaderName::from_static("hx-request"), "true"))
243            .to_request();
244
245        let resp = test::call_service(&app, req).await;
246        assert!(resp.status().is_success());
247
248        let trigger_header = resp
249            .headers()
250            .get(HeaderName::from_static(ResponseHeaders::HX_TRIGGER))
251            .unwrap()
252            .to_str()
253            .unwrap();
254
255        assert!(trigger_header.contains("event1"));
256        assert!(trigger_header.contains("value1"));
257        assert!(trigger_header.contains("event2"));
258        assert!(trigger_header.contains("value2"));
259    }
260
261    #[actix_web::test]
262    async fn test_multiple_trigger_types() {
263        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
264            "/test",
265            web::get().to(|htmx: Htmx| async move {
266                htmx.trigger_event(
267                    "standard",
268                    Some(TriggerPayload::text("value1")),
269                    Some(TriggerType::Standard),
270                );
271                htmx.trigger_event(
272                    "after_settle",
273                    Some(TriggerPayload::text("value2")),
274                    Some(TriggerType::AfterSettle),
275                );
276                htmx.trigger_event(
277                    "after_swap",
278                    Some(TriggerPayload::text("value3")),
279                    Some(TriggerType::AfterSwap),
280                );
281                HttpResponse::Ok().finish()
282            }),
283        ))
284        .await;
285
286        let req = TestRequest::get()
287            .uri("/test")
288            .insert_header((HeaderName::from_static("hx-request"), "true"))
289            .to_request();
290
291        let resp = test::call_service(&app, req).await;
292        assert!(resp.status().is_success());
293
294        // Check standard trigger
295        let standard_header = resp
296            .headers()
297            .get(HeaderName::from_static(ResponseHeaders::HX_TRIGGER))
298            .unwrap()
299            .to_str()
300            .unwrap();
301        assert!(standard_header.contains("standard"));
302        assert!(standard_header.contains("value1"));
303
304        // Check after settle trigger
305        let after_settle_header = resp
306            .headers()
307            .get(HeaderName::from_static(
308                ResponseHeaders::HX_TRIGGER_AFTER_SETTLE,
309            ))
310            .unwrap()
311            .to_str()
312            .unwrap();
313        assert!(after_settle_header.contains("after_settle"));
314        assert!(after_settle_header.contains("value2"));
315
316        // Check after swap trigger
317        let after_swap_header = resp
318            .headers()
319            .get(HeaderName::from_static(
320                ResponseHeaders::HX_TRIGGER_AFTER_SWAP,
321            ))
322            .unwrap()
323            .to_str()
324            .unwrap();
325        assert!(after_swap_header.contains("after_swap"));
326        assert!(after_swap_header.contains("value3"));
327    }
328
329    #[actix_web::test]
330    async fn test_multiple_simple_triggers() {
331        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
332            "/simple",
333            web::get().to(|htmx: Htmx| async move {
334                htmx.trigger_event("event1", None, None);
335                htmx.trigger_event("event2", None, None);
336                HttpResponse::Ok().finish()
337            }),
338        ))
339        .await;
340
341        let req = TestRequest::get()
342            .uri("/simple")
343            .insert_header((HeaderName::from_static("hx-request"), "true"))
344            .to_request();
345
346        let resp = test::call_service(&app, req).await;
347        assert!(resp.status().is_success());
348
349        let trigger_header = resp
350            .headers()
351            .get(HeaderName::from_static(ResponseHeaders::HX_TRIGGER))
352            .unwrap()
353            .to_str()
354            .unwrap()
355            .to_string();
356
357        assert_eq!(trigger_header, "event1,event2");
358    }
359
360    #[actix_web::test]
361    async fn test_string_payload_that_looks_like_json() {
362        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
363            "/test",
364            web::get().to(|htmx: Htmx| async move {
365                htmx.trigger_event(
366                    "looks-like-json",
367                    Some(TriggerPayload::text("{not: \"json\"")),
368                    None,
369                );
370                HttpResponse::Ok().finish()
371            }),
372        ))
373        .await;
374
375        let req = TestRequest::get()
376            .uri("/test")
377            .insert_header((HeaderName::from_static("hx-request"), "true"))
378            .to_request();
379
380        let resp = test::call_service(&app, req).await;
381        assert!(resp.status().is_success());
382
383        let trigger_header = resp
384            .headers()
385            .get(HeaderName::from_static(ResponseHeaders::HX_TRIGGER))
386            .unwrap();
387
388        let trigger_json: Value = serde_json::from_str(trigger_header.to_str().unwrap()).unwrap();
389        assert_eq!(
390            trigger_json["looks-like-json"],
391            Value::String("{not: \"json\"".to_string())
392        );
393    }
394
395    #[actix_web::test]
396    async fn test_json_payload_trigger() {
397        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
398            "/test",
399            web::get().to(|htmx: Htmx| async move {
400                let payload = TriggerPayload::json(json!({"id": 1, "complete": false})).unwrap();
401                htmx.trigger_event("json-event", Some(payload), None);
402                HttpResponse::Ok().finish()
403            }),
404        ))
405        .await;
406
407        let req = TestRequest::get()
408            .uri("/test")
409            .insert_header((HeaderName::from_static("hx-request"), "true"))
410            .to_request();
411
412        let resp = test::call_service(&app, req).await;
413        assert!(resp.status().is_success());
414
415        let trigger_header = resp
416            .headers()
417            .get(HeaderName::from_static(ResponseHeaders::HX_TRIGGER))
418            .unwrap();
419
420        let trigger_json: Value = serde_json::from_str(trigger_header.to_str().unwrap()).unwrap();
421        assert_eq!(trigger_json["json-event"]["id"], 1);
422        assert_eq!(trigger_json["json-event"]["complete"], false);
423    }
424
425    #[actix_web::test]
426    async fn test_htmx_redirect() {
427        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
428            "/test",
429            web::get().to(|htmx: Htmx| async move {
430                htmx.redirect("/new-location");
431                HttpResponse::Ok().finish()
432            }),
433        ))
434        .await;
435
436        let req = TestRequest::get()
437            .uri("/test")
438            .insert_header((HeaderName::from_static("hx-request"), "true"))
439            .to_request();
440
441        let resp = test::call_service(&app, req).await;
442        assert!(resp.status().is_success());
443
444        let redirect_header = resp
445            .headers()
446            .get(HeaderName::from_static(ResponseHeaders::HX_REDIRECT))
447            .unwrap();
448
449        assert_eq!(redirect_header.to_str().unwrap(), "/new-location");
450    }
451
452    #[actix_web::test]
453    async fn test_htmx_redirect_with_swap() {
454        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
455            "/test",
456            web::get().to(|htmx: Htmx| async move {
457                htmx.redirect_with_swap("/new-location");
458                HttpResponse::Ok().finish()
459            }),
460        ))
461        .await;
462
463        let req = TestRequest::get()
464            .uri("/test")
465            .insert_header((HeaderName::from_static("hx-request"), "true"))
466            .to_request();
467
468        let resp = test::call_service(&app, req).await;
469        assert!(resp.status().is_success());
470
471        let location_header = resp
472            .headers()
473            .get(HeaderName::from_static(ResponseHeaders::HX_LOCATION))
474            .unwrap();
475
476        assert_eq!(location_header.to_str().unwrap(), "/new-location");
477    }
478
479    #[actix_web::test]
480    async fn test_htmx_redirect_with_location() {
481        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
482            "/test",
483            web::get().to(|htmx: Htmx| async move {
484                let location = HxLocation::new("/builder")
485                    .target("#content")
486                    .source("#button")
487                    .event("custom")
488                    .swap(SwapType::OuterHtml)
489                    .handler("handleResponse")
490                    .select(".fragment")
491                    .header("X-Test", "1")
492                    .values(LocationValues { id: 42 })
493                    .expect("static payload should serialize")
494                    .push_path("/history-path")
495                    .replace("/replace-path");
496                htmx.redirect_with_location(location);
497                HttpResponse::Ok().finish()
498            }),
499        ))
500        .await;
501
502        let req = TestRequest::get()
503            .uri("/test")
504            .insert_header((HeaderName::from_static("hx-request"), "true"))
505            .to_request();
506
507        let resp = test::call_service(&app, req).await;
508        assert!(resp.status().is_success());
509
510        let location_header = resp
511            .headers()
512            .get(HeaderName::from_static(ResponseHeaders::HX_LOCATION))
513            .unwrap();
514        let parsed: Value = serde_json::from_str(location_header.to_str().unwrap()).unwrap();
515        assert_eq!(parsed["path"], "/builder");
516        assert_eq!(parsed["target"], "#content");
517        assert_eq!(parsed["source"], "#button");
518        assert_eq!(parsed["event"], "custom");
519        assert_eq!(parsed["swap"], "outerHTML");
520        assert_eq!(parsed["handler"], "handleResponse");
521        assert_eq!(parsed["select"], ".fragment");
522        assert_eq!(parsed["headers"]["X-Test"], "1");
523        assert_eq!(parsed["values"]["id"], 42);
524        assert_eq!(parsed["push"], "/history-path");
525        assert_eq!(parsed["replace"], "/replace-path");
526    }
527
528    #[actix_web::test]
529    async fn test_url_methods() {
530        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
531            "/test",
532            web::get().to(|htmx: Htmx| async move {
533                htmx.push_url("/pushed-url");
534                htmx.replace_url("/replaced-url");
535                HttpResponse::Ok().finish()
536            }),
537        ))
538        .await;
539
540        let req = TestRequest::get()
541            .uri("/test")
542            .insert_header((HeaderName::from_static("hx-request"), "true"))
543            .to_request();
544
545        let resp = test::call_service(&app, req).await;
546        assert!(resp.status().is_success());
547
548        let push_url = resp
549            .headers()
550            .get(HeaderName::from_static(ResponseHeaders::HX_PUSH_URL))
551            .unwrap();
552        assert_eq!(push_url.to_str().unwrap(), "/pushed-url");
553
554        let replace_url = resp
555            .headers()
556            .get(HeaderName::from_static(ResponseHeaders::HX_REPLACE_URL))
557            .unwrap();
558        assert_eq!(replace_url.to_str().unwrap(), "/replaced-url");
559    }
560
561    #[actix_web::test]
562    async fn test_target_methods() {
563        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
564            "/test",
565            web::get().to(|htmx: Htmx| async move {
566                htmx.retarget("#new-target");
567                htmx.reselect("#new-selection");
568                HttpResponse::Ok().finish()
569            }),
570        ))
571        .await;
572
573        let req = TestRequest::get()
574            .uri("/test")
575            .insert_header((HeaderName::from_static("hx-request"), "true"))
576            .to_request();
577
578        let resp = test::call_service(&app, req).await;
579        assert!(resp.status().is_success());
580
581        let retarget = resp
582            .headers()
583            .get(HeaderName::from_static(ResponseHeaders::HX_RETARGET))
584            .unwrap();
585        assert_eq!(retarget.to_str().unwrap(), "#new-target");
586
587        let reselect = resp
588            .headers()
589            .get(HeaderName::from_static(ResponseHeaders::HX_RESELECT))
590            .unwrap();
591        assert_eq!(reselect.to_str().unwrap(), "#new-selection");
592    }
593
594    #[actix_web::test]
595    async fn test_request_information() {
596        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
597            "/test",
598            web::get().to(|htmx: Htmx| async move {
599                assert_eq!(htmx.current_url().unwrap(), "http://example.com");
600                assert_eq!(htmx.prompt().unwrap(), "test prompt");
601                assert_eq!(htmx.target().unwrap(), "#target");
602                assert_eq!(htmx.trigger().unwrap(), "click");
603                assert_eq!(htmx.trigger_name().unwrap(), "button1");
604                HttpResponse::Ok().finish()
605            }),
606        ))
607        .await;
608
609        let req = TestRequest::get()
610            .uri("/test")
611            .insert_header((HeaderName::from_static("hx-request"), "true"))
612            .insert_header((
613                HeaderName::from_static("hx-current-url"),
614                "http://example.com",
615            ))
616            .insert_header((HeaderName::from_static("hx-prompt"), "test prompt"))
617            .insert_header((HeaderName::from_static("hx-target"), "#target"))
618            .insert_header((HeaderName::from_static("hx-trigger"), "click"))
619            .insert_header((HeaderName::from_static("hx-trigger-name"), "button1"))
620            .to_request();
621
622        let resp = test::call_service(&app, req).await;
623        assert!(resp.status().is_success());
624    }
625
626    #[actix_web::test]
627    async fn test_refresh() {
628        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
629            "/test",
630            web::get().to(|htmx: Htmx| async move {
631                htmx.refresh();
632                HttpResponse::Ok().finish()
633            }),
634        ))
635        .await;
636
637        let req = TestRequest::get()
638            .uri("/test")
639            .insert_header((HeaderName::from_static("hx-request"), "true"))
640            .to_request();
641
642        let resp = test::call_service(&app, req).await;
643        assert!(resp.status().is_success());
644
645        let refresh = resp
646            .headers()
647            .get(HeaderName::from_static(ResponseHeaders::HX_REFRESH))
648            .unwrap();
649        assert_eq!(refresh.to_str().unwrap(), "true");
650    }
651
652    #[actix_web::test]
653    async fn test_malformed_headers() {
654        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
655            "/test",
656            web::get().to(|htmx: Htmx| async move {
657                // Should not panic and return None for malformed headers
658                assert_eq!(htmx.current_url(), None);
659                assert_eq!(htmx.prompt(), None);
660                assert_eq!(htmx.target(), None);
661                // Should not panic and should return false
662                assert_eq!(htmx.is_htmx, false);
663                HttpResponse::Ok().finish()
664            }),
665        ))
666        .await;
667
668        let req = TestRequest::get()
669            .uri("/test")
670            .insert_header((
671                HeaderName::from_static("hx-current-url"),
672                HeaderValue::from_bytes(b"\xFF\xFF").unwrap(),
673            ))
674            .insert_header((
675                HeaderName::from_static("hx-prompt"),
676                HeaderValue::from_bytes(b"\xFF\xFF").unwrap(),
677            ))
678            .insert_header((
679                HeaderName::from_static("hx-target"),
680                HeaderValue::from_bytes(b"\xFF\xFF").unwrap(),
681            ))
682            .insert_header((
683                HeaderName::from_static("hx-request"),
684                HeaderValue::from_bytes(b"\xFF\xFF").unwrap(),
685            ))
686            .to_request();
687
688        let resp = test::call_service(&app, req).await;
689        assert!(resp.status().is_success());
690    }
691
692    #[actix_web::test]
693    async fn test_from_request_with_extensions() {
694        let app = test::init_service(App::new().wrap(HtmxMiddleware).route(
695            "/test",
696            web::get().to(|htmx1: Htmx, htmx2: Htmx| async move {
697                // Both instances should be the same when retrieved from extensions
698                assert_eq!(htmx1.is_htmx, htmx2.is_htmx);
699                assert_eq!(htmx1.boosted, htmx2.boosted);
700                HttpResponse::Ok().finish()
701            }),
702        ))
703        .await;
704
705        let req = TestRequest::get()
706            .uri("/test")
707            .insert_header((HeaderName::from_static("hx-request"), "true"))
708            .to_request();
709
710        let resp = test::call_service(&app, req).await;
711        assert!(resp.status().is_success());
712    }
713}