Skip to main content

ferro_rs/http/
response.rs

1use super::cookie::Cookie;
2use bytes::Bytes;
3use http_body_util::Full;
4
5/// HTTP Response builder providing Laravel-like response creation
6pub struct HttpResponse {
7    status: u16,
8    body: String,
9    headers: Vec<(String, String)>,
10}
11
12/// Response type alias - allows using `?` operator for early returns
13pub type Response = Result<HttpResponse, HttpResponse>;
14
15impl HttpResponse {
16    pub fn new() -> Self {
17        Self {
18            status: 200,
19            body: String::new(),
20            headers: Vec::new(),
21        }
22    }
23
24    /// Create a response with a string body
25    pub fn text(body: impl Into<String>) -> Self {
26        Self {
27            status: 200,
28            body: body.into(),
29            headers: vec![("Content-Type".to_string(), "text/plain".to_string())],
30        }
31    }
32
33    /// Create a JSON response from a serde_json::Value
34    pub fn json(body: serde_json::Value) -> Self {
35        Self {
36            status: 200,
37            body: body.to_string(),
38            headers: vec![("Content-Type".to_string(), "application/json".to_string())],
39        }
40    }
41
42    /// Set the HTTP status code
43    pub fn status(mut self, status: u16) -> Self {
44        self.status = status;
45        self
46    }
47
48    /// Get the current HTTP status code
49    pub fn status_code(&self) -> u16 {
50        self.status
51    }
52
53    /// Get the response body as a string slice.
54    pub fn body(&self) -> &str {
55        &self.body
56    }
57
58    /// Add a header to the response
59    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
60        self.headers.push((name.into(), value.into()));
61        self
62    }
63
64    /// Add a Set-Cookie header to the response
65    ///
66    /// # Example
67    ///
68    /// ```rust,ignore
69    /// use crate::{Cookie, HttpResponse};
70    ///
71    /// let response = HttpResponse::text("OK")
72    ///     .cookie(Cookie::new("session", "abc123"))
73    ///     .cookie(Cookie::new("user_id", "42"));
74    /// ```
75    pub fn cookie(self, cookie: Cookie) -> Self {
76        let header_value = cookie.to_header_value();
77        self.header("Set-Cookie", header_value)
78    }
79
80    /// Wrap this response in Ok() for use as Response type
81    pub fn ok(self) -> Response {
82        Ok(self)
83    }
84
85    /// Convert to hyper response
86    pub fn into_hyper(self) -> hyper::Response<Full<Bytes>> {
87        let mut builder = hyper::Response::builder().status(self.status);
88
89        for (name, value) in self.headers {
90            builder = builder.header(name, value);
91        }
92
93        builder.body(Full::new(Bytes::from(self.body))).unwrap()
94    }
95}
96
97impl Default for HttpResponse {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103/// Extension trait for Response to enable method chaining on macros
104pub trait ResponseExt {
105    fn status(self, code: u16) -> Self;
106    fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self;
107}
108
109impl ResponseExt for Response {
110    fn status(self, code: u16) -> Self {
111        self.map(|r| r.status(code))
112    }
113
114    fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self {
115        self.map(|r| r.header(name, value))
116    }
117}
118
119/// HTTP Redirect response builder
120pub struct Redirect {
121    location: String,
122    query_params: Vec<(String, String)>,
123    status: u16,
124}
125
126impl Redirect {
127    /// Create a redirect to a specific URL/path
128    pub fn to(path: impl Into<String>) -> Self {
129        Self {
130            location: path.into(),
131            query_params: Vec::new(),
132            status: 302,
133        }
134    }
135
136    /// Create a redirect to a named route
137    pub fn route(name: &str) -> RedirectRouteBuilder {
138        RedirectRouteBuilder {
139            name: name.to_string(),
140            params: std::collections::HashMap::new(),
141            query_params: Vec::new(),
142            status: 302,
143        }
144    }
145
146    /// Add a query parameter
147    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
148        self.query_params.push((key.to_string(), value.into()));
149        self
150    }
151
152    /// Set status to 301 (Moved Permanently)
153    pub fn permanent(mut self) -> Self {
154        self.status = 301;
155        self
156    }
157
158    fn build_url(&self) -> String {
159        if self.query_params.is_empty() {
160            self.location.clone()
161        } else {
162            let query = self
163                .query_params
164                .iter()
165                .map(|(k, v)| format!("{k}={v}"))
166                .collect::<Vec<_>>()
167                .join("&");
168            format!("{}?{}", self.location, query)
169        }
170    }
171}
172
173/// Auto-convert Redirect to Response
174impl From<Redirect> for Response {
175    fn from(redirect: Redirect) -> Response {
176        Ok(HttpResponse::new()
177            .status(redirect.status)
178            .header("Location", redirect.build_url()))
179    }
180}
181
182/// Builder for redirects to named routes with parameters
183pub struct RedirectRouteBuilder {
184    name: String,
185    params: std::collections::HashMap<String, String>,
186    query_params: Vec<(String, String)>,
187    status: u16,
188}
189
190impl RedirectRouteBuilder {
191    /// Add a route parameter value
192    pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
193        self.params.insert(key.to_string(), value.into());
194        self
195    }
196
197    /// Add a query parameter
198    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
199        self.query_params.push((key.to_string(), value.into()));
200        self
201    }
202
203    /// Set status to 301 (Moved Permanently)
204    pub fn permanent(mut self) -> Self {
205        self.status = 301;
206        self
207    }
208
209    fn build_url(&self) -> Option<String> {
210        use crate::routing::route_with_params;
211
212        let mut url = route_with_params(&self.name, &self.params)?;
213        if !self.query_params.is_empty() {
214            let query = self
215                .query_params
216                .iter()
217                .map(|(k, v)| format!("{k}={v}"))
218                .collect::<Vec<_>>()
219                .join("&");
220            url = format!("{url}?{query}");
221        }
222        Some(url)
223    }
224}
225
226/// Auto-convert RedirectRouteBuilder to Response
227impl From<RedirectRouteBuilder> for Response {
228    fn from(redirect: RedirectRouteBuilder) -> Response {
229        let url = redirect.build_url().ok_or_else(|| {
230            HttpResponse::text(format!("Route '{}' not found", redirect.name)).status(500)
231        })?;
232        Ok(HttpResponse::new()
233            .status(redirect.status)
234            .header("Location", url))
235    }
236}
237
238/// Auto-convert FrameworkError to HttpResponse
239///
240/// This enables using the `?` operator in controller handlers to propagate
241/// framework errors as appropriate HTTP responses.
242///
243/// When a hint is available (via `FrameworkError::hint()`), the JSON response
244/// includes a `"hint"` field with actionable guidance for the developer.
245impl From<crate::error::FrameworkError> for HttpResponse {
246    fn from(err: crate::error::FrameworkError) -> HttpResponse {
247        let status = err.status_code();
248        let hint = err.hint();
249        let mut body = match &err {
250            crate::error::FrameworkError::ParamError { param_name } => {
251                serde_json::json!({
252                    "message": format!("Missing required parameter: {}", param_name)
253                })
254            }
255            crate::error::FrameworkError::ValidationError { field, message } => {
256                serde_json::json!({
257                    "message": "Validation failed",
258                    "field": field,
259                    "error": message
260                })
261            }
262            crate::error::FrameworkError::Validation(errors) => {
263                // Laravel/Inertia-compatible validation error format
264                errors.to_json()
265            }
266            crate::error::FrameworkError::Unauthorized => {
267                serde_json::json!({
268                    "message": "This action is unauthorized."
269                })
270            }
271            _ => {
272                serde_json::json!({
273                    "message": err.to_string()
274                })
275            }
276        };
277        if let Some(hint_text) = hint {
278            if let Some(obj) = body.as_object_mut() {
279                obj.insert("hint".to_string(), serde_json::Value::String(hint_text));
280            }
281        }
282        HttpResponse::json(body).status(status)
283    }
284}
285
286/// Auto-convert AppError to HttpResponse
287///
288/// This enables using the `?` operator in controller handlers with AppError.
289impl From<crate::error::AppError> for HttpResponse {
290    fn from(err: crate::error::AppError) -> HttpResponse {
291        // Convert AppError -> FrameworkError -> HttpResponse
292        let framework_err: crate::error::FrameworkError = err.into();
293        framework_err.into()
294    }
295}
296
297/// Inertia-aware HTTP Redirect response builder.
298///
299/// Unlike standard `Redirect`, this respects the Inertia protocol:
300/// - For Inertia XHR requests from POST/PUT/PATCH/DELETE, uses 303 status
301/// - Includes X-Inertia header in responses to Inertia requests
302/// - Falls back to standard 302 for non-Inertia requests
303///
304/// # Example
305///
306/// ```rust,ignore
307/// use ferro_rs::{InertiaRedirect, Request, Response};
308///
309/// pub async fn store(req: Request) -> Response {
310///     // ... create record ...
311///     InertiaRedirect::to(&req, "/items").into()
312/// }
313/// ```
314pub struct InertiaRedirect<'a> {
315    request: &'a crate::http::Request,
316    location: String,
317    query_params: Vec<(String, String)>,
318}
319
320impl<'a> InertiaRedirect<'a> {
321    /// Create a redirect that respects Inertia protocol.
322    pub fn to(request: &'a crate::http::Request, path: impl Into<String>) -> Self {
323        Self {
324            request,
325            location: path.into(),
326            query_params: Vec::new(),
327        }
328    }
329
330    /// Add a query parameter.
331    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
332        self.query_params.push((key.to_string(), value.into()));
333        self
334    }
335
336    fn build_url(&self) -> String {
337        if self.query_params.is_empty() {
338            self.location.clone()
339        } else {
340            let query = self
341                .query_params
342                .iter()
343                .map(|(k, v)| format!("{k}={v}"))
344                .collect::<Vec<_>>()
345                .join("&");
346            format!("{}?{}", self.location, query)
347        }
348    }
349
350    fn is_post_like_method(&self) -> bool {
351        matches!(
352            self.request.method().as_str(),
353            "POST" | "PUT" | "PATCH" | "DELETE"
354        )
355    }
356}
357
358impl From<InertiaRedirect<'_>> for Response {
359    fn from(redirect: InertiaRedirect<'_>) -> Response {
360        let url = redirect.build_url();
361        let is_inertia = redirect.request.is_inertia();
362        let is_post_like = redirect.is_post_like_method();
363
364        if is_inertia {
365            // Use 303 for POST-like methods to force GET on redirect
366            let status = if is_post_like { 303 } else { 302 };
367            Ok(HttpResponse::new()
368                .status(status)
369                .header("X-Inertia", "true")
370                .header("Location", url))
371        } else {
372            // Standard redirect for non-Inertia requests
373            Ok(HttpResponse::new().status(302).header("Location", url))
374        }
375    }
376}