1use crate::config::InertiaConfig;
4use crate::manifest::resolve_assets;
5use crate::request::InertiaRequest;
6use crate::shared::InertiaShared;
7use serde::Serialize;
8
9#[derive(Debug, Clone)]
13pub struct InertiaHttpResponse {
14 pub status: u16,
16 pub headers: Vec<(String, String)>,
18 pub body: String,
20 pub content_type: &'static str,
22}
23
24impl InertiaHttpResponse {
25 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 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 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 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 pub fn status(mut self, status: u16) -> Self {
72 self.status = status;
73 self
74 }
75
76 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 pub fn redirect(location: impl Into<String>, is_post_like: bool) -> Self {
89 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
105pub struct Inertia;
109
110impl Inertia {
111 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 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 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 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 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 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 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 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 if let Some(shared) = shared {
265 shared.merge_into(&mut props_value);
266 }
267
268 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 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 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 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 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
336pub struct InertiaResponse {
338 component: String,
339 props: serde_json::Value,
340 url: String,
341 config: InertiaConfig,
342}
343
344impl InertiaResponse {
345 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 pub fn with_config(mut self, config: InertiaConfig) -> Self {
357 self.config = config;
358 self
359 }
360
361 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 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 let page_json = serde_json::to_string(&page_data)
384 .unwrap_or_default()
385 .replace('&', "&")
386 .replace('<', "<")
387 .replace('>', ">")
388 .replace('"', """)
389 .replace('\'', "'");
390
391 let csrf = csrf_token.unwrap_or("");
392
393 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 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}