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