1use axum::extract::FromRequestParts;
19use axum::http::request::Parts;
20use axum::response::{IntoResponse, Response};
21use http::header::{HeaderName, HeaderValue};
22use std::convert::Infallible;
23
24pub const HTMX_JS: &[u8] = include_bytes!("../vendor/htmx.min.js");
30
31pub const HTMX_JS_PATH: &str = "/static/js/htmx.min.js";
33
34pub const HTMX_CSRF_JS_PATH: &str = "/static/js/autumn-htmx-csrf.js";
43
44pub const HTMX_CSRF_JS: &str = r#"(function () {
49 document.addEventListener("htmx:configRequest", function (evt) {
50 var meta = document.querySelector('meta[name="csrf-token"], meta[name="autumn-csrf-token"]');
51
52 if (!meta || !evt.detail || !evt.detail.headers) {
53 return;
54 }
55
56 var header = meta.getAttribute("data-header") || "X-CSRF-Token";
57 evt.detail.headers[header] = meta.getAttribute("content") || "";
58 });
59})();
60"#;
61
62pub const HTMX_VERSION: &str = "2.0.4";
67
68#[allow(dead_code)]
75#[derive(Debug, Clone, Default)]
76pub struct HxRequest {
77 pub is_htmx: bool,
79 pub target: Option<String>,
81 pub trigger: Option<String>,
83 pub trigger_name: Option<String>,
85 pub current_url: Option<String>,
87 pub history_restore_request: bool,
89 pub prompt: Option<String>,
91 pub boosted: bool,
93}
94
95impl<S> FromRequestParts<S> for HxRequest
96where
97 S: Send + Sync,
98{
99 type Rejection = Infallible;
100
101 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
102 let header_str = |name: &'static str| -> Option<String> {
103 parts
104 .headers
105 .get(name)
106 .and_then(|v| v.to_str().ok())
107 .map(ToString::to_string)
108 };
109 let header_bool =
110 |name: &'static str| -> bool { parts.headers.get(name).is_some_and(|v| v == "true") };
111
112 Ok(Self {
113 is_htmx: header_bool("hx-request"),
114 target: header_str("hx-target"),
115 trigger: header_str("hx-trigger"),
116 trigger_name: header_str("hx-trigger-name"),
117 current_url: header_str("hx-current-url"),
118 history_restore_request: header_bool("hx-history-restore-request"),
119 prompt: header_str("hx-prompt"),
120 boosted: header_bool("hx-boosted"),
121 })
122 }
123}
124
125pub trait HxResponseExt: IntoResponse + Sized {
132 fn hx_location(self, url: &str) -> Response {
134 append_hx_header(self, "hx-location", url)
135 }
136
137 fn hx_push_url(self, url: &str) -> Response {
139 append_hx_header(self, "hx-push-url", url)
140 }
141
142 fn hx_redirect(self, url: &str) -> Response {
144 append_hx_header(self, "hx-redirect", url)
145 }
146
147 fn hx_refresh(self) -> Response {
149 append_hx_header(self, "hx-refresh", "true")
150 }
151
152 fn hx_replace_url(self, url: &str) -> Response {
154 append_hx_header(self, "hx-replace-url", url)
155 }
156
157 fn hx_reswap(self, swap: &str) -> Response {
159 append_hx_header(self, "hx-reswap", swap)
160 }
161
162 fn hx_retarget(self, target: &str) -> Response {
164 append_hx_header(self, "hx-retarget", target)
165 }
166
167 fn hx_trigger(self, event: &str) -> Response {
169 append_hx_header(self, "hx-trigger", event)
170 }
171
172 fn hx_trigger_after_settle(self, event: &str) -> Response {
174 append_hx_header(self, "hx-trigger-after-settle", event)
175 }
176
177 fn hx_trigger_after_swap(self, event: &str) -> Response {
179 append_hx_header(self, "hx-trigger-after-swap", event)
180 }
181}
182
183impl<T: IntoResponse> HxResponseExt for T {}
184
185fn append_hx_header<T: IntoResponse>(response: T, name: &'static str, value: &str) -> Response {
186 let mut res = response.into_response();
187 if let Ok(v) = HeaderValue::from_str(value) {
188 res.headers_mut().insert(HeaderName::from_static(name), v);
189 }
190 res
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use axum::http::Request;
197
198 #[test]
199 #[allow(clippy::const_is_empty)]
200 fn htmx_js_is_not_empty() {
201 assert!(!HTMX_JS.is_empty(), "htmx.min.js should not be empty");
202 }
203
204 #[test]
205 fn htmx_js_looks_like_javascript() {
206 let start = std::str::from_utf8(&HTMX_JS[..50]).expect("htmx should be valid UTF-8");
207 assert!(
208 start.contains("htmx") || start.contains("function") || start.contains('('),
209 "htmx.min.js doesn't look like JavaScript: {start}"
210 );
211 }
212
213 #[test]
214 fn htmx_version_matches_expected() {
215 assert_eq!(HTMX_VERSION, "2.0.4");
216 }
217
218 #[test]
219 fn htmx_asset_paths_are_same_origin_static_paths() {
220 assert_eq!(HTMX_JS_PATH, "/static/js/htmx.min.js");
221 assert_eq!(HTMX_CSRF_JS_PATH, "/static/js/autumn-htmx-csrf.js");
222 }
223
224 #[test]
225 fn htmx_csrf_js_configures_request_header_without_inline_wrapper() {
226 assert!(HTMX_CSRF_JS.contains("htmx:configRequest"));
227 assert!(HTMX_CSRF_JS.contains("X-CSRF-Token"));
228 assert!(HTMX_CSRF_JS.contains("csrf-token"));
229 assert!(!HTMX_CSRF_JS.contains("<script"));
230 }
231
232 #[tokio::test]
233 async fn hx_request_extractor_parses_headers() -> Result<(), axum::http::Error> {
234 let req = Request::builder()
235 .header("hx-request", "true")
236 .header("hx-target", "my-div")
237 .header("hx-trigger", "btn")
238 .header("hx-trigger-name", "btn-name")
239 .header("hx-current-url", "http://example.com")
240 .header("hx-history-restore-request", "true")
241 .header("hx-prompt", "yes")
242 .header("hx-boosted", "true")
243 .body(())?;
244 let (mut parts, ()) = req.into_parts();
245
246 let hx = HxRequest::from_request_parts(&mut parts, &())
247 .await
248 .expect("infallible");
249
250 assert!(hx.is_htmx);
251 assert_eq!(hx.target.as_deref(), Some("my-div"));
252 assert_eq!(hx.trigger.as_deref(), Some("btn"));
253 assert_eq!(hx.trigger_name.as_deref(), Some("btn-name"));
254 assert_eq!(hx.current_url.as_deref(), Some("http://example.com"));
255 assert!(hx.history_restore_request);
256 assert_eq!(hx.prompt.as_deref(), Some("yes"));
257 assert!(hx.boosted);
258 Ok(())
259 }
260
261 #[tokio::test]
262 async fn hx_response_ext_adds_headers() {
263 use axum::response::IntoResponse;
264 let response = "hello"
265 .hx_location("/some-location")
266 .hx_push_url("/new-url")
267 .hx_redirect("/login")
268 .hx_refresh()
269 .hx_replace_url("/old-url")
270 .hx_reswap("innerHTML")
271 .hx_retarget("#target")
272 .hx_trigger("my-event")
273 .hx_trigger_after_settle("settled-event")
274 .hx_trigger_after_swap("swapped-event")
275 .into_response();
276
277 let headers = response.headers();
278 assert_eq!(headers.get("hx-location").unwrap(), "/some-location");
279 assert_eq!(headers.get("hx-push-url").unwrap(), "/new-url");
280 assert_eq!(headers.get("hx-redirect").unwrap(), "/login");
281 assert_eq!(headers.get("hx-refresh").unwrap(), "true");
282 assert_eq!(headers.get("hx-replace-url").unwrap(), "/old-url");
283 assert_eq!(headers.get("hx-reswap").unwrap(), "innerHTML");
284 assert_eq!(headers.get("hx-retarget").unwrap(), "#target");
285 assert_eq!(headers.get("hx-trigger").unwrap(), "my-event");
286 assert_eq!(
287 headers.get("hx-trigger-after-settle").unwrap(),
288 "settled-event"
289 );
290 assert_eq!(
291 headers.get("hx-trigger-after-swap").unwrap(),
292 "swapped-event"
293 );
294 }
295
296 #[tokio::test]
297 async fn hx_response_ext_ignores_invalid_header_values() {
298 use axum::response::IntoResponse;
299
300 let invalid_header_value = "invalid\nvalue";
303
304 let response = "hello"
305 .hx_location(invalid_header_value)
306 .hx_push_url(invalid_header_value)
307 .hx_redirect(invalid_header_value)
308 .hx_refresh() .hx_replace_url(invalid_header_value)
310 .hx_reswap(invalid_header_value)
311 .hx_retarget(invalid_header_value)
312 .hx_trigger(invalid_header_value)
313 .hx_trigger_after_settle(invalid_header_value)
314 .hx_trigger_after_swap(invalid_header_value)
315 .into_response();
316
317 let headers = response.headers();
318 assert!(headers.get("hx-location").is_none());
319 assert!(headers.get("hx-push-url").is_none());
320 assert!(headers.get("hx-redirect").is_none());
321 assert_eq!(headers.get("hx-refresh").unwrap(), "true");
323 assert!(headers.get("hx-replace-url").is_none());
324 assert!(headers.get("hx-reswap").is_none());
325 assert!(headers.get("hx-retarget").is_none());
326 assert!(headers.get("hx-trigger").is_none());
327 assert!(headers.get("hx-trigger-after-settle").is_none());
328 assert!(headers.get("hx-trigger-after-swap").is_none());
329 }
330
331 #[tokio::test]
332 async fn hx_request_extractor_handles_missing_headers() -> Result<(), axum::http::Error> {
333 let req = Request::builder().body(())?;
334 let (mut parts, ()) = req.into_parts();
335
336 let hx = HxRequest::from_request_parts(&mut parts, &())
337 .await
338 .expect("infallible");
339
340 assert!(!hx.is_htmx);
341 assert_eq!(hx.target, None);
342 assert_eq!(hx.trigger, None);
343 assert_eq!(hx.trigger_name, None);
344 assert_eq!(hx.current_url, None);
345 assert!(!hx.history_restore_request);
346 assert_eq!(hx.prompt, None);
347 assert!(!hx.boosted);
348 Ok(())
349 }
350}