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