Skip to main content

ferro_rs/http/
response.rs

1use super::body::FerroBody;
2use super::cookie::Cookie;
3use bytes::Bytes;
4use http_body_util::Full;
5
6/// HTTP Response builder providing Laravel-like response creation
7#[derive(Debug)]
8pub struct HttpResponse {
9    status: u16,
10    body: Bytes,
11    headers: Vec<(String, String)>,
12}
13
14/// Response type alias - allows using `?` operator for early returns
15pub type Response = Result<HttpResponse, HttpResponse>;
16
17impl HttpResponse {
18    /// Create an empty 200 OK response.
19    pub fn new() -> Self {
20        Self {
21            status: 200,
22            body: Bytes::new(),
23            headers: Vec::new(),
24        }
25    }
26
27    /// Create a response with a string body
28    pub fn text(body: impl Into<String>) -> Self {
29        let s: String = body.into();
30        Self {
31            status: 200,
32            body: Bytes::from(s),
33            headers: vec![("Content-Type".to_string(), "text/plain".to_string())],
34        }
35    }
36
37    /// Create a JSON response from a serde_json::Value
38    pub fn json(body: serde_json::Value) -> Self {
39        Self {
40            status: 200,
41            body: Bytes::from(body.to_string()),
42            headers: vec![("Content-Type".to_string(), "application/json".to_string())],
43        }
44    }
45
46    /// Create a response with raw binary data.
47    ///
48    /// No default Content-Type is set; the caller must add one via `.header()`.
49    pub fn bytes(body: impl Into<Bytes>) -> Self {
50        Self {
51            status: 200,
52            body: body.into(),
53            headers: vec![],
54        }
55    }
56
57    /// Create a file download response with Content-Disposition header.
58    ///
59    /// Auto-detects Content-Type from the filename extension using `mime_guess`.
60    /// Falls back to `application/octet-stream` for unknown extensions.
61    /// The filename is sanitized against header injection (control characters
62    /// and quote marks are stripped).
63    pub fn download(body: impl Into<Bytes>, filename: &str) -> Self {
64        let safe_name: String = filename
65            .chars()
66            .filter(|c| !c.is_control() && *c != '"' && *c != '\\')
67            .collect();
68
69        let content_type = mime_guess::from_path(&safe_name)
70            .first()
71            .map(|m| m.to_string())
72            .unwrap_or_else(|| "application/octet-stream".to_string());
73
74        Self {
75            status: 200,
76            body: body.into(),
77            headers: vec![
78                ("Content-Type".to_string(), content_type),
79                (
80                    "Content-Disposition".to_string(),
81                    format!("attachment; filename=\"{safe_name}\""),
82                ),
83            ],
84        }
85    }
86
87    /// Set the response body
88    pub fn set_body(mut self, body: impl Into<String>) -> Self {
89        let s: String = body.into();
90        self.body = Bytes::from(s);
91        self
92    }
93
94    /// Set the HTTP status code
95    pub fn status(mut self, status: u16) -> Self {
96        self.status = status;
97        self
98    }
99
100    /// Get the current HTTP status code
101    pub fn status_code(&self) -> u16 {
102        self.status
103    }
104
105    /// Get the response body as a string slice.
106    ///
107    /// Returns an empty string for non-UTF-8 bodies (e.g. binary data).
108    /// Use `body_bytes()` to access raw binary data.
109    pub fn body(&self) -> &str {
110        std::str::from_utf8(&self.body).unwrap_or("")
111    }
112
113    /// Get the response body as raw bytes.
114    pub fn body_bytes(&self) -> &Bytes {
115        &self.body
116    }
117
118    /// Set a response header, replacing any existing header with the same name.
119    ///
120    /// The name match is case-insensitive (ASCII). Use [`append_header`](Self::append_header)
121    /// for legitimately multi-value headers such as `Set-Cookie`, `Vary`, or `Link`.
122    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
123        let name = name.into();
124        self.headers.retain(|(n, _)| !n.eq_ignore_ascii_case(&name));
125        self.headers.push((name, value.into()));
126        self
127    }
128
129    /// Append a response header without removing any existing entry with the same name.
130    ///
131    /// Intended for headers that legitimately carry multiple values on separate lines,
132    /// such as `Set-Cookie` (RFC 6265 §4.1), `Vary`, and `Link`. For single-value
133    /// headers like `Content-Type` or `Location`, use [`header`](Self::header) instead.
134    pub fn append_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
135        self.headers.push((name.into(), value.into()));
136        self
137    }
138
139    /// Get the response headers as a borrowed slice.
140    ///
141    /// Returns all header entries in insertion order. Multi-value headers
142    /// (e.g. `Set-Cookie`) appear as multiple entries with the same name.
143    pub fn headers(&self) -> &[(String, String)] {
144        &self.headers
145    }
146
147    /// Add a Set-Cookie header to the response
148    ///
149    /// # Example
150    ///
151    /// ```rust,ignore
152    /// use crate::{Cookie, HttpResponse};
153    ///
154    /// let response = HttpResponse::text("OK")
155    ///     .cookie(Cookie::new("session", "abc123"))
156    ///     .cookie(Cookie::new("user_id", "42"));
157    /// ```
158    pub fn cookie(self, cookie: Cookie) -> Self {
159        let header_value = cookie.to_header_value();
160        self.append_header("Set-Cookie", header_value)
161    }
162
163    /// Wrap this response in Ok() for use as Response type
164    pub fn ok(self) -> Response {
165        Ok(self)
166    }
167
168    /// Convert to hyper response.
169    ///
170    /// Returns `hyper::Response<FerroBody>` — the buffered body is wrapped as
171    /// `FerroBody::Full`. For streaming SSE responses, use `HttpResponse::sse()` instead,
172    /// which returns the response with a `FerroBody::Stream` body directly.
173    pub fn into_hyper(self) -> hyper::Response<FerroBody> {
174        let mut builder = hyper::Response::builder().status(self.status);
175
176        for (name, value) in self.headers {
177            builder = builder.header(name, value);
178        }
179
180        builder.body(FerroBody::Full(Full::new(self.body))).unwrap()
181    }
182
183    /// Create an SSE streaming response with the correct headers, returning a channel
184    /// sender and the ready-to-send hyper response.
185    ///
186    /// Sets `Content-Type: text/event-stream`, `Cache-Control: no-cache`,
187    /// `Connection: keep-alive`, and `X-Accel-Buffering: no` (disables nginx proxy
188    /// buffering). The response body is `FerroBody::Stream` — structurally guaranteed
189    /// to never be whole-body buffered (D-06).
190    ///
191    /// # Example
192    ///
193    /// ```rust,ignore
194    /// #[handler]
195    /// pub async fn stream(req: Request) -> Response {
196    ///     let (tx, response) = HttpResponse::sse_channel(16);
197    ///     tokio::spawn(async move {
198    ///         tx.send(SseEvent::data("hello")).await.ok();
199    ///         // tx dropped → stream ends
200    ///     });
201    ///     Ok(response.into())
202    /// }
203    /// ```
204    pub fn sse_channel(
205        buffer: usize,
206    ) -> (
207        tokio::sync::mpsc::Sender<super::sse::SseEvent>,
208        hyper::Response<FerroBody>,
209    ) {
210        let (tx, stream) = super::sse::SseStream::channel(buffer);
211        let response = hyper::Response::builder()
212            .status(200)
213            .header("Content-Type", "text/event-stream")
214            .header("Cache-Control", "no-cache")
215            .header("Connection", "keep-alive")
216            .header("X-Accel-Buffering", "no")
217            .body(FerroBody::Stream(stream))
218            .unwrap();
219        (tx, response)
220    }
221
222    /// Create an SSE streaming response from an existing [`SseStream`](super::sse::SseStream).
223    ///
224    /// Sets the same four required SSE headers as [`sse_channel`](Self::sse_channel).
225    /// Use this when you already have an `SseStream` (e.g. created via
226    /// [`SseStream::channel`](super::sse::SseStream::channel)).
227    pub fn sse(stream: super::sse::SseStream) -> hyper::Response<FerroBody> {
228        hyper::Response::builder()
229            .status(200)
230            .header("Content-Type", "text/event-stream")
231            .header("Cache-Control", "no-cache")
232            .header("Connection", "keep-alive")
233            .header("X-Accel-Buffering", "no")
234            .body(FerroBody::Stream(stream))
235            .unwrap()
236    }
237}
238
239impl Default for HttpResponse {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245/// Extension trait for Response to enable method chaining on macros
246pub trait ResponseExt {
247    /// Set the HTTP status code.
248    fn status(self, code: u16) -> Self;
249    /// Set a response header, replacing any existing header with the same name (case-insensitive).
250    fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self;
251}
252
253impl ResponseExt for Response {
254    fn status(self, code: u16) -> Self {
255        self.map(|r| r.status(code))
256    }
257
258    fn header(self, name: impl Into<String>, value: impl Into<String>) -> Self {
259        self.map(|r| r.header(name, value))
260    }
261}
262
263/// HTTP Redirect response builder
264pub struct Redirect {
265    location: String,
266    query_params: Vec<(String, String)>,
267    status: u16,
268}
269
270impl Redirect {
271    /// Create a redirect to a specific URL/path
272    pub fn to(path: impl Into<String>) -> Self {
273        Self {
274            location: path.into(),
275            query_params: Vec::new(),
276            status: 302,
277        }
278    }
279
280    /// Create a redirect that returns the user to the page that triggered
281    /// the current request, derived from the `Referer` header.
282    ///
283    /// Preserves query string and fragment from the source page so tab
284    /// selection (`?tab=note`), pagination cursors, and scroll-restoration
285    /// keys (`scroll_preserve.rs`) survive form POSTs.
286    ///
287    /// Falls back to `fallback` when the Referer is absent or points
288    /// off-origin. Same-origin is enforced by requiring the Referer's host
289    /// to match the request's `Host` header (or the Referer to be already
290    /// a relative path) — protects against open-redirect via spoofed Referer.
291    pub fn back(req: &crate::http::Request, fallback: impl Into<String>) -> Self {
292        let location = same_origin_path_from_referer(req).unwrap_or_else(|| fallback.into());
293        Self {
294            location,
295            query_params: Vec::new(),
296            status: 302,
297        }
298    }
299
300    /// Create a redirect to a named route
301    pub fn route(name: &str) -> RedirectRouteBuilder {
302        RedirectRouteBuilder {
303            name: name.to_string(),
304            params: std::collections::HashMap::new(),
305            query_params: Vec::new(),
306            status: 302,
307        }
308    }
309
310    /// Add a query parameter
311    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
312        self.query_params.push((key.to_string(), value.into()));
313        self
314    }
315
316    /// Set status to 301 (Moved Permanently)
317    pub fn permanent(mut self) -> Self {
318        self.status = 301;
319        self
320    }
321
322    fn build_url(&self) -> String {
323        if self.query_params.is_empty() {
324            self.location.clone()
325        } else {
326            let query = self
327                .query_params
328                .iter()
329                .map(|(k, v)| format!("{k}={v}"))
330                .collect::<Vec<_>>()
331                .join("&");
332            format!("{}?{}", self.location, query)
333        }
334    }
335}
336
337/// Auto-convert Redirect to Response
338impl From<Redirect> for Response {
339    fn from(redirect: Redirect) -> Response {
340        Ok(HttpResponse::new()
341            .status(redirect.status)
342            .header("Location", redirect.build_url()))
343    }
344}
345
346/// Extracts a same-origin `/path?query#fragment` string from the request's
347/// `Referer` header, returning `None` when the header is absent, malformed,
348/// or points off-origin.
349///
350/// Same-origin rule: when the Referer is an absolute URL (`scheme://host/...`)
351/// the host must equal the request's `Host` header. When the Referer is
352/// already a relative path it is accepted as-is. Scheme-relative URLs
353/// (`//evil.com/x`) are rejected.
354fn same_origin_path_from_referer(req: &crate::http::Request) -> Option<String> {
355    let referer = req.header("referer")?;
356    // Scheme-relative URLs (//host/...) — reject; they bypass scheme check.
357    if referer.starts_with("//") {
358        return None;
359    }
360    // Already-relative path — accept as-is.
361    if referer.starts_with('/') {
362        return Some(referer.to_string());
363    }
364    // Absolute URL — strip `scheme://host` prefix and verify host matches.
365    let rest = referer
366        .strip_prefix("http://")
367        .or_else(|| referer.strip_prefix("https://"))?;
368    let (referer_host, path) = match rest.find('/') {
369        Some(i) => (&rest[..i], &rest[i..]),
370        None => (rest, "/"),
371    };
372    let request_host = req.header("host")?;
373    if referer_host == request_host {
374        Some(path.to_string())
375    } else {
376        None
377    }
378}
379
380/// Builder for redirects to named routes with parameters
381pub struct RedirectRouteBuilder {
382    name: String,
383    params: std::collections::HashMap<String, String>,
384    query_params: Vec<(String, String)>,
385    status: u16,
386}
387
388impl RedirectRouteBuilder {
389    /// Add a route parameter value
390    pub fn with(mut self, key: &str, value: impl Into<String>) -> Self {
391        self.params.insert(key.to_string(), value.into());
392        self
393    }
394
395    /// Add a query parameter
396    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
397        self.query_params.push((key.to_string(), value.into()));
398        self
399    }
400
401    /// Set status to 301 (Moved Permanently)
402    pub fn permanent(mut self) -> Self {
403        self.status = 301;
404        self
405    }
406
407    fn build_url(&self) -> Option<String> {
408        use crate::routing::route_with_params;
409
410        let mut url = route_with_params(&self.name, &self.params)?;
411        if !self.query_params.is_empty() {
412            let query = self
413                .query_params
414                .iter()
415                .map(|(k, v)| format!("{k}={v}"))
416                .collect::<Vec<_>>()
417                .join("&");
418            url = format!("{url}?{query}");
419        }
420        Some(url)
421    }
422}
423
424/// Auto-convert RedirectRouteBuilder to Response
425impl From<RedirectRouteBuilder> for Response {
426    fn from(redirect: RedirectRouteBuilder) -> Response {
427        let url = redirect.build_url().ok_or_else(|| {
428            HttpResponse::text(format!("Route '{}' not found", redirect.name)).status(500)
429        })?;
430        Ok(HttpResponse::new()
431            .status(redirect.status)
432            .header("Location", url))
433    }
434}
435
436/// Auto-convert FrameworkError to HttpResponse
437///
438/// This enables using the `?` operator in controller handlers to propagate
439/// framework errors as appropriate HTTP responses.
440///
441/// When a hint is available (via `FrameworkError::hint()`), the JSON response
442/// includes a `"hint"` field with actionable guidance for the developer.
443impl From<crate::error::FrameworkError> for HttpResponse {
444    fn from(err: crate::error::FrameworkError) -> HttpResponse {
445        let status = err.status_code();
446        let hint = err.hint();
447        let mut body = match &err {
448            crate::error::FrameworkError::ParamError { param_name } => {
449                serde_json::json!({
450                    "message": format!("Missing required parameter: {}", param_name)
451                })
452            }
453            crate::error::FrameworkError::ValidationError { field, message } => {
454                serde_json::json!({
455                    "message": "Validation failed",
456                    "field": field,
457                    "error": message
458                })
459            }
460            crate::error::FrameworkError::Validation(errors) => {
461                // Laravel/Inertia-compatible validation error format
462                errors.to_json()
463            }
464            crate::error::FrameworkError::Unauthorized => {
465                serde_json::json!({
466                    "message": "This action is unauthorized."
467                })
468            }
469            _ => {
470                serde_json::json!({
471                    "message": err.to_string()
472                })
473            }
474        };
475        if let Some(hint_text) = hint {
476            if let Some(obj) = body.as_object_mut() {
477                obj.insert("hint".to_string(), serde_json::Value::String(hint_text));
478            }
479        }
480        HttpResponse::json(body).status(status)
481    }
482}
483
484/// Auto-convert AppError to HttpResponse
485///
486/// This enables using the `?` operator in controller handlers with AppError.
487impl From<crate::error::AppError> for HttpResponse {
488    fn from(err: crate::error::AppError) -> HttpResponse {
489        // Convert AppError -> FrameworkError -> HttpResponse
490        let framework_err: crate::error::FrameworkError = err.into();
491        framework_err.into()
492    }
493}
494
495/// Auto-convert ferro_projections::Error to HttpResponse
496///
497/// This enables using the `?` operator in controller handlers with projection errors.
498#[cfg(feature = "projections")]
499impl From<ferro_projections::Error> for HttpResponse {
500    fn from(err: ferro_projections::Error) -> HttpResponse {
501        let framework_err: crate::error::FrameworkError = err.into();
502        framework_err.into()
503    }
504}
505
506/// Inertia-aware HTTP Redirect response builder.
507///
508/// Unlike standard `Redirect`, this respects the Inertia protocol:
509/// - For Inertia XHR requests from POST/PUT/PATCH/DELETE, uses 303 status
510/// - Includes X-Inertia header in responses to Inertia requests
511/// - Falls back to standard 302 for non-Inertia requests
512///
513/// # Example
514///
515/// ```rust,ignore
516/// use ferro_rs::{InertiaRedirect, Request, Response};
517///
518/// pub async fn store(req: Request) -> Response {
519///     // ... create record ...
520///     InertiaRedirect::to(&req, "/items").into()
521/// }
522/// ```
523pub struct InertiaRedirect<'a> {
524    request: &'a crate::http::Request,
525    location: String,
526    query_params: Vec<(String, String)>,
527}
528
529impl<'a> InertiaRedirect<'a> {
530    /// Create a redirect that respects Inertia protocol.
531    pub fn to(request: &'a crate::http::Request, path: impl Into<String>) -> Self {
532        Self {
533            request,
534            location: path.into(),
535            query_params: Vec::new(),
536        }
537    }
538
539    /// Add a query parameter.
540    pub fn query(mut self, key: &str, value: impl Into<String>) -> Self {
541        self.query_params.push((key.to_string(), value.into()));
542        self
543    }
544
545    fn build_url(&self) -> String {
546        if self.query_params.is_empty() {
547            self.location.clone()
548        } else {
549            let query = self
550                .query_params
551                .iter()
552                .map(|(k, v)| format!("{k}={v}"))
553                .collect::<Vec<_>>()
554                .join("&");
555            format!("{}?{}", self.location, query)
556        }
557    }
558
559    fn is_post_like_method(&self) -> bool {
560        matches!(
561            self.request.method().as_str(),
562            "POST" | "PUT" | "PATCH" | "DELETE"
563        )
564    }
565}
566
567impl From<InertiaRedirect<'_>> for Response {
568    fn from(redirect: InertiaRedirect<'_>) -> Response {
569        let url = redirect.build_url();
570        let is_inertia = redirect.request.is_inertia();
571        let is_post_like = redirect.is_post_like_method();
572
573        if is_inertia {
574            // Use 303 for POST-like methods to force GET on redirect
575            let status = if is_post_like { 303 } else { 302 };
576            Ok(HttpResponse::new()
577                .status(status)
578                .header("X-Inertia", "true")
579                .header("Location", url))
580        } else {
581            // Standard redirect for non-Inertia requests
582            Ok(HttpResponse::new().status(302).header("Location", url))
583        }
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_bytes_constructor() {
593        let resp = HttpResponse::bytes(vec![0xFF, 0xFE, 0x00]);
594        assert_eq!(resp.body_bytes().as_ref(), &[0xFF, 0xFE, 0x00]);
595        assert_eq!(resp.status_code(), 200);
596        assert!(
597            resp.headers.is_empty(),
598            "bytes() should set no default headers"
599        );
600    }
601
602    #[test]
603    fn test_bytes_from_vec_u8() {
604        let resp = HttpResponse::bytes(vec![1, 2, 3]);
605        assert_eq!(resp.body_bytes().len(), 3);
606    }
607
608    #[test]
609    fn test_bytes_with_content_type() {
610        let resp = HttpResponse::bytes(b"PNG data".to_vec()).header("Content-Type", "image/png");
611        let ct = resp
612            .headers
613            .iter()
614            .find(|(k, _)| k == "Content-Type")
615            .map(|(_, v)| v.as_str());
616        assert_eq!(ct, Some("image/png"));
617    }
618
619    #[test]
620    fn test_download_constructor() {
621        let resp = HttpResponse::download(b"pdf content".to_vec(), "report.pdf");
622        let ct = resp
623            .headers
624            .iter()
625            .find(|(k, _)| k == "Content-Type")
626            .map(|(_, v)| v.as_str());
627        assert_eq!(ct, Some("application/pdf"));
628
629        let cd = resp
630            .headers
631            .iter()
632            .find(|(k, _)| k == "Content-Disposition")
633            .map(|(_, v)| v.as_str());
634        assert_eq!(cd, Some("attachment; filename=\"report.pdf\""));
635    }
636
637    #[test]
638    fn test_download_unknown_extension() {
639        let resp = HttpResponse::download(b"data".to_vec(), "file.zzqx");
640        let ct = resp
641            .headers
642            .iter()
643            .find(|(k, _)| k == "Content-Type")
644            .map(|(_, v)| v.as_str());
645        assert_eq!(ct, Some("application/octet-stream"));
646    }
647
648    #[test]
649    fn test_download_filename_sanitization() {
650        let resp = HttpResponse::download(b"data".to_vec(), "evil\"file\nname.pdf");
651        let cd = resp
652            .headers
653            .iter()
654            .find(|(k, _)| k == "Content-Disposition")
655            .map(|(_, v)| v.as_str())
656            .unwrap();
657        assert!(
658            !cd.contains('"') || cd.matches('"').count() == 2,
659            "filename should be properly quoted"
660        );
661        assert!(!cd.contains('\n'), "filename should not contain newlines");
662    }
663
664    #[test]
665    fn test_text_still_works() {
666        let resp = HttpResponse::text("hello");
667        assert_eq!(resp.body(), "hello");
668        assert_eq!(resp.body_bytes().as_ref(), b"hello");
669    }
670
671    #[test]
672    fn test_json_still_works() {
673        let resp = HttpResponse::json(serde_json::json!({"ok": true}));
674        let body = resp.body();
675        assert!(!body.is_empty(), "json body should not be empty");
676        let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
677        assert_eq!(parsed["ok"], true);
678        assert!(!resp.body_bytes().is_empty());
679    }
680
681    #[test]
682    fn test_body_returns_empty_for_binary() {
683        let resp = HttpResponse::bytes(vec![0xFF, 0xFE]);
684        assert_eq!(resp.body(), "");
685    }
686
687    #[test]
688    fn test_into_hyper_preserves_binary() {
689        use http_body_util::BodyExt;
690
691        let data = vec![0xFF, 0x00, 0xFE];
692        let resp = HttpResponse::bytes(data.clone());
693        let hyper_resp = resp.into_hyper();
694
695        let rt = tokio::runtime::Runtime::new().unwrap();
696        let collected =
697            rt.block_on(async { hyper_resp.into_body().collect().await.unwrap().to_bytes() });
698        assert_eq!(collected.as_ref(), &data);
699    }
700
701    #[test]
702    fn test_header_replaces_existing() {
703        let resp = HttpResponse::text("x").header("Content-Type", "text/html");
704        let ct: Vec<_> = resp
705            .headers()
706            .iter()
707            .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
708            .collect();
709        assert_eq!(ct.len(), 1, "expected exactly one Content-Type entry");
710        assert_eq!(ct[0].1, "text/html");
711    }
712
713    #[test]
714    fn test_multi_cookie_preserved() {
715        let resp = HttpResponse::new()
716            .cookie(Cookie::new("a", "1"))
717            .cookie(Cookie::new("b", "2"));
718        let cookies: Vec<_> = resp
719            .headers()
720            .iter()
721            .filter(|(k, _)| k == "Set-Cookie")
722            .collect();
723        assert_eq!(
724            cookies.len(),
725            2,
726            "both Set-Cookie entries must be preserved"
727        );
728    }
729
730    #[test]
731    fn test_header_case_insensitive_replace() {
732        let resp = HttpResponse::new()
733            .append_header("content-type", "text/plain")
734            .header("Content-Type", "text/html");
735        let ct: Vec<_> = resp
736            .headers()
737            .iter()
738            .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
739            .collect();
740        assert_eq!(ct.len(), 1, "lowercase prior entry must be replaced");
741        assert_eq!(ct[0].1, "text/html");
742    }
743
744    #[test]
745    fn test_append_header_does_not_replace() {
746        let resp = HttpResponse::new()
747            .append_header("X-Tag", "a")
748            .append_header("X-Tag", "b");
749        let count = resp.headers().iter().filter(|(k, _)| k == "X-Tag").count();
750        assert_eq!(count, 2, "append_header must not strip existing entries");
751    }
752
753    #[test]
754    fn test_headers_accessor() {
755        let resp = HttpResponse::text("x");
756        assert!(
757            !resp.headers().is_empty(),
758            "headers() accessor should return the prepopulated Content-Type"
759        );
760    }
761}