Skip to main content

ferro_inertia/
response.rs

1//! Inertia response generation.
2
3use crate::config::InertiaConfig;
4use crate::manifest::resolve_assets;
5use crate::request::InertiaRequest;
6use crate::shared::InertiaShared;
7use serde::Serialize;
8
9/// Escape a string for safe insertion into a double-quoted HTML attribute or element text.
10/// Covers the five characters that can break attribute/text context.
11fn escape_html(value: &str) -> String {
12    value
13        .replace('&', "&")
14        .replace('<', "&lt;")
15        .replace('>', "&gt;")
16        .replace('"', "&quot;")
17        .replace('\'', "&#x27;")
18}
19
20/// Framework-agnostic HTTP response.
21///
22/// Convert this to your framework's response type.
23#[derive(Debug, Clone)]
24pub struct InertiaHttpResponse {
25    /// HTTP status code
26    pub status: u16,
27    /// Response headers as (name, value) pairs
28    pub headers: Vec<(String, String)>,
29    /// Response body
30    pub body: String,
31    /// Content type
32    pub content_type: &'static str,
33}
34
35impl InertiaHttpResponse {
36    /// Create a JSON response with Inertia headers.
37    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    /// Create a raw JSON response without Inertia headers.
50    ///
51    /// Used for JSON fallback when a non-Inertia client requests JSON.
52    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    /// Create an HTML response.
62    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    /// Create a 409 Conflict response for version mismatch.
72    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    /// Set the HTTP status code.
82    pub fn status(mut self, status: u16) -> Self {
83        self.status = status;
84        self
85    }
86
87    /// Add a header to the response.
88    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    /// Create a redirect response for Inertia requests.
94    ///
95    /// For POST/PUT/PATCH/DELETE requests, uses status 303 (See Other) to force
96    /// the browser to follow the redirect with a GET request.
97    ///
98    /// For GET requests, uses standard 302.
99    pub fn redirect(location: impl Into<String>, is_post_like: bool) -> Self {
100        // POST/PUT/PATCH/DELETE -> 303 (See Other) forces GET on redirect
101        // GET -> 302 (Found) standard redirect
102        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
116/// Main Inertia integration struct.
117///
118/// Provides methods for rendering Inertia responses in a framework-agnostic way.
119pub struct Inertia;
120
121impl Inertia {
122    /// Render an Inertia response.
123    ///
124    /// This is the primary method for returning Inertia responses from handlers.
125    /// It automatically:
126    /// - Detects XHR vs initial page load
127    /// - Filters props for partial reloads
128    ///
129    /// # Configuration
130    ///
131    /// This uses [`InertiaConfig::default`] (env-driven). It does NOT read any
132    /// process-global app config — that wiring lives in the framework wrapper
133    /// (`ferro_rs::Inertia`, configured via `App::set_inertia_config`). Direct
134    /// embedders of this crate that need custom config should call
135    /// [`Inertia::render_with_config`].
136    ///
137    /// # Example
138    ///
139    /// ```rust,ignore
140    /// use ferro_inertia::Inertia;
141    /// use serde_json::json;
142    ///
143    /// let response = Inertia::render(&req, "Home", json!({
144    ///     "title": "Welcome",
145    ///     "user": { "name": "John" }
146    /// }));
147    /// ```
148    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    /// Render an Inertia response with JSON fallback for API clients.
157    ///
158    /// When enabled, requests with `Accept: application/json` header (but without
159    /// `X-Inertia: true`) will receive raw props as JSON instead of HTML.
160    ///
161    /// This is useful for:
162    /// - API testing with curl or Postman
163    /// - Hybrid apps that sometimes need raw JSON
164    /// - Debug tooling
165    ///
166    /// # Example
167    ///
168    /// ```rust,ignore
169    /// use ferro_inertia::Inertia;
170    /// use serde_json::json;
171    ///
172    /// // curl -H "Accept: application/json" http://localhost:3000/posts/1
173    /// // Returns raw JSON props instead of HTML
174    /// let response = Inertia::render_with_json_fallback(&req, "Posts/Show", json!({
175    ///     "post": { "id": 1, "title": "Hello" }
176    /// }));
177    /// ```
178    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    /// Render an Inertia response with shared props.
191    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    /// Render an Inertia response with custom configuration.
212    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    /// Render an Inertia response with all options.
226    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    /// Render an Inertia response with all options and JSON fallback.
241    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    /// Internal render method with all options.
256    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        // Serialize props
274        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        // Merge shared props
283        if let Some(shared) = shared {
284            shared.merge_into(&mut props_value);
285        }
286
287        // Filter props for partial reloads
288        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        // Check for JSON fallback before normal Inertia handling
299        // If JSON fallback is enabled and request accepts JSON but is not an Inertia request,
300        // return raw props as JSON
301        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        // Extract CSRF token from shared props for HTML response
310        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    /// Check if a version conflict should trigger a full reload.
320    ///
321    /// Returns `Some(response)` with a 409 Conflict if versions don't match.
322    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    /// Filter props to only include those requested in partial reload.
341    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
355/// Internal response builder.
356pub struct InertiaResponse {
357    component: String,
358    props: serde_json::Value,
359    url: String,
360    config: InertiaConfig,
361}
362
363impl InertiaResponse {
364    /// Create a new Inertia response.
365    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    /// Set the configuration.
375    pub fn with_config(mut self, config: InertiaConfig) -> Self {
376        self.config = config;
377        self
378    }
379
380    /// Build JSON response for XHR requests.
381    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    /// Build HTML response for initial page loads.
393    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        // Escape JSON for the double-quoted data-page HTML attribute.
402        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        // Use custom template if provided
407        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        // Derive shared template values from config fields (title/head_extras/mount_id).
415        // title and mount_id are escaped — they land in a <title> element and an id="" attribute.
416        // head_extras is intentionally NOT escaped: it is raw, developer-controlled HTML
417        // (trust boundary documented on InertiaConfig::head_extras — never populate from request data).
418        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        // Default template
428        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("&quot;", "\"")
582            .replace("&amp;", "&")
583            .replace("&lt;", "<")
584            .replace("&gt;", ">")
585            .replace("&#x27;", "'");
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    // SECURITY (T-238-03): prod build must never emit the @vite/client preamble.
656    // Uses the prod branch — asserts ABSENCE of dev tags (manifest-cache bleed
657    // is harmless for absence assertions).
658    #[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}