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_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_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");
}
#[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_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-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_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-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(())
}
}