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: Bytes,
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: Bytes::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        let s: String = body.into();
27        Self {
28            status: 200,
29            body: Bytes::from(s),
30            headers: vec![("Content-Type".to_string(), "text/plain".to_string())],
31        }
32    }
33
34    /// Create a JSON response from a serde_json::Value
35    pub fn json(body: serde_json::Value) -> Self {
36        Self {
37            status: 200,
38            body: Bytes::from(body.to_string()),
39            headers: vec![("Content-Type".to_string(), "application/json".to_string())],
40        }
41    }
42
43    /// Create a response with raw binary data.
44    ///
45    /// No default Content-Type is set; the caller must add one via `.header()`.
46    pub fn bytes(body: impl Into<Bytes>) -> Self {
47        Self {
48            status: 200,
49            body: body.into(),
50            headers: vec![],
51        }
52    }
53
54    /// Create a file download response with Content-Disposition header.
55    ///
56    /// Auto-detects Content-Type from the filename extension using `mime_guess`.
57    /// Falls back to `application/octet-stream` for unknown extensions.
58    /// The filename is sanitized against header injection (control characters
59    /// and quote marks are stripped).
60    pub fn download(body: impl Into<Bytes>, filename: &str) -> Self {
61        let safe_name: String = filename
62            .chars()
63            .filter(|c| !c.is_control() && *c != '"' && *c != '\\')
64            .collect();
65
66        let content_type = mime_guess::from_path(&safe_name)
67            .first()
68            .map(|m| m.to_string())
69            .unwrap_or_else(|| "application/octet-stream".to_string());
70
71        Self {
72            status: 200,
73            body: body.into(),
74            headers: vec![
75                ("Content-Type".to_string(), content_type),
76                (
77                    "Content-Disposition".to_string(),
78                    format!("attachment; filename=\"{safe_name}\""),
79                ),
80            ],
81        }
82    }
83
84    /// Set the response body
85    pub fn set_body(mut self, body: impl Into<String>) -> Self {
86        let s: String = body.into();
87        self.body = Bytes::from(s);
88        self
89    }
90
91    /// Set the HTTP status code
92    pub fn status(mut self, status: u16) -> Self {
93        self.status = status;
94        self
95    }
96
97    /// Get the current HTTP status code
98    pub fn status_code(&self) -> u16 {
99        self.status
100    }
101
102    /// Get the response body as a string slice.
103    ///
104    /// Returns an empty string for non-UTF-8 bodies (e.g. binary data).
105    /// Use `body_bytes()` to access raw binary data.
106    pub fn body(&self) -> &str {
107        std::str::from_utf8(&self.body).unwrap_or("")
108    }
109
110    /// Get the response body as raw bytes.
111    pub fn body_bytes(&self) -> &Bytes {
112        &self.body
113    }
114
115    /// Add a header to the response
116    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
117        self.headers.push((name.into(), value.into()));
118        self
119    }
120
121    /// Add a Set-Cookie header to the response
122    ///
123    /// # Example
124    ///
125    /// ```rust,ignore
126    /// use crate::{Cookie, HttpResponse};
127    ///
128    /// let response = HttpResponse::text("OK")
129    ///     .cookie(Cookie::new("session", "abc123"))
130    ///     .cookie(Cookie::new("user_id", "42"));
131    /// ```
132    pub fn cookie(self, cookie: Cookie) -> Self {
133        let header_value = cookie.to_header_value();
134        self.header("Set-Cookie", header_value)
135    }
136
137    /// Wrap this response in Ok() for use as Response type
138    pub fn ok(self) -> Response {
139        Ok(self)
140    }
141
142    /// Convert to hyper response
143    pub fn into_hyper(self) -> hyper::Response<Full<Bytes>> {
144        let mut builder = hyper::Response::builder().status(self.status);
145
146        for (name, value) in self.headers {
147            builder = builder.header(name, value);
148        }
149
150        builder.body(Full::new(self.body)).unwrap()
151    }
152}
153
154impl Default for HttpResponse {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160/// Extension trait for Response to enable method chaining on macros
161pub trait ResponseExt {
162    fn status(self, code: u16) -> Self;
163    fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self;
164}
165
166impl ResponseExt for Response {
167    fn status(self, code: u16) -> Self {
168        self.map(|r| r.status(code))
169    }
170
171    fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self {
172        self.map(|r| r.header(name, value))
173    }
174}
175
176/// HTTP Redirect response builder
177pub struct Redirect {
178    location: String,
179    query_params: Vec<(String, String)>,
180    status: u16,
181}
182
183impl Redirect {
184    /// Create a redirect to a specific URL/path
185    pub fn to(path: impl Into<String>) -> Self {
186        Self {
187            location: path.into(),
188            query_params: Vec::new(),
189            status: 302,
190        }
191    }
192
193    /// Create a redirect to a named route
194    pub fn route(name: &str) -> RedirectRouteBuilder {
195        RedirectRouteBuilder {
196            name: name.to_string(),
197            params: std::collections::HashMap::new(),
198            query_params: Vec::new(),
199            status: 302,
200        }
201    }
202
203    /// Add a query parameter
204    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
205        self.query_params.push((key.to_string(), value.into()));
206        self
207    }
208
209    /// Set status to 301 (Moved Permanently)
210    pub fn permanent(mut self) -> Self {
211        self.status = 301;
212        self
213    }
214
215    fn build_url(&self) -> String {
216        if self.query_params.is_empty() {
217            self.location.clone()
218        } else {
219            let query = self
220                .query_params
221                .iter()
222                .map(|(k, v)| format!("{k}={v}"))
223                .collect::<Vec<_>>()
224                .join("&");
225            format!("{}?{}", self.location, query)
226        }
227    }
228}
229
230/// Auto-convert Redirect to Response
231impl From<Redirect> for Response {
232    fn from(redirect: Redirect) -> Response {
233        Ok(HttpResponse::new()
234            .status(redirect.status)
235            .header("Location", redirect.build_url()))
236    }
237}
238
239/// Builder for redirects to named routes with parameters
240pub struct RedirectRouteBuilder {
241    name: String,
242    params: std::collections::HashMap<String, String>,
243    query_params: Vec<(String, String)>,
244    status: u16,
245}
246
247impl RedirectRouteBuilder {
248    /// Add a route parameter value
249    pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
250        self.params.insert(key.to_string(), value.into());
251        self
252    }
253
254    /// Add a query parameter
255    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
256        self.query_params.push((key.to_string(), value.into()));
257        self
258    }
259
260    /// Set status to 301 (Moved Permanently)
261    pub fn permanent(mut self) -> Self {
262        self.status = 301;
263        self
264    }
265
266    fn build_url(&self) -> Option<String> {
267        use crate::routing::route_with_params;
268
269        let mut url = route_with_params(&self.name, &self.params)?;
270        if !self.query_params.is_empty() {
271            let query = self
272                .query_params
273                .iter()
274                .map(|(k, v)| format!("{k}={v}"))
275                .collect::<Vec<_>>()
276                .join("&");
277            url = format!("{url}?{query}");
278        }
279        Some(url)
280    }
281}
282
283/// Auto-convert RedirectRouteBuilder to Response
284impl From<RedirectRouteBuilder> for Response {
285    fn from(redirect: RedirectRouteBuilder) -> Response {
286        let url = redirect.build_url().ok_or_else(|| {
287            HttpResponse::text(format!("Route '{}' not found", redirect.name)).status(500)
288        })?;
289        Ok(HttpResponse::new()
290            .status(redirect.status)
291            .header("Location", url))
292    }
293}
294
295/// Auto-convert FrameworkError to HttpResponse
296///
297/// This enables using the `?` operator in controller handlers to propagate
298/// framework errors as appropriate HTTP responses.
299///
300/// When a hint is available (via `FrameworkError::hint()`), the JSON response
301/// includes a `"hint"` field with actionable guidance for the developer.
302impl From<crate::error::FrameworkError> for HttpResponse {
303    fn from(err: crate::error::FrameworkError) -> HttpResponse {
304        let status = err.status_code();
305        let hint = err.hint();
306        let mut body = match &err {
307            crate::error::FrameworkError::ParamError { param_name } => {
308                serde_json::json!({
309                    "message": format!("Missing required parameter: {}", param_name)
310                })
311            }
312            crate::error::FrameworkError::ValidationError { field, message } => {
313                serde_json::json!({
314                    "message": "Validation failed",
315                    "field": field,
316                    "error": message
317                })
318            }
319            crate::error::FrameworkError::Validation(errors) => {
320                // Laravel/Inertia-compatible validation error format
321                errors.to_json()
322            }
323            crate::error::FrameworkError::Unauthorized => {
324                serde_json::json!({
325                    "message": "This action is unauthorized."
326                })
327            }
328            _ => {
329                serde_json::json!({
330                    "message": err.to_string()
331                })
332            }
333        };
334        if let Some(hint_text) = hint {
335            if let Some(obj) = body.as_object_mut() {
336                obj.insert("hint".to_string(), serde_json::Value::String(hint_text));
337            }
338        }
339        HttpResponse::json(body).status(status)
340    }
341}
342
343/// Auto-convert AppError to HttpResponse
344///
345/// This enables using the `?` operator in controller handlers with AppError.
346impl From<crate::error::AppError> for HttpResponse {
347    fn from(err: crate::error::AppError) -> HttpResponse {
348        // Convert AppError -> FrameworkError -> HttpResponse
349        let framework_err: crate::error::FrameworkError = err.into();
350        framework_err.into()
351    }
352}
353
354/// Inertia-aware HTTP Redirect response builder.
355///
356/// Unlike standard `Redirect`, this respects the Inertia protocol:
357/// - For Inertia XHR requests from POST/PUT/PATCH/DELETE, uses 303 status
358/// - Includes X-Inertia header in responses to Inertia requests
359/// - Falls back to standard 302 for non-Inertia requests
360///
361/// # Example
362///
363/// ```rust,ignore
364/// use ferro_rs::{InertiaRedirect, Request, Response};
365///
366/// pub async fn store(req: Request) -> Response {
367///     // ... create record ...
368///     InertiaRedirect::to(&req, "/items").into()
369/// }
370/// ```
371pub struct InertiaRedirect<'a> {
372    request: &'a crate::http::Request,
373    location: String,
374    query_params: Vec<(String, String)>,
375}
376
377impl<'a> InertiaRedirect<'a> {
378    /// Create a redirect that respects Inertia protocol.
379    pub fn to(request: &'a crate::http::Request, path: impl Into<String>) -> Self {
380        Self {
381            request,
382            location: path.into(),
383            query_params: Vec::new(),
384        }
385    }
386
387    /// Add a query parameter.
388    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
389        self.query_params.push((key.to_string(), value.into()));
390        self
391    }
392
393    fn build_url(&self) -> String {
394        if self.query_params.is_empty() {
395            self.location.clone()
396        } else {
397            let query = self
398                .query_params
399                .iter()
400                .map(|(k, v)| format!("{k}={v}"))
401                .collect::<Vec<_>>()
402                .join("&");
403            format!("{}?{}", self.location, query)
404        }
405    }
406
407    fn is_post_like_method(&self) -> bool {
408        matches!(
409            self.request.method().as_str(),
410            "POST" | "PUT" | "PATCH" | "DELETE"
411        )
412    }
413}
414
415impl From<InertiaRedirect<'_>> for Response {
416    fn from(redirect: InertiaRedirect<'_>) -> Response {
417        let url = redirect.build_url();
418        let is_inertia = redirect.request.is_inertia();
419        let is_post_like = redirect.is_post_like_method();
420
421        if is_inertia {
422            // Use 303 for POST-like methods to force GET on redirect
423            let status = if is_post_like { 303 } else { 302 };
424            Ok(HttpResponse::new()
425                .status(status)
426                .header("X-Inertia", "true")
427                .header("Location", url))
428        } else {
429            // Standard redirect for non-Inertia requests
430            Ok(HttpResponse::new().status(302).header("Location", url))
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_bytes_constructor() {
441        let resp = HttpResponse::bytes(vec![0xFF, 0xFE, 0x00]);
442        assert_eq!(resp.body_bytes().as_ref(), &[0xFF, 0xFE, 0x00]);
443        assert_eq!(resp.status_code(), 200);
444        assert!(
445            resp.headers.is_empty(),
446            "bytes() should set no default headers"
447        );
448    }
449
450    #[test]
451    fn test_bytes_from_vec_u8() {
452        let resp = HttpResponse::bytes(vec![1, 2, 3]);
453        assert_eq!(resp.body_bytes().len(), 3);
454    }
455
456    #[test]
457    fn test_bytes_with_content_type() {
458        let resp = HttpResponse::bytes(b"PNG data".to_vec()).header("Content-Type", "image/png");
459        let ct = resp
460            .headers
461            .iter()
462            .find(|(k, _)| k == "Content-Type")
463            .map(|(_, v)| v.as_str());
464        assert_eq!(ct, Some("image/png"));
465    }
466
467    #[test]
468    fn test_download_constructor() {
469        let resp = HttpResponse::download(b"pdf content".to_vec(), "report.pdf");
470        let ct = resp
471            .headers
472            .iter()
473            .find(|(k, _)| k == "Content-Type")
474            .map(|(_, v)| v.as_str());
475        assert_eq!(ct, Some("application/pdf"));
476
477        let cd = resp
478            .headers
479            .iter()
480            .find(|(k, _)| k == "Content-Disposition")
481            .map(|(_, v)| v.as_str());
482        assert_eq!(cd, Some("attachment; filename=\"report.pdf\""));
483    }
484
485    #[test]
486    fn test_download_unknown_extension() {
487        let resp = HttpResponse::download(b"data".to_vec(), "file.zzqx");
488        let ct = resp
489            .headers
490            .iter()
491            .find(|(k, _)| k == "Content-Type")
492            .map(|(_, v)| v.as_str());
493        assert_eq!(ct, Some("application/octet-stream"));
494    }
495
496    #[test]
497    fn test_download_filename_sanitization() {
498        let resp = HttpResponse::download(b"data".to_vec(), "evil\"file\nname.pdf");
499        let cd = resp
500            .headers
501            .iter()
502            .find(|(k, _)| k == "Content-Disposition")
503            .map(|(_, v)| v.as_str())
504            .unwrap();
505        assert!(
506            !cd.contains('"') || cd.matches('"').count() == 2,
507            "filename should be properly quoted"
508        );
509        assert!(!cd.contains('\n'), "filename should not contain newlines");
510    }
511
512    #[test]
513    fn test_text_still_works() {
514        let resp = HttpResponse::text("hello");
515        assert_eq!(resp.body(), "hello");
516        assert_eq!(resp.body_bytes().as_ref(), b"hello");
517    }
518
519    #[test]
520    fn test_json_still_works() {
521        let resp = HttpResponse::json(serde_json::json!({"ok": true}));
522        let body = resp.body();
523        assert!(!body.is_empty(), "json body should not be empty");
524        let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
525        assert_eq!(parsed["ok"], true);
526        assert!(!resp.body_bytes().is_empty());
527    }
528
529    #[test]
530    fn test_body_returns_empty_for_binary() {
531        let resp = HttpResponse::bytes(vec![0xFF, 0xFE]);
532        assert_eq!(resp.body(), "");
533    }
534
535    #[test]
536    fn test_into_hyper_preserves_binary() {
537        use http_body_util::BodyExt;
538
539        let data = vec![0xFF, 0x00, 0xFE];
540        let resp = HttpResponse::bytes(data.clone());
541        let hyper_resp = resp.into_hyper();
542
543        let rt = tokio::runtime::Runtime::new().unwrap();
544        let collected =
545            rt.block_on(async { hyper_resp.into_body().collect().await.unwrap().to_bytes() });
546        assert_eq!(collected.as_ref(), &data);
547    }
548}