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}