Skip to main content

rullst_core/
htmx.rs

1use axum::{
2    extract::FromRequestParts,
3    http::{HeaderValue, request::Parts},
4    response::{Html, IntoResponse, Response},
5};
6
7use crate as rullst;
8
9/// Extract's HTMX request headers to determine context and re-act re-actively.
10#[derive(Debug, Clone)]
11#[non_exhaustive]
12pub struct HtmxRequest {
13    /// True if the request was triggered by HTMX in the browser (`HX-Request: true`).
14    pub is_htmx: bool,
15    /// The ID of the triggered element if sent by HTMX (`HX-Trigger`).
16    pub trigger: Option<String>,
17    /// The ID of the target element if sent by HTMX (`HX-Target`).
18    pub target: Option<String>,
19    /// The user response inputted into the prompt if sent by HTMX (`HX-Prompt`).
20    pub prompt: Option<String>,
21    /// The browser's active URL when the request was initiated (`HX-Current-URL`).
22    pub current_url: Option<String>,
23    csp_nonce: Option<crate::security::CspNonce>,
24}
25
26impl<S> FromRequestParts<S> for HtmxRequest
27where
28    S: Send + Sync,
29{
30    type Rejection = std::convert::Infallible;
31
32    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
33        let is_htmx = parts
34            .headers
35            .get("HX-Request")
36            .and_then(|v| v.to_str().ok())
37            .map(|v| v == "true")
38            .unwrap_or(false);
39
40        let trigger = parts
41            .headers
42            .get("HX-Trigger")
43            .and_then(|v| v.to_str().ok())
44            .map(|s| s.to_string());
45
46        let target = parts
47            .headers
48            .get("HX-Target")
49            .and_then(|v| v.to_str().ok())
50            .map(|s| s.to_string());
51
52        let prompt = parts
53            .headers
54            .get("HX-Prompt")
55            .and_then(|v| v.to_str().ok())
56            .map(|s| s.to_string());
57
58        let current_url = parts
59            .headers
60            .get("HX-Current-URL")
61            .and_then(|v| v.to_str().ok())
62            .map(|s| s.to_string());
63
64        let csp_nonce = parts.extensions.get::<crate::security::CspNonce>().cloned();
65
66        Ok(HtmxRequest {
67            is_htmx,
68            trigger,
69            target,
70            prompt,
71            current_url,
72            csp_nonce,
73        })
74    }
75}
76
77/// A highly ergonomic, builder-style HTMX responder to set dynamic headers in client side.
78#[derive(Debug, Clone)]
79#[non_exhaustive]
80pub struct HtmxResponse {
81    /// The inner HTML content to be sent in the response body.
82    pub content: String,
83    /// Event name to trigger a custom client-side event (`HX-Trigger`).
84    pub trigger: Option<String>,
85    /// Target path to redirect the client side to a new page (`HX-Redirect`).
86    pub redirect: Option<String>,
87    /// Set to true to trigger a full page refresh on the client (`HX-Refresh: true`).
88    pub refresh: bool,
89}
90
91impl HtmxResponse {
92    /// Creates a new base HTMX response with raw HTML content.
93    pub fn new(content: impl Into<String>) -> Self {
94        Self {
95            content: content.into(),
96            trigger: None,
97            redirect: None,
98            refresh: false,
99        }
100    }
101
102    /// Triggers a custom event in the browser on response completion.
103    pub fn trigger(mut self, event: impl Into<String>) -> Self {
104        self.trigger = Some(event.into());
105        self
106    }
107
108    /// Triggers a client-side browser redirect to a new path.
109    pub fn redirect(mut self, url: impl Into<String>) -> Self {
110        self.redirect = Some(url.into());
111        self
112    }
113
114    /// Triggers a full browser refresh when the client processes the response.
115    pub fn refresh(mut self) -> Self {
116        self.refresh = true;
117        self
118    }
119}
120
121impl IntoResponse for HtmxResponse {
122    fn into_response(self) -> Response {
123        let mut res = Html(self.content).into_response();
124        let headers = res.headers_mut();
125
126        if let Some(ref trigger) = self.trigger
127            && let Ok(val) = HeaderValue::from_str(trigger)
128        {
129            headers.insert("HX-Trigger", val);
130        }
131
132        if let Some(ref redirect) = self.redirect
133            && let Ok(val) = HeaderValue::from_str(redirect)
134        {
135            headers.insert("HX-Redirect", val);
136        }
137
138        if self.refresh {
139            headers.insert("HX-Refresh", HeaderValue::from_static("true"));
140        }
141
142        res
143    }
144}
145
146/// Helper function to render a hybrid SSR layout page.
147/// - If it is triggered by HTMX, it returns just the inner `content` as a fragment.
148/// - Otherwise, it wraps `content` in an HTML5 skeleton that loads the generated
149///   same-origin stylesheet and the version-pinned HTMX browser client.
150///   CLI scaffolds supply `/static/htmx-1.9.12.min.js`; manually built
151///   applications must serve that asset themselves. `content` is trusted HTML:
152///   escape untrusted values before constructing this fragment.
153pub fn render_page(htmx: &HtmxRequest, title: &str, content: String) -> Html<String> {
154    if htmx.is_htmx {
155        Html(content)
156    } else {
157        let htmx_script = if let Some(nonce) = htmx.csp_nonce.as_ref() {
158            crate::html! {
159                <script nonce={nonce.as_str()} src="/static/htmx-1.9.12.min.js"></script>
160            }
161        } else {
162            crate::html! {
163                <script src="/static/htmx-1.9.12.min.js"></script>
164            }
165        };
166        let html_content = crate::html! {
167            <html lang="pt-BR" class="h-full bg-slate-950 text-slate-100">
168                <head>
169                    <meta charset="utf-8" />
170                    <title>{title}</title>
171                    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
172                    <link rel="icon" type="image/png" href="/static/rullst.png" />
173                    <link rel="stylesheet" href="/static/rullst.css" />
174                    { crate::html::RawHtml(htmx_script) }
175                </head>
176                <body class="h-full">
177                    { crate::html::RawHtml(content) }
178                </body>
179            </html>
180        };
181        Html(format!("<!DOCTYPE html>{}", html_content))
182    }
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used, clippy::expect_used)]
187mod tests {
188    use super::*;
189    use axum::http::Request;
190
191    #[tokio::test]
192    async fn test_htmx_request_extractor_empty() {
193        let req = Request::builder().body(()).unwrap();
194        let (mut parts, _) = req.into_parts();
195        let htmx_req = HtmxRequest::from_request_parts(&mut parts, &())
196            .await
197            .unwrap();
198
199        assert!(!htmx_req.is_htmx);
200        assert!(htmx_req.trigger.is_none());
201        assert!(htmx_req.target.is_none());
202        assert!(htmx_req.prompt.is_none());
203        assert!(htmx_req.current_url.is_none());
204    }
205
206    #[tokio::test]
207    async fn test_htmx_request_extractor_headers() {
208        let nonce = crate::security::CspNonce::generate();
209        let mut req = Request::builder()
210            .header("HX-Request", "true")
211            .header("HX-Trigger", "my-btn")
212            .header("HX-Target", "content-div")
213            .header("HX-Prompt", "hello")
214            .header("HX-Current-URL", "http://localhost/home")
215            .body(())
216            .unwrap();
217        req.extensions_mut().insert(nonce.clone());
218        let (mut parts, _) = req.into_parts();
219        let htmx_req = HtmxRequest::from_request_parts(&mut parts, &())
220            .await
221            .unwrap();
222
223        assert!(htmx_req.is_htmx);
224        assert_eq!(htmx_req.trigger.as_deref(), Some("my-btn"));
225        assert_eq!(htmx_req.target.as_deref(), Some("content-div"));
226        assert_eq!(htmx_req.prompt.as_deref(), Some("hello"));
227        assert_eq!(
228            htmx_req.current_url.as_deref(),
229            Some("http://localhost/home")
230        );
231        assert_eq!(htmx_req.csp_nonce.as_ref(), Some(&nonce));
232    }
233
234    #[test]
235    fn test_htmx_response_builder() {
236        let res = HtmxResponse::new("Hello world")
237            .trigger("custom-event")
238            .redirect("/new-path")
239            .refresh();
240
241        assert_eq!(res.content, "Hello world");
242        assert_eq!(res.trigger.as_deref(), Some("custom-event"));
243        assert_eq!(res.redirect.as_deref(), Some("/new-path"));
244        assert!(res.refresh);
245    }
246
247    #[tokio::test]
248    async fn test_htmx_response_into_response() {
249        use axum::response::IntoResponse;
250        let res = HtmxResponse::new("Hello world")
251            .trigger("my-trigger")
252            .redirect("/some-redirect")
253            .refresh()
254            .into_response();
255
256        let headers = res.headers();
257        assert_eq!(headers.get("HX-Trigger").unwrap(), "my-trigger");
258        assert_eq!(headers.get("HX-Redirect").unwrap(), "/some-redirect");
259        assert_eq!(headers.get("HX-Refresh").unwrap(), "true");
260    }
261
262    #[test]
263    fn test_htmx_response_refresh_builder() {
264        let res = HtmxResponse::new("Hello").refresh();
265        assert!(res.refresh);
266    }
267
268    #[tokio::test]
269    async fn test_htmx_response_refresh_into_response() {
270        use axum::response::IntoResponse;
271        let res = HtmxResponse::new("Hello world").refresh().into_response();
272        let headers = res.headers();
273        assert_eq!(headers.get("HX-Refresh").unwrap(), "true");
274        assert!(headers.get("HX-Trigger").is_none());
275        assert!(headers.get("HX-Redirect").is_none());
276    }
277
278    #[test]
279    fn test_render_page_helper() {
280        let req_htmx = HtmxRequest {
281            is_htmx: true,
282            trigger: None,
283            target: None,
284            prompt: None,
285            current_url: None,
286            csp_nonce: None,
287        };
288        let req_normal = HtmxRequest {
289            is_htmx: false,
290            trigger: None,
291            target: None,
292            prompt: None,
293            current_url: None,
294            csp_nonce: None,
295        };
296
297        // HTMX request -> only the inner content fragment
298        let res_htmx = render_page(&req_htmx, "Title", "<div>Fragment</div>".to_string());
299        assert_eq!(res_htmx.0, "<div>Fragment</div>");
300
301        // Normal request -> wraps in HTML template
302        let res_normal = render_page(
303            &req_normal,
304            "My Page Title",
305            "<div>Body Content</div>".to_string(),
306        );
307        assert!(res_normal.0.contains("<!DOCTYPE html>"));
308        assert!(res_normal.0.contains("<title>My Page Title</title>"));
309        assert!(res_normal.0.contains("<div>Body Content</div>"));
310        assert!(res_normal.0.contains("/static/rullst.css"));
311        assert!(res_normal.0.contains("/static/rullst.png"));
312        assert!(!res_normal.0.contains("https://cdn.tailwindcss.com"));
313        assert!(res_normal.0.contains("/static/htmx-1.9.12.min.js"));
314        assert!(!res_normal.0.contains("https://unpkg.com"));
315    }
316
317    #[test]
318    fn test_render_page_edge_cases() {
319        let req_normal = HtmxRequest {
320            is_htmx: false,
321            trigger: None,
322            target: None,
323            prompt: None,
324            current_url: None,
325            csp_nonce: None,
326        };
327
328        // Empty content
329        let res_empty = render_page(&req_normal, "Empty Title", "".to_string());
330        assert!(res_empty.0.contains("<title>Empty Title</title>"));
331        assert!(res_empty.0.contains("<body class=\"h-full\">"));
332
333        // Unusual titles
334        let res_special = render_page(
335            &req_normal,
336            "<script>alert(1)</script>",
337            "content".to_string(),
338        );
339        assert!(
340            res_special
341                .0
342                .contains("<title>&lt;script&gt;alert(1)&lt;/script&gt;</title>")
343        );
344    }
345
346    #[tokio::test]
347    async fn rendered_htmx_script_uses_the_request_csp_nonce() {
348        let nonce = crate::security::CspNonce::generate();
349        let mut request = Request::new(());
350        request.extensions_mut().insert(nonce.clone());
351        let (mut parts, _) = request.into_parts();
352        let htmx = HtmxRequest::from_request_parts(&mut parts, &())
353            .await
354            .unwrap();
355
356        let response = render_page(&htmx, "Nonce", "content".to_string());
357        assert!(
358            response
359                .0
360                .contains(&format!("nonce=\"{}\"", nonce.as_str()))
361        );
362    }
363}