1use crate::config::InertiaConfig;
4use crate::manifest::resolve_assets;
5use crate::request::InertiaRequest;
6use crate::shared::InertiaShared;
7use serde::Serialize;
8
9fn escape_html(value: &str) -> String {
12 value
13 .replace('&', "&")
14 .replace('<', "<")
15 .replace('>', ">")
16 .replace('"', """)
17 .replace('\'', "'")
18}
19
20#[derive(Debug, Clone)]
24pub struct InertiaHttpResponse {
25 pub status: u16,
27 pub headers: Vec<(String, String)>,
29 pub body: String,
31 pub content_type: &'static str,
33}
34
35impl InertiaHttpResponse {
36 pub fn json(body: impl Into<String>) -> Self {
38 Self {
39 status: 200,
40 headers: vec![
41 ("X-Inertia".to_string(), "true".to_string()),
42 ("Vary".to_string(), "X-Inertia".to_string()),
43 ],
44 body: body.into(),
45 content_type: "application/json",
46 }
47 }
48
49 pub fn raw_json(body: impl Into<String>) -> Self {
53 Self {
54 status: 200,
55 headers: vec![],
56 body: body.into(),
57 content_type: "application/json",
58 }
59 }
60
61 pub fn html(body: impl Into<String>) -> Self {
63 Self {
64 status: 200,
65 headers: vec![("Vary".to_string(), "X-Inertia".to_string())],
66 body: body.into(),
67 content_type: "text/html; charset=utf-8",
68 }
69 }
70
71 pub fn conflict(location: impl Into<String>) -> Self {
73 Self {
74 status: 409,
75 headers: vec![("X-Inertia-Location".to_string(), location.into())],
76 body: String::new(),
77 content_type: "text/plain",
78 }
79 }
80
81 pub fn status(mut self, status: u16) -> Self {
83 self.status = status;
84 self
85 }
86
87 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
89 self.headers.push((name.into(), value.into()));
90 self
91 }
92
93 pub fn redirect(location: impl Into<String>, is_post_like: bool) -> Self {
100 let status = if is_post_like { 303 } else { 302 };
103
104 Self {
105 status,
106 headers: vec![
107 ("X-Inertia".to_string(), "true".to_string()),
108 ("Location".to_string(), location.into()),
109 ],
110 body: String::new(),
111 content_type: "text/plain",
112 }
113 }
114}
115
116pub struct Inertia;
120
121impl Inertia {
122 pub fn render<R, P>(req: &R, component: &str, props: P) -> InertiaHttpResponse
149 where
150 R: InertiaRequest,
151 P: Serialize,
152 {
153 Self::render_internal(req, component, props, None, InertiaConfig::default(), false)
154 }
155
156 pub fn render_with_json_fallback<R, P>(
179 req: &R,
180 component: &str,
181 props: P,
182 ) -> InertiaHttpResponse
183 where
184 R: InertiaRequest,
185 P: Serialize,
186 {
187 Self::render_internal(req, component, props, None, InertiaConfig::default(), true)
188 }
189
190 pub fn render_with_shared<R, P>(
192 req: &R,
193 component: &str,
194 props: P,
195 shared: &InertiaShared,
196 ) -> InertiaHttpResponse
197 where
198 R: InertiaRequest,
199 P: Serialize,
200 {
201 Self::render_internal(
202 req,
203 component,
204 props,
205 Some(shared),
206 InertiaConfig::default(),
207 false,
208 )
209 }
210
211 pub fn render_with_config<R, P>(
213 req: &R,
214 component: &str,
215 props: P,
216 config: InertiaConfig,
217 ) -> InertiaHttpResponse
218 where
219 R: InertiaRequest,
220 P: Serialize,
221 {
222 Self::render_internal(req, component, props, None, config, false)
223 }
224
225 pub fn render_with_options<R, P>(
227 req: &R,
228 component: &str,
229 props: P,
230 shared: Option<&InertiaShared>,
231 config: InertiaConfig,
232 ) -> InertiaHttpResponse
233 where
234 R: InertiaRequest,
235 P: Serialize,
236 {
237 Self::render_internal(req, component, props, shared, config, false)
238 }
239
240 pub fn render_with_options_and_json_fallback<R, P>(
242 req: &R,
243 component: &str,
244 props: P,
245 shared: Option<&InertiaShared>,
246 config: InertiaConfig,
247 ) -> InertiaHttpResponse
248 where
249 R: InertiaRequest,
250 P: Serialize,
251 {
252 Self::render_internal(req, component, props, shared, config, true)
253 }
254
255 fn render_internal<R, P>(
257 req: &R,
258 component: &str,
259 props: P,
260 shared: Option<&InertiaShared>,
261 config: InertiaConfig,
262 json_fallback: bool,
263 ) -> InertiaHttpResponse
264 where
265 R: InertiaRequest,
266 P: Serialize,
267 {
268 let url = req.path().to_string();
269 let is_inertia = req.is_inertia();
270 let partial_data = req.inertia_partial_data();
271 let partial_component = req.inertia_partial_component();
272
273 let mut props_value = match serde_json::to_value(&props) {
275 Ok(v) => v,
276 Err(e) => {
277 return InertiaHttpResponse::html(format!("Failed to serialize props: {e}"))
278 .status(500);
279 }
280 };
281
282 if let Some(shared) = shared {
284 shared.merge_into(&mut props_value);
285 }
286
287 if is_inertia {
289 if let Some(partial_keys) = partial_data {
290 let should_filter = partial_component.map(|pc| pc == component).unwrap_or(false);
291
292 if should_filter {
293 props_value = Self::filter_partial_props(props_value, &partial_keys);
294 }
295 }
296 }
297
298 if json_fallback && !is_inertia && req.accepts_json() {
302 return InertiaHttpResponse::raw_json(
303 serde_json::to_string(&props_value).unwrap_or_default(),
304 );
305 }
306
307 let response = InertiaResponse::new(component, props_value, url).with_config(config);
308
309 let csrf = shared.and_then(|s| s.csrf.as_deref());
311
312 if is_inertia {
313 response.to_json_response()
314 } else {
315 response.to_html_response(csrf)
316 }
317 }
318
319 pub fn check_version<R: InertiaRequest>(
323 req: &R,
324 current_version: &str,
325 redirect_url: &str,
326 ) -> Option<InertiaHttpResponse> {
327 if !req.is_inertia() {
328 return None;
329 }
330
331 if let Some(client_version) = req.inertia_version() {
332 if client_version != current_version {
333 return Some(InertiaHttpResponse::conflict(redirect_url));
334 }
335 }
336
337 None
338 }
339
340 fn filter_partial_props(props: serde_json::Value, partial_keys: &[&str]) -> serde_json::Value {
342 match props {
343 serde_json::Value::Object(map) => {
344 let filtered: serde_json::Map<String, serde_json::Value> = map
345 .into_iter()
346 .filter(|(k, _)| partial_keys.contains(&k.as_str()))
347 .collect();
348 serde_json::Value::Object(filtered)
349 }
350 other => other,
351 }
352 }
353}
354
355pub struct InertiaResponse {
357 component: String,
358 props: serde_json::Value,
359 url: String,
360 config: InertiaConfig,
361}
362
363impl InertiaResponse {
364 pub fn new(component: impl Into<String>, props: serde_json::Value, url: String) -> Self {
366 Self {
367 component: component.into(),
368 props,
369 url,
370 config: InertiaConfig::default(),
371 }
372 }
373
374 pub fn with_config(mut self, config: InertiaConfig) -> Self {
376 self.config = config;
377 self
378 }
379
380 pub fn to_json_response(&self) -> InertiaHttpResponse {
382 let page = serde_json::json!({
383 "component": self.component,
384 "props": self.props,
385 "url": self.url,
386 "version": self.config.version,
387 });
388
389 InertiaHttpResponse::json(serde_json::to_string(&page).unwrap_or_default())
390 }
391
392 pub fn to_html_response(&self, csrf_token: Option<&str>) -> InertiaHttpResponse {
394 let page_data = serde_json::json!({
395 "component": self.component,
396 "props": self.props,
397 "url": self.url,
398 "version": self.config.version,
399 });
400
401 let page_json = escape_html(&serde_json::to_string(&page_data).unwrap_or_default());
403
404 let csrf = escape_html(csrf_token.unwrap_or(""));
405
406 if let Some(template) = &self.config.html_template {
408 let html = template
409 .replace("{page}", &page_json)
410 .replace("{csrf}", &csrf);
411 return InertiaHttpResponse::html(html);
412 }
413
414 let title_text = escape_html(
419 self.config
420 .title
421 .as_deref()
422 .unwrap_or(&self.config.app_name),
423 );
424 let head_extras = self.config.head_extras.as_deref().unwrap_or("");
425 let mount_id = escape_html(&self.config.mount_id);
426
427 let html = if self.config.development {
429 format!(
430 r#"<!DOCTYPE html>
431<html lang="en">
432<head>
433 <meta charset="UTF-8">
434 <meta name="viewport" content="width=device-width, initial-scale=1.0">
435 <meta name="csrf-token" content="{}">
436 <title>{}</title>
437 <script type="module">
438 import RefreshRuntime from '{}/@react-refresh'
439 RefreshRuntime.injectIntoGlobalHook(window)
440 window.$RefreshReg$ = () => {{}}
441 window.$RefreshSig$ = () => (type) => type
442 window.__vite_plugin_react_preamble_installed__ = true
443 </script>
444 <script type="module" src="{}/@vite/client"></script>
445 <script type="module" src="{}/{}"></script>
446 {}
447</head>
448<body>
449 <div id="{}" data-page="{}"></div>
450</body>
451</html>"#,
452 csrf,
453 title_text,
454 self.config.vite_dev_server,
455 self.config.vite_dev_server,
456 self.config.vite_dev_server,
457 self.config.entry_point,
458 head_extras,
459 mount_id,
460 page_json
461 )
462 } else {
463 let assets = resolve_assets(&self.config.manifest_path, &self.config.entry_point);
464
465 let css_tags: String = assets
466 .css
467 .iter()
468 .map(|href| format!(r#" <link rel="stylesheet" href="{href}">"#))
469 .collect::<Vec<_>>()
470 .join("\n");
471
472 format!(
473 r#"<!DOCTYPE html>
474<html lang="en">
475<head>
476 <meta charset="UTF-8">
477 <meta name="viewport" content="width=device-width, initial-scale=1.0">
478 <meta name="csrf-token" content="{csrf}">
479 <title>{title_text}</title>
480 <script type="module" src="{js_src}"></script>
481{css_tags}
482 {head_extras}
483</head>
484<body>
485 <div id="{mount_id}" data-page="{page_json}"></div>
486</body>
487</html>"#,
488 js_src = assets.js,
489 title_text = title_text,
490 head_extras = head_extras,
491 mount_id = mount_id,
492 )
493 };
494
495 InertiaHttpResponse::html(html)
496 }
497}
498
499#[cfg(test)]
500mod content_negotiation_tests {
501 use super::*;
502 use crate::config::InertiaConfig;
503
504 struct MockReq {
505 is_inertia: bool,
506 path: &'static str,
507 }
508
509 impl crate::request::InertiaRequest for MockReq {
510 fn inertia_header(&self, name: &str) -> Option<&str> {
511 if name == "X-Inertia" && self.is_inertia {
512 Some("true")
513 } else {
514 None
515 }
516 }
517 fn path(&self) -> &str {
518 self.path
519 }
520 }
521
522 #[test]
523 fn non_inertia_request_returns_html_document() {
524 let req = MockReq {
525 is_inertia: false,
526 path: "/home",
527 };
528 let resp = Inertia::render_with_config(
529 &req,
530 "Home",
531 serde_json::json!({"title": "Hi"}),
532 InertiaConfig::new().development(),
533 );
534 assert_eq!(resp.content_type, "text/html; charset=utf-8");
535 assert!(resp.body.contains("<!DOCTYPE html>"));
536 assert!(resp.body.contains(r#"data-page=""#));
537 }
538
539 #[test]
540 fn inertia_request_returns_json_contract() {
541 let req = MockReq {
542 is_inertia: true,
543 path: "/home",
544 };
545 let resp = Inertia::render_with_config(
546 &req,
547 "Home",
548 serde_json::json!({"title": "Hi"}),
549 InertiaConfig::new().development(),
550 );
551 assert_eq!(resp.content_type, "application/json");
552 let body: serde_json::Value = serde_json::from_str(&resp.body).unwrap();
553 assert_eq!(body["component"], "Home");
554 }
555
556 #[test]
557 fn html_data_page_equals_json_contract() {
558 let props = serde_json::json!({"title": "Hi", "count": 42});
559 let cfg = InertiaConfig::new().development().version("test-1");
560 let html = Inertia::render_with_config(
561 &MockReq {
562 is_inertia: false,
563 path: "/home",
564 },
565 "Home",
566 props.clone(),
567 cfg.clone(),
568 );
569 let json = Inertia::render_with_config(
570 &MockReq {
571 is_inertia: true,
572 path: "/home",
573 },
574 "Home",
575 props,
576 cfg,
577 );
578 let start = html.body.find(r#"data-page=""#).unwrap() + 11;
579 let end = html.body[start..].find('"').unwrap() + start;
580 let page = html.body[start..end]
581 .replace(""", "\"")
582 .replace("&", "&")
583 .replace("<", "<")
584 .replace(">", ">")
585 .replace("'", "'");
586 let html_page: serde_json::Value = serde_json::from_str(&page).unwrap();
587 let json_page: serde_json::Value = serde_json::from_str(&json.body).unwrap();
588 assert_eq!(html_page, json_page);
589 }
590
591 #[test]
592 fn dev_mode_emits_vite_client_script() {
593 let resp = Inertia::render_with_config(
594 &MockReq {
595 is_inertia: false,
596 path: "/",
597 },
598 "App",
599 serde_json::json!({}),
600 InertiaConfig::new()
601 .development()
602 .vite_dev_server("http://localhost:5173"),
603 );
604 assert!(resp.body.contains("http://localhost:5173/@vite/client"));
605 }
606
607 #[test]
608 fn title_override() {
609 let resp = Inertia::render_with_config(
610 &MockReq {
611 is_inertia: false,
612 path: "/",
613 },
614 "App",
615 serde_json::json!({}),
616 InertiaConfig::new()
617 .development()
618 .app_name("Fallback")
619 .title("Explicit"),
620 );
621 assert!(resp.body.contains("<title>Explicit</title>"));
622 assert!(!resp.body.contains("<title>Fallback</title>"));
623 }
624
625 #[test]
626 fn head_extras_in_html() {
627 let resp = Inertia::render_with_config(
628 &MockReq {
629 is_inertia: false,
630 path: "/",
631 },
632 "App",
633 serde_json::json!({}),
634 InertiaConfig::new()
635 .development()
636 .head_extras(r#"<meta name="x" content="y">"#),
637 );
638 assert!(resp.body.contains(r#"<meta name="x" content="y">"#));
639 }
640
641 #[test]
642 fn mount_id_applied() {
643 let resp = Inertia::render_with_config(
644 &MockReq {
645 is_inertia: false,
646 path: "/",
647 },
648 "App",
649 serde_json::json!({}),
650 InertiaConfig::new().development().mount_id("root"),
651 );
652 assert!(resp.body.contains(r#"id="root" data-page="#));
653 }
654
655 #[test]
659 fn prod_mode_does_not_leak_dev_server() {
660 let resp = Inertia::render_with_config(
661 &MockReq {
662 is_inertia: false,
663 path: "/",
664 },
665 "App",
666 serde_json::json!({}),
667 InertiaConfig::new()
668 .production()
669 .vite_dev_server("http://localhost:5173"),
670 );
671 assert!(!resp.body.contains("/@vite/client"));
672 assert!(!resp.body.contains("@react-refresh"));
673 }
674}