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 that returns the user to the page that triggered
221    /// the current request, derived from the `Referer` header.
222    ///
223    /// Preserves query string and fragment from the source page so tab
224    /// selection (`?tab=note`), pagination cursors, and scroll-restoration
225    /// keys (`scroll_preserve.rs`) survive form POSTs.
226    ///
227    /// Falls back to `fallback` when the Referer is absent or points
228    /// off-origin. Same-origin is enforced by requiring the Referer's host
229    /// to match the request's `Host` header (or the Referer to be already
230    /// a relative path) — protects against open-redirect via spoofed Referer.
231    pub fn back(req: &crate::http::Request, fallback: impl Into<String>) -> Self {
232        let location = same_origin_path_from_referer(req).unwrap_or_else(|| fallback.into());
233        Self {
234            location,
235            query_params: Vec::new(),
236            status: 302,
237        }
238    }
239
240    /// Create a redirect to a named route
241    pub fn route(name: &str) -> RedirectRouteBuilder {
242        RedirectRouteBuilder {
243            name: name.to_string(),
244            params: std::collections::HashMap::new(),
245            query_params: Vec::new(),
246            status: 302,
247        }
248    }
249
250    /// Add a query parameter
251    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
252        self.query_params.push((key.to_string(), value.into()));
253        self
254    }
255
256    /// Set status to 301 (Moved Permanently)
257    pub fn permanent(mut self) -> Self {
258        self.status = 301;
259        self
260    }
261
262    fn build_url(&self) -> String {
263        if self.query_params.is_empty() {
264            self.location.clone()
265        } else {
266            let query = self
267                .query_params
268                .iter()
269                .map(|(k, v)| format!("{k}={v}"))
270                .collect::<Vec<_>>()
271                .join("&");
272            format!("{}?{}", self.location, query)
273        }
274    }
275}
276
277/// Auto-convert Redirect to Response
278impl From<Redirect> for Response {
279    fn from(redirect: Redirect) -> Response {
280        Ok(HttpResponse::new()
281            .status(redirect.status)
282            .header("Location", redirect.build_url()))
283    }
284}
285
286/// Extracts a same-origin `/path?query#fragment` string from the request's
287/// `Referer` header, returning `None` when the header is absent, malformed,
288/// or points off-origin.
289///
290/// Same-origin rule: when the Referer is an absolute URL (`scheme://host/...`)
291/// the host must equal the request's `Host` header. When the Referer is
292/// already a relative path it is accepted as-is. Scheme-relative URLs
293/// (`//evil.com/x`) are rejected.
294fn same_origin_path_from_referer(req: &crate::http::Request) -> Option<String> {
295    let referer = req.header("referer")?;
296    // Scheme-relative URLs (//host/...) — reject; they bypass scheme check.
297    if referer.starts_with("//") {
298        return None;
299    }
300    // Already-relative path — accept as-is.
301    if referer.starts_with('/') {
302        return Some(referer.to_string());
303    }
304    // Absolute URL — strip `scheme://host` prefix and verify host matches.
305    let rest = referer
306        .strip_prefix("http://")
307        .or_else(|| referer.strip_prefix("https://"))?;
308    let (referer_host, path) = match rest.find('/') {
309        Some(i) => (&rest[..i], &rest[i..]),
310        None => (rest, "/"),
311    };
312    let request_host = req.header("host")?;
313    if referer_host == request_host {
314        Some(path.to_string())
315    } else {
316        None
317    }
318}
319
320/// Builder for redirects to named routes with parameters
321pub struct RedirectRouteBuilder {
322    name: String,
323    params: std::collections::HashMap<String, String>,
324    query_params: Vec<(String, String)>,
325    status: u16,
326}
327
328impl RedirectRouteBuilder {
329    /// Add a route parameter value
330    pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
331        self.params.insert(key.to_string(), value.into());
332        self
333    }
334
335    /// Add a query parameter
336    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
337        self.query_params.push((key.to_string(), value.into()));
338        self
339    }
340
341    /// Set status to 301 (Moved Permanently)
342    pub fn permanent(mut self) -> Self {
343        self.status = 301;
344        self
345    }
346
347    fn build_url(&self) -> Option<String> {
348        use crate::routing::route_with_params;
349
350        let mut url = route_with_params(&self.name, &self.params)?;
351        if !self.query_params.is_empty() {
352            let query = self
353                .query_params
354                .iter()
355                .map(|(k, v)| format!("{k}={v}"))
356                .collect::<Vec<_>>()
357                .join("&");
358            url = format!("{url}?{query}");
359        }
360        Some(url)
361    }
362}
363
364/// Auto-convert RedirectRouteBuilder to Response
365impl From<RedirectRouteBuilder> for Response {
366    fn from(redirect: RedirectRouteBuilder) -> Response {
367        let url = redirect.build_url().ok_or_else(|| {
368            HttpResponse::text(format!("Route '{}' not found", redirect.name)).status(500)
369        })?;
370        Ok(HttpResponse::new()
371            .status(redirect.status)
372            .header("Location", url))
373    }
374}
375
376/// Auto-convert FrameworkError to HttpResponse
377///
378/// This enables using the `?` operator in controller handlers to propagate
379/// framework errors as appropriate HTTP responses.
380///
381/// When a hint is available (via `FrameworkError::hint()`), the JSON response
382/// includes a `"hint"` field with actionable guidance for the developer.
383impl From<crate::error::FrameworkError> for HttpResponse {
384    fn from(err: crate::error::FrameworkError) -> HttpResponse {
385        let status = err.status_code();
386        let hint = err.hint();
387        let mut body = match &err {
388            crate::error::FrameworkError::ParamError { param_name } => {
389                serde_json::json!({
390                    "message": format!("Missing required parameter: {}", param_name)
391                })
392            }
393            crate::error::FrameworkError::ValidationError { field, message } => {
394                serde_json::json!({
395                    "message": "Validation failed",
396                    "field": field,
397                    "error": message
398                })
399            }
400            crate::error::FrameworkError::Validation(errors) => {
401                // Laravel/Inertia-compatible validation error format
402                errors.to_json()
403            }
404            crate::error::FrameworkError::Unauthorized => {
405                serde_json::json!({
406                    "message": "This action is unauthorized."
407                })
408            }
409            _ => {
410                serde_json::json!({
411                    "message": err.to_string()
412                })
413            }
414        };
415        if let Some(hint_text) = hint {
416            if let Some(obj) = body.as_object_mut() {
417                obj.insert("hint".to_string(), serde_json::Value::String(hint_text));
418            }
419        }
420        HttpResponse::json(body).status(status)
421    }
422}
423
424/// Auto-convert AppError to HttpResponse
425///
426/// This enables using the `?` operator in controller handlers with AppError.
427impl From<crate::error::AppError> for HttpResponse {
428    fn from(err: crate::error::AppError) -> HttpResponse {
429        // Convert AppError -> FrameworkError -> HttpResponse
430        let framework_err: crate::error::FrameworkError = err.into();
431        framework_err.into()
432    }
433}
434
435/// Auto-convert ferro_projections::Error to HttpResponse
436///
437/// This enables using the `?` operator in controller handlers with projection errors.
438#[cfg(feature = "projections")]
439impl From<ferro_projections::Error> for HttpResponse {
440    fn from(err: ferro_projections::Error) -> HttpResponse {
441        let framework_err: crate::error::FrameworkError = err.into();
442        framework_err.into()
443    }
444}
445
446/// Inertia-aware HTTP Redirect response builder.
447///
448/// Unlike standard `Redirect`, this respects the Inertia protocol:
449/// - For Inertia XHR requests from POST/PUT/PATCH/DELETE, uses 303 status
450/// - Includes X-Inertia header in responses to Inertia requests
451/// - Falls back to standard 302 for non-Inertia requests
452///
453/// # Example
454///
455/// ```rust,ignore
456/// use ferro_rs::{InertiaRedirect, Request, Response};
457///
458/// pub async fn store(req: Request) -> Response {
459///     // ... create record ...
460///     InertiaRedirect::to(&req, "/items").into()
461/// }
462/// ```
463pub struct InertiaRedirect<'a> {
464    request: &'a crate::http::Request,
465    location: String,
466    query_params: Vec<(String, String)>,
467}
468
469impl<'a> InertiaRedirect<'a> {
470    /// Create a redirect that respects Inertia protocol.
471    pub fn to(request: &'a crate::http::Request, path: impl Into<String>) -> Self {
472        Self {
473            request,
474            location: path.into(),
475            query_params: Vec::new(),
476        }
477    }
478
479    /// Add a query parameter.
480    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
481        self.query_params.push((key.to_string(), value.into()));
482        self
483    }
484
485    fn build_url(&self) -> String {
486        if self.query_params.is_empty() {
487            self.location.clone()
488        } else {
489            let query = self
490                .query_params
491                .iter()
492                .map(|(k, v)| format!("{k}={v}"))
493                .collect::<Vec<_>>()
494                .join("&");
495            format!("{}?{}", self.location, query)
496        }
497    }
498
499    fn is_post_like_method(&self) -> bool {
500        matches!(
501            self.request.method().as_str(),
502            "POST" | "PUT" | "PATCH" | "DELETE"
503        )
504    }
505}
506
507impl From<InertiaRedirect<'_>> for Response {
508    fn from(redirect: InertiaRedirect<'_>) -> Response {
509        let url = redirect.build_url();
510        let is_inertia = redirect.request.is_inertia();
511        let is_post_like = redirect.is_post_like_method();
512
513        if is_inertia {
514            // Use 303 for POST-like methods to force GET on redirect
515            let status = if is_post_like { 303 } else { 302 };
516            Ok(HttpResponse::new()
517                .status(status)
518                .header("X-Inertia", "true")
519                .header("Location", url))
520        } else {
521            // Standard redirect for non-Inertia requests
522            Ok(HttpResponse::new().status(302).header("Location", url))
523        }
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn test_bytes_constructor() {
533        let resp = HttpResponse::bytes(vec![0xFF, 0xFE, 0x00]);
534        assert_eq!(resp.body_bytes().as_ref(), &[0xFF, 0xFE, 0x00]);
535        assert_eq!(resp.status_code(), 200);
536        assert!(
537            resp.headers.is_empty(),
538            "bytes() should set no default headers"
539        );
540    }
541
542    #[test]
543    fn test_bytes_from_vec_u8() {
544        let resp = HttpResponse::bytes(vec![1, 2, 3]);
545        assert_eq!(resp.body_bytes().len(), 3);
546    }
547
548    #[test]
549    fn test_bytes_with_content_type() {
550        let resp = HttpResponse::bytes(b"PNG data".to_vec()).header("Content-Type", "image/png");
551        let ct = resp
552            .headers
553            .iter()
554            .find(|(k, _)| k == "Content-Type")
555            .map(|(_, v)| v.as_str());
556        assert_eq!(ct, Some("image/png"));
557    }
558
559    #[test]
560    fn test_download_constructor() {
561        let resp = HttpResponse::download(b"pdf content".to_vec(), "report.pdf");
562        let ct = resp
563            .headers
564            .iter()
565            .find(|(k, _)| k == "Content-Type")
566            .map(|(_, v)| v.as_str());
567        assert_eq!(ct, Some("application/pdf"));
568
569        let cd = resp
570            .headers
571            .iter()
572            .find(|(k, _)| k == "Content-Disposition")
573            .map(|(_, v)| v.as_str());
574        assert_eq!(cd, Some("attachment; filename=\"report.pdf\""));
575    }
576
577    #[test]
578    fn test_download_unknown_extension() {
579        let resp = HttpResponse::download(b"data".to_vec(), "file.zzqx");
580        let ct = resp
581            .headers
582            .iter()
583            .find(|(k, _)| k == "Content-Type")
584            .map(|(_, v)| v.as_str());
585        assert_eq!(ct, Some("application/octet-stream"));
586    }
587
588    #[test]
589    fn test_download_filename_sanitization() {
590        let resp = HttpResponse::download(b"data".to_vec(), "evil\"file\nname.pdf");
591        let cd = resp
592            .headers
593            .iter()
594            .find(|(k, _)| k == "Content-Disposition")
595            .map(|(_, v)| v.as_str())
596            .unwrap();
597        assert!(
598            !cd.contains('"') || cd.matches('"').count() == 2,
599            "filename should be properly quoted"
600        );
601        assert!(!cd.contains('\n'), "filename should not contain newlines");
602    }
603
604    #[test]
605    fn test_text_still_works() {
606        let resp = HttpResponse::text("hello");
607        assert_eq!(resp.body(), "hello");
608        assert_eq!(resp.body_bytes().as_ref(), b"hello");
609    }
610
611    #[test]
612    fn test_json_still_works() {
613        let resp = HttpResponse::json(serde_json::json!({"ok": true}));
614        let body = resp.body();
615        assert!(!body.is_empty(), "json body should not be empty");
616        let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
617        assert_eq!(parsed["ok"], true);
618        assert!(!resp.body_bytes().is_empty());
619    }
620
621    #[test]
622    fn test_body_returns_empty_for_binary() {
623        let resp = HttpResponse::bytes(vec![0xFF, 0xFE]);
624        assert_eq!(resp.body(), "");
625    }
626
627    #[test]
628    fn test_into_hyper_preserves_binary() {
629        use http_body_util::BodyExt;
630
631        let data = vec![0xFF, 0x00, 0xFE];
632        let resp = HttpResponse::bytes(data.clone());
633        let hyper_resp = resp.into_hyper();
634
635        let rt = tokio::runtime::Runtime::new().unwrap();
636        let collected =
637            rt.block_on(async { hyper_resp.into_body().collect().await.unwrap().to_bytes() });
638        assert_eq!(collected.as_ref(), &data);
639    }
640
641    #[test]
642    fn test_header_replaces_existing() {
643        let resp = HttpResponse::text("x").header("Content-Type", "text/html");
644        let ct: Vec<_> = resp
645            .headers()
646            .iter()
647            .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
648            .collect();
649        assert_eq!(ct.len(), 1, "expected exactly one Content-Type entry");
650        assert_eq!(ct[0].1, "text/html");
651    }
652
653    #[test]
654    fn test_multi_cookie_preserved() {
655        let resp = HttpResponse::new()
656            .cookie(Cookie::new("a", "1"))
657            .cookie(Cookie::new("b", "2"));
658        let cookies: Vec<_> = resp
659            .headers()
660            .iter()
661            .filter(|(k, _)| k == "Set-Cookie")
662            .collect();
663        assert_eq!(
664            cookies.len(),
665            2,
666            "both Set-Cookie entries must be preserved"
667        );
668    }
669
670    #[test]
671    fn test_header_case_insensitive_replace() {
672        let resp = HttpResponse::new()
673            .append_header("content-type", "text/plain")
674            .header("Content-Type", "text/html");
675        let ct: Vec<_> = resp
676            .headers()
677            .iter()
678            .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
679            .collect();
680        assert_eq!(ct.len(), 1, "lowercase prior entry must be replaced");
681        assert_eq!(ct[0].1, "text/html");
682    }
683
684    #[test]
685    fn test_append_header_does_not_replace() {
686        let resp = HttpResponse::new()
687            .append_header("X-Tag", "a")
688            .append_header("X-Tag", "b");
689        let count = resp.headers().iter().filter(|(k, _)| k == "X-Tag").count();
690        assert_eq!(count, 2, "append_header must not strip existing entries");
691    }
692
693    #[test]
694    fn test_headers_accessor() {
695        let resp = HttpResponse::text("x");
696        assert!(
697            !resp.headers().is_empty(),
698            "headers() accessor should return the prepopulated Content-Type"
699        );
700    }
701}