Skip to main content

ferro_inertia/
response.rs

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