1mod 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 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 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 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 assert_eq!(htmx.current_url(), None);
659 assert_eq!(htmx.prompt(), None);
660 assert_eq!(htmx.target(), None);
661 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 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}