Skip to main content

karbon_framework/inertia/
inertia_response.rs

1use axum::http::{header, HeaderMap, StatusCode};
2use axum::response::{Html, IntoResponse, Response};
3use serde::Serialize;
4
5/// Header sent by the Inertia.js client adapter on subsequent visits
6pub const INERTIA_HEADER: &str = "x-inertia";
7
8/// Header for asset versioning — triggers full reload when version changes
9pub const INERTIA_VERSION_HEADER: &str = "x-inertia-version";
10
11/// The page object returned to the Inertia client
12#[derive(Debug, Clone, Serialize)]
13pub struct InertiaPage {
14    pub component: String,
15    pub props: serde_json::Value,
16    pub url: String,
17    pub version: String,
18}
19
20/// Configuration for the Inertia adapter
21#[derive(Clone)]
22pub struct InertiaConfig {
23    /// HTML template with a `{{INERTIA_PAGE}}` placeholder for the page data
24    /// and `{{INERTIA_HEAD}}` for optional head tags
25    pub root_template: String,
26    /// Asset version string — change this to force a full page reload
27    pub version: String,
28}
29
30impl InertiaConfig {
31    pub fn new(root_template: &str) -> Self {
32        Self {
33            root_template: root_template.to_string(),
34            version: String::new(),
35        }
36    }
37
38    pub fn version(mut self, version: &str) -> Self {
39        self.version = version.to_string();
40        self
41    }
42}
43
44impl Default for InertiaConfig {
45    fn default() -> Self {
46        Self::new(DEFAULT_TEMPLATE)
47    }
48}
49
50/// Default HTML shell that works with both SvelteKit and React Inertia adapters
51const DEFAULT_TEMPLATE: &str = r#"<!DOCTYPE html>
52<html lang="en">
53<head>
54    <meta charset="UTF-8">
55    <meta name="viewport" content="width=device-width, initial-scale=1.0">
56    {{INERTIA_HEAD}}
57</head>
58<body>
59    <div id="app" data-page='{{INERTIA_PAGE}}'></div>
60    {{INERTIA_SCRIPTS}}
61</body>
62</html>"#;
63
64/// Main Inertia helper for building responses from controllers.
65///
66/// ```ignore
67/// use framework::inertia::Inertia;
68///
69/// #[framework::get("/dashboard")]
70/// async fn dashboard(State(state): State<AppState>) -> impl IntoResponse {
71///     Inertia::render("Dashboard", serde_json::json!({
72///         "user": current_user,
73///         "stats": stats,
74///     }))
75/// }
76/// ```
77pub struct Inertia;
78
79impl Inertia {
80    /// Create an Inertia response with a component name and props.
81    ///
82    /// - If the request has an `X-Inertia` header → returns JSON (partial response)
83    /// - Otherwise → returns full HTML page with embedded page data
84    pub fn render<T: Serialize>(component: &str, props: T) -> InertiaResponse {
85        let props = serde_json::to_value(props).unwrap_or_default();
86        InertiaResponse {
87            component: component.to_string(),
88            props,
89        }
90    }
91
92    /// Create an Inertia redirect (303 See Other) — used after POST/PUT/DELETE
93    pub fn location(url: &str) -> Response {
94        (StatusCode::SEE_OTHER, [(header::LOCATION, url)]).into_response()
95    }
96}
97
98/// An Inertia response that adapts its format based on request headers.
99/// Convert to an actual response using `into_response_with(headers, url, config)`.
100pub struct InertiaResponse {
101    pub component: String,
102    pub props: serde_json::Value,
103}
104
105impl InertiaResponse {
106    /// Build the final HTTP response.
107    ///
108    /// Called by the Inertia middleware which has access to the request headers and URL.
109    pub fn into_response_with(
110        self,
111        request_headers: &HeaderMap,
112        url: &str,
113        config: &InertiaConfig,
114    ) -> Response {
115        let page = InertiaPage {
116            component: self.component,
117            props: self.props,
118            url: url.to_string(),
119            version: config.version.clone(),
120        };
121
122        let is_inertia = request_headers.contains_key(INERTIA_HEADER);
123
124        if is_inertia {
125            // Partial response — JSON only
126            let mut response = axum::Json(&page).into_response();
127            response
128                .headers_mut()
129                .insert(INERTIA_HEADER, "true".parse().unwrap());
130            response.headers_mut().insert(
131                "vary",
132                "X-Inertia".parse().unwrap(),
133            );
134            response
135        } else {
136            // Full page — render HTML with embedded page data
137            let page_json = serde_json::to_string(&page).unwrap_or_default();
138            // Full HTML attribute escaping (covers both single and double quote contexts)
139            let page_escaped = page_json
140                .replace('&', "&amp;")
141                .replace('"', "&quot;")
142                .replace('\'', "&#39;")
143                .replace('<', "&lt;")
144                .replace('>', "&gt;");
145
146            let html = config
147                .root_template
148                .replace("{{INERTIA_PAGE}}", &page_escaped)
149                .replace("{{INERTIA_HEAD}}", "")
150                .replace("{{INERTIA_SCRIPTS}}", "");
151
152            Html(html).into_response()
153        }
154    }
155}