use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use http::header::{HeaderName, HeaderValue};
use std::convert::Infallible;
pub const HTMX_JS: &[u8] = include_bytes!("../vendor/htmx.min.js");
pub const HTMX_JS_PATH: &str = "/static/js/htmx.min.js";
pub const HTMX_CSRF_JS_PATH: &str = "/static/js/autumn-htmx-csrf.js";
pub const HTMX_CSRF_JS: &str = r#"(function () {
document.addEventListener("htmx:configRequest", function (evt) {
var meta = document.querySelector('meta[name="csrf-token"], meta[name="autumn-csrf-token"]');
if (!meta || !evt.detail || !evt.detail.headers) {
return;
}
var header = meta.getAttribute("data-header") || "X-CSRF-Token";
evt.detail.headers[header] = meta.getAttribute("content") || "";
});
})();
"#;
pub const HTMX_VERSION: &str = "2.0.4";
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct HxRequest {
pub is_htmx: bool,
pub target: Option<String>,
pub trigger: Option<String>,
pub trigger_name: Option<String>,
pub current_url: Option<String>,
pub history_restore_request: bool,
pub prompt: Option<String>,
pub boosted: bool,
}
impl<S> FromRequestParts<S> for HxRequest
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let header_str = |name: &'static str| -> Option<String> {
parts
.headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(ToString::to_string)
};
let header_bool =
|name: &'static str| -> bool { parts.headers.get(name).is_some_and(|v| v == "true") };
Ok(Self {
is_htmx: header_bool("hx-request"),
target: header_str("hx-target"),
trigger: header_str("hx-trigger"),
trigger_name: header_str("hx-trigger-name"),
current_url: header_str("hx-current-url"),
history_restore_request: header_bool("hx-history-restore-request"),
prompt: header_str("hx-prompt"),
boosted: header_bool("hx-boosted"),
})
}
}
pub trait HxResponseExt: IntoResponse + Sized {
fn hx_location(self, url: &str) -> Response {
append_hx_header(self, "hx-location", url)
}
fn hx_push_url(self, url: &str) -> Response {
append_hx_header(self, "hx-push-url", url)
}
fn hx_redirect(self, url: &str) -> Response {
append_hx_header(self, "hx-redirect", url)
}
fn hx_refresh(self) -> Response {
append_hx_header(self, "hx-refresh", "true")
}
fn hx_replace_url(self, url: &str) -> Response {
append_hx_header(self, "hx-replace-url", url)
}
fn hx_reswap(self, swap: &str) -> Response {
append_hx_header(self, "hx-reswap", swap)
}
fn hx_retarget(self, target: &str) -> Response {
append_hx_header(self, "hx-retarget", target)
}
fn hx_trigger(self, event: &str) -> Response {
append_hx_header(self, "hx-trigger", event)
}
fn hx_trigger_after_settle(self, event: &str) -> Response {
append_hx_header(self, "hx-trigger-after-settle", event)
}
fn hx_trigger_after_swap(self, event: &str) -> Response {
append_hx_header(self, "hx-trigger-after-swap", event)
}
}
impl<T: IntoResponse> HxResponseExt for T {}
fn append_hx_header<T: IntoResponse>(response: T, name: &'static str, value: &str) -> Response {
let mut res = response.into_response();
if let Ok(v) = HeaderValue::from_str(value) {
res.headers_mut().insert(HeaderName::from_static(name), v);
}
res
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
#[test]
#[allow(clippy::const_is_empty)]
fn htmx_js_is_not_empty() {
assert!(!HTMX_JS.is_empty(), "htmx.min.js should not be empty");
}
#[test]
fn htmx_js_looks_like_javascript() {
let start = std::str::from_utf8(&HTMX_JS[..50]).expect("htmx should be valid UTF-8");
assert!(
start.contains("htmx") || start.contains("function") || start.contains('('),
"htmx.min.js doesn't look like JavaScript: {start}"
);
}
#[test]
fn htmx_version_matches_expected() {
assert_eq!(HTMX_VERSION, "2.0.4");
}
#[test]
fn htmx_asset_paths_are_same_origin_static_paths() {
assert_eq!(HTMX_JS_PATH, "/static/js/htmx.min.js");
assert_eq!(HTMX_CSRF_JS_PATH, "/static/js/autumn-htmx-csrf.js");
}
#[test]
fn htmx_csrf_js_configures_request_header_without_inline_wrapper() {
assert!(HTMX_CSRF_JS.contains("htmx:configRequest"));
assert!(HTMX_CSRF_JS.contains("X-CSRF-Token"));
assert!(HTMX_CSRF_JS.contains("csrf-token"));
assert!(!HTMX_CSRF_JS.contains("<script"));
}
#[tokio::test]
async fn hx_request_extractor_parses_headers() -> Result<(), axum::http::Error> {
let req = Request::builder()
.header("hx-request", "true")
.header("hx-target", "my-div")
.header("hx-trigger", "btn")
.header("hx-trigger-name", "btn-name")
.header("hx-current-url", "http://example.com")
.header("hx-history-restore-request", "true")
.header("hx-prompt", "yes")
.header("hx-boosted", "true")
.body(())?;
let (mut parts, ()) = req.into_parts();
let hx = HxRequest::from_request_parts(&mut parts, &())
.await
.expect("infallible");
assert!(hx.is_htmx);
assert_eq!(hx.target.as_deref(), Some("my-div"));
assert_eq!(hx.trigger.as_deref(), Some("btn"));
assert_eq!(hx.trigger_name.as_deref(), Some("btn-name"));
assert_eq!(hx.current_url.as_deref(), Some("http://example.com"));
assert!(hx.history_restore_request);
assert_eq!(hx.prompt.as_deref(), Some("yes"));
assert!(hx.boosted);
Ok(())
}
#[tokio::test]
async fn hx_response_ext_adds_headers() {
use axum::response::IntoResponse;
let response = "hello"
.hx_location("/some-location")
.hx_push_url("/new-url")
.hx_redirect("/login")
.hx_refresh()
.hx_replace_url("/old-url")
.hx_reswap("innerHTML")
.hx_retarget("#target")
.hx_trigger("my-event")
.hx_trigger_after_settle("settled-event")
.hx_trigger_after_swap("swapped-event")
.into_response();
let headers = response.headers();
assert_eq!(headers.get("hx-location").unwrap(), "/some-location");
assert_eq!(headers.get("hx-push-url").unwrap(), "/new-url");
assert_eq!(headers.get("hx-redirect").unwrap(), "/login");
assert_eq!(headers.get("hx-refresh").unwrap(), "true");
assert_eq!(headers.get("hx-replace-url").unwrap(), "/old-url");
assert_eq!(headers.get("hx-reswap").unwrap(), "innerHTML");
assert_eq!(headers.get("hx-retarget").unwrap(), "#target");
assert_eq!(headers.get("hx-trigger").unwrap(), "my-event");
assert_eq!(
headers.get("hx-trigger-after-settle").unwrap(),
"settled-event"
);
assert_eq!(
headers.get("hx-trigger-after-swap").unwrap(),
"swapped-event"
);
}
#[tokio::test]
async fn hx_response_ext_ignores_invalid_header_values() {
use axum::response::IntoResponse;
let invalid_header_value = "invalid\nvalue";
let response = "hello"
.hx_location(invalid_header_value)
.hx_push_url(invalid_header_value)
.hx_redirect(invalid_header_value)
.hx_refresh() .hx_replace_url(invalid_header_value)
.hx_reswap(invalid_header_value)
.hx_retarget(invalid_header_value)
.hx_trigger(invalid_header_value)
.hx_trigger_after_settle(invalid_header_value)
.hx_trigger_after_swap(invalid_header_value)
.into_response();
let headers = response.headers();
assert!(headers.get("hx-location").is_none());
assert!(headers.get("hx-push-url").is_none());
assert!(headers.get("hx-redirect").is_none());
assert_eq!(headers.get("hx-refresh").unwrap(), "true");
assert!(headers.get("hx-replace-url").is_none());
assert!(headers.get("hx-reswap").is_none());
assert!(headers.get("hx-retarget").is_none());
assert!(headers.get("hx-trigger").is_none());
assert!(headers.get("hx-trigger-after-settle").is_none());
assert!(headers.get("hx-trigger-after-swap").is_none());
}
#[tokio::test]
async fn hx_request_extractor_handles_missing_headers() -> Result<(), axum::http::Error> {
let req = Request::builder().body(())?;
let (mut parts, ()) = req.into_parts();
let hx = HxRequest::from_request_parts(&mut parts, &())
.await
.expect("infallible");
assert!(!hx.is_htmx);
assert_eq!(hx.target, None);
assert_eq!(hx.trigger, None);
assert_eq!(hx.trigger_name, None);
assert_eq!(hx.current_url, None);
assert!(!hx.history_restore_request);
assert_eq!(hx.prompt, None);
assert!(!hx.boosted);
Ok(())
}
}