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    /// Set a response header, replacing any existing header with the same name.
118    ///
119    /// The name match is case-insensitive (ASCII). Use [`append_header`](Self::append_header)
120    /// for legitimately multi-value headers such as `Set-Cookie`, `Vary`, or `Link`.
121    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
122        let name = name.into();
123        self.headers.retain(|(n, _)| !n.eq_ignore_ascii_case(&name));
124        self.headers.push((name, value.into()));
125        self
126    }
127
128    /// Append a response header without removing any existing entry with the same name.
129    ///
130    /// Intended for headers that legitimately carry multiple values on separate lines,
131    /// such as `Set-Cookie` (RFC 6265 ยง4.1), `Vary`, and `Link`. For single-value
132    /// headers like `Content-Type` or `Location`, use [`header`](Self::header) instead.
133    pub fn append_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
134        self.headers.push((name.into(), value.into()));
135        self
136    }
137
138    /// Get the response headers as a borrowed slice.
139    ///
140    /// Returns all header entries in insertion order. Multi-value headers
141    /// (e.g. `Set-Cookie`) appear as multiple entries with the same name.
142    pub fn headers(&self) -> &[(String, String)] {
143        &self.headers
144    }
145
146    /// Add a Set-Cookie header to the response
147    ///
148    /// # Example
149    ///
150    /// ```rust,ignore
151    /// use crate::{Cookie, HttpResponse};
152    ///
153    /// let response = HttpResponse::text("OK")
154    ///     .cookie(Cookie::new("session", "abc123"))
155    ///     .cookie(Cookie::new("user_id", "42"));
156    /// ```
157    pub fn cookie(self, cookie: Cookie) -> Self {
158        let header_value = cookie.to_header_value();
159        self.append_header("Set-Cookie", header_value)
160    }
161
162    /// Wrap this response in Ok() for use as Response type
163    pub fn ok(self) -> Response {
164        Ok(self)
165    }
166
167    /// Convert to hyper response
168    pub fn into_hyper(self) -> hyper::Response<Full<Bytes>> {
169        let mut builder = hyper::Response::builder().status(self.status);
170
171        for (name, value) in self.headers {
172            builder = builder.header(name, value);
173        }
174
175        builder.body(Full::new(self.body)).unwrap()
176    }
177}
178
179impl Default for HttpResponse {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185/// Extension trait for Response to enable method chaining on macros
186pub trait ResponseExt {
187    /// Set the HTTP status code.
188    fn status(self, code: u16) -> Self;
189    /// Set a response header, replacing any existing header with the same name (case-insensitive).
190    fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self;
191}
192
193impl ResponseExt for Response {
194    fn status(self, code: u16) -> Self {
195        self.map(|r| r.status(code))
196    }
197
198    fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self {
199        self.map(|r| r.header(name, value))
200    }
201}
202
203/// HTTP Redirect response builder
204pub struct Redirect {
205    location: String,
206    query_params: Vec<(String, String)>,
207    status: u16,
208}
209
210impl Redirect {
211    /// Create a redirect to a specific URL/path
212    pub fn to(path: impl Into<String>) -> Self {
213        Self {
214            location: path.into(),
215            query_params: Vec::new(),
216            status: 302,
217        }
218    }
219
220    /// Create a redirect to a named route
221    pub fn route(name: &str) -> RedirectRouteBuilder {
222        RedirectRouteBuilder {
223            name: name.to_string(),
224            params: std::collections::HashMap::new(),
225            query_params: Vec::new(),
226            status: 302,
227        }
228    }
229
230    /// Add a query parameter
231    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
232        self.query_params.push((key.to_string(), value.into()));
233        self
234    }
235
236    /// Set status to 301 (Moved Permanently)
237    pub fn permanent(mut self) -> Self {
238        self.status = 301;
239        self
240    }
241
242    fn build_url(&self) -> String {
243        if self.query_params.is_empty() {
244            self.location.clone()
245        } else {
246            let query = self
247                .query_params
248                .iter()
249                .map(|(k, v)| format!("{k}={v}"))
250                .collect::<Vec<_>>()
251                .join("&");
252            format!("{}?{}", self.location, query)
253        }
254    }
255}
256
257/// Auto-convert Redirect to Response
258impl From<Redirect> for Response {
259    fn from(redirect: Redirect) -> Response {
260        Ok(HttpResponse::new()
261            .status(redirect.status)
262            .header("Location", redirect.build_url()))
263    }
264}
265
266/// Builder for redirects to named routes with parameters
267pub struct RedirectRouteBuilder {
268    name: String,
269    params: std::collections::HashMap<String, String>,
270    query_params: Vec<(String, String)>,
271    status: u16,
272}
273
274impl RedirectRouteBuilder {
275    /// Add a route parameter value
276    pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
277        self.params.insert(key.to_string(), value.into());
278        self
279    }
280
281    /// Add a query parameter
282    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
283        self.query_params.push((key.to_string(), value.into()));
284        self
285    }
286
287    /// Set status to 301 (Moved Permanently)
288    pub fn permanent(mut self) -> Self {
289        self.status = 301;
290        self
291    }
292
293    fn build_url(&self) -> Option<String> {
294        use crate::routing::route_with_params;
295
296        let mut url = route_with_params(&self.name, &self.params)?;
297        if !self.query_params.is_empty() {
298            let query = self
299                .query_params
300                .iter()
301                .map(|(k, v)| format!("{k}={v}"))
302                .collect::<Vec<_>>()
303                .join("&");
304            url = format!("{url}?{query}");
305        }
306        Some(url)
307    }
308}
309
310/// Auto-convert RedirectRouteBuilder to Response
311impl From<RedirectRouteBuilder> for Response {
312    fn from(redirect: RedirectRouteBuilder) -> Response {
313        let url = redirect.build_url().ok_or_else(|| {
314            HttpResponse::text(format!("Route '{}' not found", redirect.name)).status(500)
315        })?;
316        Ok(HttpResponse::new()
317            .status(redirect.status)
318            .header("Location", url))
319    }
320}
321
322/// Auto-convert FrameworkError to HttpResponse
323///
324/// This enables using the `?` operator in controller handlers to propagate
325/// framework errors as appropriate HTTP responses.
326///
327/// When a hint is available (via `FrameworkError::hint()`), the JSON response
328/// includes a `"hint"` field with actionable guidance for the developer.
329impl From<crate::error::FrameworkError> for HttpResponse {
330    fn from(err: crate::error::FrameworkError) -> HttpResponse {
331        let status = err.status_code();
332        let hint = err.hint();
333        let mut body = match &err {
334            crate::error::FrameworkError::ParamError { param_name } => {
335                serde_json::json!({
336                    "message": format!("Missing required parameter: {}", param_name)
337                })
338            }
339            crate::error::FrameworkError::ValidationError { field, message } => {
340                serde_json::json!({
341                    "message": "Validation failed",
342                    "field": field,
343                    "error": message
344                })
345            }
346            crate::error::FrameworkError::Validation(errors) => {
347                // Laravel/Inertia-compatible validation error format
348                errors.to_json()
349            }
350            crate::error::FrameworkError::Unauthorized => {
351                serde_json::json!({
352                    "message": "This action is unauthorized."
353                })
354            }
355            _ => {
356                serde_json::json!({
357                    "message": err.to_string()
358                })
359            }
360        };
361        if let Some(hint_text) = hint {
362            if let Some(obj) = body.as_object_mut() {
363                obj.insert("hint".to_string(), serde_json::Value::String(hint_text));
364            }
365        }
366        HttpResponse::json(body).status(status)
367    }
368}
369
370/// Auto-convert AppError to HttpResponse
371///
372/// This enables using the `?` operator in controller handlers with AppError.
373impl From<crate::error::AppError> for HttpResponse {
374    fn from(err: crate::error::AppError) -> HttpResponse {
375        // Convert AppError -> FrameworkError -> HttpResponse
376        let framework_err: crate::error::FrameworkError = err.into();
377        framework_err.into()
378    }
379}
380
381/// Auto-convert ferro_projections::Error to HttpResponse
382///
383/// This enables using the `?` operator in controller handlers with projection errors.
384#[cfg(feature = "projections")]
385impl From<ferro_projections::Error> for HttpResponse {
386    fn from(err: ferro_projections::Error) -> HttpResponse {
387        let framework_err: crate::error::FrameworkError = err.into();
388        framework_err.into()
389    }
390}
391
392/// Inertia-aware HTTP Redirect response builder.
393///
394/// Unlike standard `Redirect`, this respects the Inertia protocol:
395/// - For Inertia XHR requests from POST/PUT/PATCH/DELETE, uses 303 status
396/// - Includes X-Inertia header in responses to Inertia requests
397/// - Falls back to standard 302 for non-Inertia requests
398///
399/// # Example
400///
401/// ```rust,ignore
402/// use ferro_rs::{InertiaRedirect, Request, Response};
403///
404/// pub async fn store(req: Request) -> Response {
405///     // ... create record ...
406///     InertiaRedirect::to(&req, "/items").into()
407/// }
408/// ```
409pub struct InertiaRedirect<'a> {
410    request: &'a crate::http::Request,
411    location: String,
412    query_params: Vec<(String, String)>,
413}
414
415impl<'a> InertiaRedirect<'a> {
416    /// Create a redirect that respects Inertia protocol.
417    pub fn to(request: &'a crate::http::Request, path: impl Into<String>) -> Self {
418        Self {
419            request,
420            location: path.into(),
421            query_params: Vec::new(),
422        }
423    }
424
425    /// Add a query parameter.
426    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
427        self.query_params.push((key.to_string(), value.into()));
428        self
429    }
430
431    fn build_url(&self) -> String {
432        if self.query_params.is_empty() {
433            self.location.clone()
434        } else {
435            let query = self
436                .query_params
437                .iter()
438                .map(|(k, v)| format!("{k}={v}"))
439                .collect::<Vec<_>>()
440                .join("&");
441            format!("{}?{}", self.location, query)
442        }
443    }
444
445    fn is_post_like_method(&self) -> bool {
446        matches!(
447            self.request.method().as_str(),
448            "POST" | "PUT" | "PATCH" | "DELETE"
449        )
450    }
451}
452
453impl From<InertiaRedirect<'_>> for Response {
454    fn from(redirect: InertiaRedirect<'_>) -> Response {
455        let url = redirect.build_url();
456        let is_inertia = redirect.request.is_inertia();
457        let is_post_like = redirect.is_post_like_method();
458
459        if is_inertia {
460            // Use 303 for POST-like methods to force GET on redirect
461            let status = if is_post_like { 303 } else { 302 };
462            Ok(HttpResponse::new()
463                .status(status)
464                .header("X-Inertia", "true")
465                .header("Location", url))
466        } else {
467            // Standard redirect for non-Inertia requests
468            Ok(HttpResponse::new().status(302).header("Location", url))
469        }
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_bytes_constructor() {
479        let resp = HttpResponse::bytes(vec![0xFF, 0xFE, 0x00]);
480        assert_eq!(resp.body_bytes().as_ref(), &[0xFF, 0xFE, 0x00]);
481        assert_eq!(resp.status_code(), 200);
482        assert!(
483            resp.headers.is_empty(),
484            "bytes() should set no default headers"
485        );
486    }
487
488    #[test]
489    fn test_bytes_from_vec_u8() {
490        let resp = HttpResponse::bytes(vec![1, 2, 3]);
491        assert_eq!(resp.body_bytes().len(), 3);
492    }
493
494    #[test]
495    fn test_bytes_with_content_type() {
496        let resp = HttpResponse::bytes(b"PNG data".to_vec()).header("Content-Type", "image/png");
497        let ct = resp
498            .headers
499            .iter()
500            .find(|(k, _)| k == "Content-Type")
501            .map(|(_, v)| v.as_str());
502        assert_eq!(ct, Some("image/png"));
503    }
504
505    #[test]
506    fn test_download_constructor() {
507        let resp = HttpResponse::download(b"pdf content".to_vec(), "report.pdf");
508        let ct = resp
509            .headers
510            .iter()
511            .find(|(k, _)| k == "Content-Type")
512            .map(|(_, v)| v.as_str());
513        assert_eq!(ct, Some("application/pdf"));
514
515        let cd = resp
516            .headers
517            .iter()
518            .find(|(k, _)| k == "Content-Disposition")
519            .map(|(_, v)| v.as_str());
520        assert_eq!(cd, Some("attachment; filename=\"report.pdf\""));
521    }
522
523    #[test]
524    fn test_download_unknown_extension() {
525        let resp = HttpResponse::download(b"data".to_vec(), "file.zzqx");
526        let ct = resp
527            .headers
528            .iter()
529            .find(|(k, _)| k == "Content-Type")
530            .map(|(_, v)| v.as_str());
531        assert_eq!(ct, Some("application/octet-stream"));
532    }
533
534    #[test]
535    fn test_download_filename_sanitization() {
536        let resp = HttpResponse::download(b"data".to_vec(), "evil\"file\nname.pdf");
537        let cd = resp
538            .headers
539            .iter()
540            .find(|(k, _)| k == "Content-Disposition")
541            .map(|(_, v)| v.as_str())
542            .unwrap();
543        assert!(
544            !cd.contains('"') || cd.matches('"').count() == 2,
545            "filename should be properly quoted"
546        );
547        assert!(!cd.contains('\n'), "filename should not contain newlines");
548    }
549
550    #[test]
551    fn test_text_still_works() {
552        let resp = HttpResponse::text("hello");
553        assert_eq!(resp.body(), "hello");
554        assert_eq!(resp.body_bytes().as_ref(), b"hello");
555    }
556
557    #[test]
558    fn test_json_still_works() {
559        let resp = HttpResponse::json(serde_json::json!({"ok": true}));
560        let body = resp.body();
561        assert!(!body.is_empty(), "json body should not be empty");
562        let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
563        assert_eq!(parsed["ok"], true);
564        assert!(!resp.body_bytes().is_empty());
565    }
566
567    #[test]
568    fn test_body_returns_empty_for_binary() {
569        let resp = HttpResponse::bytes(vec![0xFF, 0xFE]);
570        assert_eq!(resp.body(), "");
571    }
572
573    #[test]
574    fn test_into_hyper_preserves_binary() {
575        use http_body_util::BodyExt;
576
577        let data = vec![0xFF, 0x00, 0xFE];
578        let resp = HttpResponse::bytes(data.clone());
579        let hyper_resp = resp.into_hyper();
580
581        let rt = tokio::runtime::Runtime::new().unwrap();
582        let collected =
583            rt.block_on(async { hyper_resp.into_body().collect().await.unwrap().to_bytes() });
584        assert_eq!(collected.as_ref(), &data);
585    }
586
587    #[test]
588    fn test_header_replaces_existing() {
589        let resp = HttpResponse::text("x").header("Content-Type", "text/html");
590        let ct: Vec<_> = resp
591            .headers()
592            .iter()
593            .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
594            .collect();
595        assert_eq!(ct.len(), 1, "expected exactly one Content-Type entry");
596        assert_eq!(ct[0].1, "text/html");
597    }
598
599    #[test]
600    fn test_multi_cookie_preserved() {
601        let resp = HttpResponse::new()
602            .cookie(Cookie::new("a", "1"))
603            .cookie(Cookie::new("b", "2"));
604        let cookies: Vec<_> = resp
605            .headers()
606            .iter()
607            .filter(|(k, _)| k == "Set-Cookie")
608            .collect();
609        assert_eq!(
610            cookies.len(),
611            2,
612            "both Set-Cookie entries must be preserved"
613        );
614    }
615
616    #[test]
617    fn test_header_case_insensitive_replace() {
618        let resp = HttpResponse::new()
619            .append_header("content-type", "text/plain")
620            .header("Content-Type", "text/html");
621        let ct: Vec<_> = resp
622            .headers()
623            .iter()
624            .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
625            .collect();
626        assert_eq!(ct.len(), 1, "lowercase prior entry must be replaced");
627        assert_eq!(ct[0].1, "text/html");
628    }
629
630    #[test]
631    fn test_append_header_does_not_replace() {
632        let resp = HttpResponse::new()
633            .append_header("X-Tag", "a")
634            .append_header("X-Tag", "b");
635        let count = resp.headers().iter().filter(|(k, _)| k == "X-Tag").count();
636        assert_eq!(count, 2, "append_header must not strip existing entries");
637    }
638
639    #[test]
640    fn test_headers_accessor() {
641        let resp = HttpResponse::text("x");
642        assert!(
643            !resp.headers().is_empty(),
644            "headers() accessor should return the prepopulated Content-Type"
645        );
646    }
647}