1use axum::{
2 extract::FromRequestParts,
3 http::{HeaderValue, request::Parts},
4 response::{Html, IntoResponse, Response},
5};
6
7use crate as rullst;
8
9#[derive(Debug, Clone)]
11#[non_exhaustive]
12pub struct HtmxRequest {
13 pub is_htmx: bool,
15 pub trigger: Option<String>,
17 pub target: Option<String>,
19 pub prompt: Option<String>,
21 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#[derive(Debug, Clone)]
79#[non_exhaustive]
80pub struct HtmxResponse {
81 pub content: String,
83 pub trigger: Option<String>,
85 pub redirect: Option<String>,
87 pub refresh: bool,
89}
90
91impl HtmxResponse {
92 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 pub fn trigger(mut self, event: impl Into<String>) -> Self {
104 self.trigger = Some(event.into());
105 self
106 }
107
108 pub fn redirect(mut self, url: impl Into<String>) -> Self {
110 self.redirect = Some(url.into());
111 self
112 }
113
114 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
146pub 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 let res_htmx = render_page(&req_htmx, "Title", "<div>Fragment</div>".to_string());
299 assert_eq!(res_htmx.0, "<div>Fragment</div>");
300
301 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 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 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><script>alert(1)</script></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}