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/// Framework-agnostic HTTP response.
10///
11/// Convert this to your framework's response type.
12#[derive(Debug, Clone)]
13pub struct InertiaHttpResponse {
14    /// HTTP status code
15    pub status: u16,
16    /// Response headers as (name, value) pairs
17    pub headers: Vec<(String, String)>,
18    /// Response body
19    pub body: String,
20    /// Content type
21    pub content_type: &'static str,
22}
23
24impl InertiaHttpResponse {
25    /// Create a JSON response with Inertia headers.
26    pub fn json(body: impl Into<String>) -> Self {
27        Self {
28            status: 200,
29            headers: vec![
30                ("X-Inertia".to_string(), "true".to_string()),
31                ("Vary".to_string(), "X-Inertia".to_string()),
32            ],
33            body: body.into(),
34            content_type: "application/json",
35        }
36    }
37
38    /// Create a raw JSON response without Inertia headers.
39    ///
40    /// Used for JSON fallback when a non-Inertia client requests JSON.
41    pub fn raw_json(body: impl Into<String>) -> Self {
42        Self {
43            status: 200,
44            headers: vec![],
45            body: body.into(),
46            content_type: "application/json",
47        }
48    }
49
50    /// Create an HTML response.
51    pub fn html(body: impl Into<String>) -> Self {
52        Self {
53            status: 200,
54            headers: vec![("Vary".to_string(), "X-Inertia".to_string())],
55            body: body.into(),
56            content_type: "text/html; charset=utf-8",
57        }
58    }
59
60    /// Create a 409 Conflict response for version mismatch.
61    pub fn conflict(location: impl Into<String>) -> Self {
62        Self {
63            status: 409,
64            headers: vec![("X-Inertia-Location".to_string(), location.into())],
65            body: String::new(),
66            content_type: "text/plain",
67        }
68    }
69
70    /// Set the HTTP status code.
71    pub fn status(mut self, status: u16) -> Self {
72        self.status = status;
73        self
74    }
75
76    /// Add a header to the response.
77    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
78        self.headers.push((name.into(), value.into()));
79        self
80    }
81
82    /// Create a redirect response for Inertia requests.
83    ///
84    /// For POST/PUT/PATCH/DELETE requests, uses status 303 (See Other) to force
85    /// the browser to follow the redirect with a GET request.
86    ///
87    /// For GET requests, uses standard 302.
88    pub fn redirect(location: impl Into<String>, is_post_like: bool) -> Self {
89        // POST/PUT/PATCH/DELETE -> 303 (See Other) forces GET on redirect
90        // GET -> 302 (Found) standard redirect
91        let status = if is_post_like { 303 } else { 302 };
92
93        Self {
94            status,
95            headers: vec![
96                ("X-Inertia".to_string(), "true".to_string()),
97                ("Location".to_string(), location.into()),
98            ],
99            body: String::new(),
100            content_type: "text/plain",
101        }
102    }
103}
104
105/// Main Inertia integration struct.
106///
107/// Provides methods for rendering Inertia responses in a framework-agnostic way.
108pub struct Inertia;
109
110impl Inertia {
111    /// Render an Inertia response.
112    ///
113    /// This is the primary method for returning Inertia responses from handlers.
114    /// It automatically:
115    /// - Detects XHR vs initial page load
116    /// - Filters props for partial reloads
117    ///
118    /// # Example
119    ///
120    /// ```rust,ignore
121    /// use ferro_inertia::Inertia;
122    /// use serde_json::json;
123    ///
124    /// let response = Inertia::render(&req, "Home", json!({
125    ///     "title": "Welcome",
126    ///     "user": { "name": "John" }
127    /// }));
128    /// ```
129    pub fn render<R, P>(req: &R, component: &str, props: P) -> InertiaHttpResponse
130    where
131        R: InertiaRequest,
132        P: Serialize,
133    {
134        Self::render_internal(req, component, props, None, InertiaConfig::default(), false)
135    }
136
137    /// Render an Inertia response with JSON fallback for API clients.
138    ///
139    /// When enabled, requests with `Accept: application/json` header (but without
140    /// `X-Inertia: true`) will receive raw props as JSON instead of HTML.
141    ///
142    /// This is useful for:
143    /// - API testing with curl or Postman
144    /// - Hybrid apps that sometimes need raw JSON
145    /// - Debug tooling
146    ///
147    /// # Example
148    ///
149    /// ```rust,ignore
150    /// use ferro_inertia::Inertia;
151    /// use serde_json::json;
152    ///
153    /// // curl -H "Accept: application/json" http://localhost:3000/posts/1
154    /// // Returns raw JSON props instead of HTML
155    /// let response = Inertia::render_with_json_fallback(&req, "Posts/Show", json!({
156    ///     "post": { "id": 1, "title": "Hello" }
157    /// }));
158    /// ```
159    pub fn render_with_json_fallback<R, P>(
160        req: &R,
161        component: &str,
162        props: P,
163    ) -> InertiaHttpResponse
164    where
165        R: InertiaRequest,
166        P: Serialize,
167    {
168        Self::render_internal(req, component, props, None, InertiaConfig::default(), true)
169    }
170
171    /// Render an Inertia response with shared props.
172    pub fn render_with_shared<R, P>(
173        req: &R,
174        component: &str,
175        props: P,
176        shared: &InertiaShared,
177    ) -> InertiaHttpResponse
178    where
179        R: InertiaRequest,
180        P: Serialize,
181    {
182        Self::render_internal(
183            req,
184            component,
185            props,
186            Some(shared),
187            InertiaConfig::default(),
188            false,
189        )
190    }
191
192    /// Render an Inertia response with custom configuration.
193    pub fn render_with_config<R, P>(
194        req: &R,
195        component: &str,
196        props: P,
197        config: InertiaConfig,
198    ) -> InertiaHttpResponse
199    where
200        R: InertiaRequest,
201        P: Serialize,
202    {
203        Self::render_internal(req, component, props, None, config, false)
204    }
205
206    /// Render an Inertia response with all options.
207    pub fn render_with_options<R, P>(
208        req: &R,
209        component: &str,
210        props: P,
211        shared: Option<&InertiaShared>,
212        config: InertiaConfig,
213    ) -> InertiaHttpResponse
214    where
215        R: InertiaRequest,
216        P: Serialize,
217    {
218        Self::render_internal(req, component, props, shared, config, false)
219    }
220
221    /// Render an Inertia response with all options and JSON fallback.
222    pub fn render_with_options_and_json_fallback<R, P>(
223        req: &R,
224        component: &str,
225        props: P,
226        shared: Option<&InertiaShared>,
227        config: InertiaConfig,
228    ) -> InertiaHttpResponse
229    where
230        R: InertiaRequest,
231        P: Serialize,
232    {
233        Self::render_internal(req, component, props, shared, config, true)
234    }
235
236    /// Internal render method with all options.
237    fn render_internal<R, P>(
238        req: &R,
239        component: &str,
240        props: P,
241        shared: Option<&InertiaShared>,
242        config: InertiaConfig,
243        json_fallback: bool,
244    ) -> InertiaHttpResponse
245    where
246        R: InertiaRequest,
247        P: Serialize,
248    {
249        let url = req.path().to_string();
250        let is_inertia = req.is_inertia();
251        let partial_data = req.inertia_partial_data();
252        let partial_component = req.inertia_partial_component();
253
254        // Serialize props
255        let mut props_value = match serde_json::to_value(&props) {
256            Ok(v) => v,
257            Err(e) => {
258                return InertiaHttpResponse::html(format!("Failed to serialize props: {e}"))
259                    .status(500);
260            }
261        };
262
263        // Merge shared props
264        if let Some(shared) = shared {
265            shared.merge_into(&mut props_value);
266        }
267
268        // Filter props for partial reloads
269        if is_inertia {
270            if let Some(partial_keys) = partial_data {
271                let should_filter = partial_component.map(|pc| pc == component).unwrap_or(false);
272
273                if should_filter {
274                    props_value = Self::filter_partial_props(props_value, &partial_keys);
275                }
276            }
277        }
278
279        // Check for JSON fallback before normal Inertia handling
280        // If JSON fallback is enabled and request accepts JSON but is not an Inertia request,
281        // return raw props as JSON
282        if json_fallback && !is_inertia && req.accepts_json() {
283            return InertiaHttpResponse::raw_json(
284                serde_json::to_string(&props_value).unwrap_or_default(),
285            );
286        }
287
288        let response = InertiaResponse::new(component, props_value, url).with_config(config);
289
290        // Extract CSRF token from shared props for HTML response
291        let csrf = shared.and_then(|s| s.csrf.as_deref());
292
293        if is_inertia {
294            response.to_json_response()
295        } else {
296            response.to_html_response(csrf)
297        }
298    }
299
300    /// Check if a version conflict should trigger a full reload.
301    ///
302    /// Returns `Some(response)` with a 409 Conflict if versions don't match.
303    pub fn check_version<R: InertiaRequest>(
304        req: &R,
305        current_version: &str,
306        redirect_url: &str,
307    ) -> Option<InertiaHttpResponse> {
308        if !req.is_inertia() {
309            return None;
310        }
311
312        if let Some(client_version) = req.inertia_version() {
313            if client_version != current_version {
314                return Some(InertiaHttpResponse::conflict(redirect_url));
315            }
316        }
317
318        None
319    }
320
321    /// Filter props to only include those requested in partial reload.
322    fn filter_partial_props(props: serde_json::Value, partial_keys: &[&str]) -> serde_json::Value {
323        match props {
324            serde_json::Value::Object(map) => {
325                let filtered: serde_json::Map<String, serde_json::Value> = map
326                    .into_iter()
327                    .filter(|(k, _)| partial_keys.contains(&k.as_str()))
328                    .collect();
329                serde_json::Value::Object(filtered)
330            }
331            other => other,
332        }
333    }
334}
335
336/// Internal response builder.
337pub struct InertiaResponse {
338    component: String,
339    props: serde_json::Value,
340    url: String,
341    config: InertiaConfig,
342}
343
344impl InertiaResponse {
345    /// Create a new Inertia response.
346    pub fn new(component: impl Into<String>, props: serde_json::Value, url: String) -> Self {
347        Self {
348            component: component.into(),
349            props,
350            url,
351            config: InertiaConfig::default(),
352        }
353    }
354
355    /// Set the configuration.
356    pub fn with_config(mut self, config: InertiaConfig) -> Self {
357        self.config = config;
358        self
359    }
360
361    /// Build JSON response for XHR requests.
362    pub fn to_json_response(&self) -> InertiaHttpResponse {
363        let page = serde_json::json!({
364            "component": self.component,
365            "props": self.props,
366            "url": self.url,
367            "version": self.config.version,
368        });
369
370        InertiaHttpResponse::json(serde_json::to_string(&page).unwrap_or_default())
371    }
372
373    /// Build HTML response for initial page loads.
374    pub fn to_html_response(&self, csrf_token: Option<&str>) -> InertiaHttpResponse {
375        let page_data = serde_json::json!({
376            "component": self.component,
377            "props": self.props,
378            "url": self.url,
379            "version": self.config.version,
380        });
381
382        // Escape JSON for HTML attribute
383        let page_json = serde_json::to_string(&page_data)
384            .unwrap_or_default()
385            .replace('&', "&amp;")
386            .replace('<', "&lt;")
387            .replace('>', "&gt;")
388            .replace('"', "&quot;")
389            .replace('\'', "&#x27;");
390
391        let csrf = csrf_token.unwrap_or("");
392
393        // Use custom template if provided
394        if let Some(template) = &self.config.html_template {
395            let html = template
396                .replace("{page}", &page_json)
397                .replace("{csrf}", csrf);
398            return InertiaHttpResponse::html(html);
399        }
400
401        // Default template
402        let html = if self.config.development {
403            format!(
404                r#"<!DOCTYPE html>
405<html lang="en">
406<head>
407    <meta charset="UTF-8">
408    <meta name="viewport" content="width=device-width, initial-scale=1.0">
409    <meta name="csrf-token" content="{}">
410    <title>{}</title>
411    <script type="module">
412        import RefreshRuntime from '{}/@react-refresh'
413        RefreshRuntime.injectIntoGlobalHook(window)
414        window.$RefreshReg$ = () => {{}}
415        window.$RefreshSig$ = () => (type) => type
416        window.__vite_plugin_react_preamble_installed__ = true
417    </script>
418    <script type="module" src="{}/@vite/client"></script>
419    <script type="module" src="{}/{}"></script>
420</head>
421<body>
422    <div id="app" data-page="{}"></div>
423</body>
424</html>"#,
425                csrf,
426                self.config.app_name,
427                self.config.vite_dev_server,
428                self.config.vite_dev_server,
429                self.config.vite_dev_server,
430                self.config.entry_point,
431                page_json
432            )
433        } else {
434            let assets = resolve_assets(&self.config.manifest_path, &self.config.entry_point);
435
436            let css_tags: String = assets
437                .css
438                .iter()
439                .map(|href| format!(r#"    <link rel="stylesheet" href="{href}">"#))
440                .collect::<Vec<_>>()
441                .join("\n");
442
443            format!(
444                r#"<!DOCTYPE html>
445<html lang="en">
446<head>
447    <meta charset="UTF-8">
448    <meta name="viewport" content="width=device-width, initial-scale=1.0">
449    <meta name="csrf-token" content="{csrf}">
450    <title>{app_name}</title>
451    <script type="module" src="{js_src}"></script>
452{css_tags}
453</head>
454<body>
455    <div id="app" data-page="{page_json}"></div>
456</body>
457</html>"#,
458                app_name = self.config.app_name,
459                js_src = assets.js,
460            )
461        };
462
463        InertiaHttpResponse::html(html)
464    }
465}