Skip to main content

ferro_rs/inertia/
context.rs

1//! Inertia.js integration - async-safe implementation.
2//!
3//! This module provides the main `Inertia` struct for rendering Inertia responses.
4//! It wraps the framework-agnostic `ferro-inertia` crate with Ferro-specific features.
5
6use crate::csrf::csrf_token;
7use crate::http::{HttpResponse, Request};
8use crate::Response;
9use ferro_inertia::{InertiaConfig, InertiaRequest as InertiaRequestTrait};
10use serde::Serialize;
11use std::collections::HashMap;
12
13// Re-export InertiaShared from ferro-inertia
14pub use ferro_inertia::InertiaShared;
15
16/// Implement the framework-agnostic InertiaRequest trait for Ferro's Request type.
17impl InertiaRequestTrait for Request {
18    fn inertia_header(&self, name: &str) -> Option<&str> {
19        self.header(name)
20    }
21
22    fn path(&self) -> &str {
23        Request::path(self)
24    }
25}
26
27/// Saved Inertia context for use after consuming the Request.
28///
29/// Use this when you need to call `req.input()` (which consumes the request)
30/// but still need to render Inertia error responses.
31///
32/// # Example
33///
34/// ```rust,ignore
35/// use ferro_rs::{Inertia, Request, Response, SavedInertiaContext};
36///
37/// pub async fn login(req: Request) -> Response {
38///     // Save Inertia context before consuming request
39///     let ctx = SavedInertiaContext::from(&req);
40///
41///     // This consumes the request
42///     let form: LoginForm = req.input().await?;
43///
44///     // Use saved context for error responses
45///     if let Err(errors) = form.validate() {
46///         return Inertia::render(&ctx, "auth/Login", LoginProps { errors });
47///     }
48///
49///     // ...
50/// }
51/// ```
52#[derive(Clone, Debug)]
53pub struct SavedInertiaContext {
54    path: String,
55    headers: HashMap<String, String>,
56}
57
58impl SavedInertiaContext {
59    /// Create a new SavedInertiaContext by capturing data from a Request.
60    pub fn new(req: &Request) -> Self {
61        let mut headers = HashMap::new();
62
63        // Capture Inertia-relevant headers
64        for name in &[
65            "X-Inertia",
66            "X-Inertia-Version",
67            "X-Inertia-Partial-Data",
68            "X-Inertia-Partial-Component",
69        ] {
70            if let Some(value) = req.header(name) {
71                headers.insert(name.to_string(), value.to_string());
72            }
73        }
74
75        Self {
76            path: req.path().to_string(),
77            headers,
78        }
79    }
80}
81
82impl From<&Request> for SavedInertiaContext {
83    fn from(req: &Request) -> Self {
84        Self::new(req)
85    }
86}
87
88impl InertiaRequestTrait for SavedInertiaContext {
89    fn inertia_header(&self, name: &str) -> Option<&str> {
90        self.headers.get(name).map(|s| s.as_str())
91    }
92
93    fn path(&self) -> &str {
94        &self.path
95    }
96}
97
98/// Main Inertia integration struct for Ferro framework.
99///
100/// Provides methods for rendering Inertia responses in an async-safe manner.
101/// All state is derived from the Request, not thread-local storage.
102pub struct Inertia;
103
104impl Inertia {
105    /// Render an Inertia response.
106    ///
107    /// This is the primary method for returning Inertia responses from controllers.
108    /// It automatically:
109    /// - Detects XHR vs initial page load
110    /// - Merges shared props from middleware
111    /// - Filters props for partial reloads
112    /// - Includes CSRF token in HTML responses
113    ///
114    /// # Example
115    ///
116    /// ```rust,ignore
117    /// use ferro_rs::{Inertia, Request, Response};
118    ///
119    /// pub async fn index(req: Request) -> Response {
120    ///     Inertia::render(&req, "Home", HomeProps {
121    ///         title: "Welcome".into(),
122    ///     })
123    /// }
124    /// ```
125    pub fn render<P: Serialize>(req: &Request, component: &str, props: P) -> Response {
126        Self::render_with_config(
127            req,
128            component,
129            props,
130            crate::inertia::global::get_inertia_config(),
131        )
132    }
133
134    /// Render an Inertia response with custom configuration.
135    pub fn render_with_config<P: Serialize>(
136        req: &Request,
137        component: &str,
138        props: P,
139        config: InertiaConfig,
140    ) -> Response {
141        // Get shared props from middleware (if set)
142        let shared = req.get::<InertiaShared>();
143
144        // Get CSRF token for HTML responses
145        let csrf = csrf_token().unwrap_or_default();
146
147        // Build shared props with CSRF included
148        let effective_shared = if let Some(existing) = shared {
149            // Clone and add CSRF if not already set
150            let mut shared_clone = existing.clone();
151            if shared_clone.csrf.is_none() {
152                shared_clone.csrf = Some(csrf.clone());
153            }
154            Some(shared_clone)
155        } else {
156            Some(InertiaShared::new().csrf(csrf.clone()))
157        };
158
159        // Use ferro-inertia for the core rendering logic
160        let http_response = ferro_inertia::Inertia::render_with_options(
161            req,
162            component,
163            props,
164            effective_shared.as_ref(),
165            config,
166        );
167
168        // Convert InertiaHttpResponse to Ferro's Response
169        Ok(Self::convert_response(http_response))
170    }
171
172    /// Render an Inertia response using a saved context.
173    ///
174    /// Use this when you've already consumed the Request (e.g., via `req.input()`)
175    /// but still need to render an Inertia response (typically for validation errors).
176    ///
177    /// # Example
178    ///
179    /// ```rust,ignore
180    /// use ferro_rs::{Inertia, Request, Response, SavedInertiaContext};
181    ///
182    /// pub async fn login(req: Request) -> Response {
183    ///     let ctx = SavedInertiaContext::from(&req);
184    ///     let form: LoginForm = req.input().await?;
185    ///
186    ///     if let Err(errors) = form.validate() {
187    ///         return Inertia::render_ctx(&ctx, "auth/Login", LoginProps { errors });
188    ///     }
189    ///     // ...
190    /// }
191    /// ```
192    pub fn render_ctx<P: Serialize>(
193        ctx: &SavedInertiaContext,
194        component: &str,
195        props: P,
196    ) -> Response {
197        let csrf = csrf_token().unwrap_or_default();
198        let shared = InertiaShared::new().csrf(csrf);
199
200        let http_response = ferro_inertia::Inertia::render_with_options(
201            ctx,
202            component,
203            props,
204            Some(&shared),
205            crate::inertia::global::get_inertia_config(),
206        );
207
208        Ok(Self::convert_response(http_response))
209    }
210
211    /// Convert an InertiaHttpResponse to Ferro's HttpResponse.
212    fn convert_response(inertia_response: ferro_inertia::InertiaHttpResponse) -> HttpResponse {
213        let mut response = HttpResponse::new()
214            .header("Content-Type", inertia_response.content_type)
215            .set_body(inertia_response.body)
216            .status(inertia_response.status);
217
218        for (name, value) in inertia_response.headers {
219            response = response.header(name, value);
220        }
221
222        response
223    }
224
225    /// Fail-fast production asset check for Inertia apps.
226    ///
227    /// Call this from `bootstrap` (after config is loaded) so a missing or
228    /// mismatched frontend build errors clearly at startup instead of serving a
229    /// blank page. No-op in development. Uses the process-global `InertiaConfig`
230    /// (`from_env()` by default; override with `set_inertia_config`).
231    ///
232    /// ```rust,ignore
233    /// if let Err(e) = Inertia::preflight() {
234    ///     eprintln!("Inertia assets not ready: {e}");
235    ///     std::process::exit(1);
236    /// }
237    /// ```
238    pub fn preflight() -> Result<(), String> {
239        crate::inertia::global::get_inertia_config().verify_production_assets()
240    }
241
242    /// Check if the current request is an Inertia XHR request.
243    pub fn is_inertia_request(req: &Request) -> bool {
244        req.is_inertia()
245    }
246
247    /// Get the current URL from the request.
248    pub fn current_url(req: &Request) -> String {
249        req.path().to_string()
250    }
251
252    /// Check for version mismatch and return 409 Conflict if needed.
253    ///
254    /// Call this in middleware to handle asset version changes.
255    pub fn check_version(
256        req: &Request,
257        current_version: &str,
258        redirect_url: &str,
259    ) -> Option<Response> {
260        ferro_inertia::Inertia::check_version(req, current_version, redirect_url)
261            .map(|http_response| Ok(Self::convert_response(http_response)))
262    }
263
264    /// Create an Inertia-aware redirect.
265    ///
266    /// This properly handles the Inertia protocol:
267    /// - For POST/PUT/PATCH/DELETE requests, uses 303 status to force GET
268    /// - Includes X-Inertia header for Inertia XHR requests
269    /// - Falls back to standard 302 for non-Inertia requests
270    ///
271    /// # Example
272    ///
273    /// ```rust,ignore
274    /// use ferro_rs::{Inertia, Request, Response};
275    ///
276    /// pub async fn login(req: Request) -> Response {
277    ///     // ... validation and auth logic ...
278    ///     Inertia::redirect(&req, "/dashboard")
279    /// }
280    /// ```
281    pub fn redirect(req: &Request, path: impl Into<String>) -> Response {
282        let url = path.into();
283        let is_inertia = req.is_inertia();
284        let is_post_like = matches!(req.method().as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
285
286        if is_inertia {
287            // 303 See Other forces browser to GET the redirect location
288            let status = if is_post_like { 303 } else { 302 };
289            Ok(HttpResponse::new()
290                .status(status)
291                .header("X-Inertia", "true")
292                .header("Location", url))
293        } else {
294            // Standard redirect for non-Inertia requests
295            Ok(HttpResponse::new().status(302).header("Location", url))
296        }
297    }
298
299    /// Create an Inertia-aware redirect using saved context.
300    ///
301    /// Use when you've consumed the Request but need to redirect.
302    ///
303    /// # Example
304    ///
305    /// ```rust,ignore
306    /// use ferro_rs::{Inertia, Request, Response, SavedInertiaContext};
307    ///
308    /// pub async fn store(req: Request) -> Response {
309    ///     let ctx = SavedInertiaContext::from(&req);
310    ///     let form: CreateForm = req.input().await?;
311    ///
312    ///     // ... create record ...
313    ///
314    ///     Inertia::redirect_ctx(&ctx, "/items")
315    /// }
316    /// ```
317    pub fn redirect_ctx(ctx: &SavedInertiaContext, path: impl Into<String>) -> Response {
318        let url = path.into();
319        let is_inertia = ctx.headers.contains_key("X-Inertia");
320
321        // When using saved context, we assume POST-like (form submissions)
322        // because that's the common case for needing SavedInertiaContext
323        if is_inertia {
324            Ok(HttpResponse::new()
325                .status(303)
326                .header("X-Inertia", "true")
327                .header("Location", url))
328        } else {
329            Ok(HttpResponse::new().status(302).header("Location", url))
330        }
331    }
332}
333
334// Keep deprecated InertiaContext for backward compatibility during migration
335#[deprecated(
336    since = "0.2.0",
337    note = "Use Inertia::render() instead - thread-local storage is async-unsafe"
338)]
339/// Deprecated thread-local Inertia context (use `Inertia::render()` instead).
340pub struct InertiaContext;
341
342#[allow(deprecated)]
343impl InertiaContext {
344    /// No-op — kept for compilation compatibility.
345    #[deprecated(note = "Use Inertia::render() instead")]
346    pub fn set(_ctx: InertiaContextData) {
347        // No-op - kept for compilation compatibility during migration
348    }
349
350    /// Always returns false — kept for compilation compatibility.
351    #[deprecated(note = "Use Inertia::is_inertia_request(&req) instead")]
352    pub fn is_inertia_request() -> bool {
353        false
354    }
355
356    /// Returns empty string — kept for compilation compatibility.
357    #[deprecated(note = "Use req.path() instead")]
358    pub fn current_path() -> String {
359        String::new()
360    }
361
362    /// No-op — kept for compilation compatibility.
363    #[deprecated(note = "No longer needed")]
364    pub fn clear() {
365        // No-op
366    }
367
368    /// Always returns None — kept for compilation compatibility.
369    #[deprecated(note = "Use req methods instead")]
370    pub fn get() -> Option<InertiaContextData> {
371        None
372    }
373}
374
375/// Legacy context data - kept for migration compatibility.
376#[deprecated(since = "0.2.0", note = "Use Request methods instead")]
377#[derive(Clone, Default)]
378pub struct InertiaContextData {
379    /// Request path.
380    pub path: String,
381    /// Whether the request is an Inertia request.
382    pub is_inertia: bool,
383    /// Asset version for cache busting.
384    pub version: Option<String>,
385}