Skip to main content

chopin_core/
http.rs

1// src/http.rs
2use crate::headers::{Headers, IntoHeaderValue};
3use crate::syscalls;
4use std::io;
5
6/// HTTP request method.
7///
8/// Uses a `u8` repr for fast array-indexed dispatch in the router.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10#[repr(u8)]
11pub enum Method {
12    Get = 0,
13    Post = 1,
14    Put = 2,
15    Delete = 3,
16    Patch = 4,
17    Head = 5,
18    Options = 6,
19    Trace = 7,
20    Connect = 8,
21    Unknown = 9,
22}
23
24impl Method {
25    /// First-byte dispatch for fast HTTP method parsing (picohttpparser technique).
26    #[inline(always)]
27    pub fn from_bytes(b: &[u8]) -> Self {
28        if b.is_empty() {
29            return Method::Unknown;
30        }
31        match b[0] {
32            b'G' => {
33                if b.len() == 3 && b[1] == b'E' && b[2] == b'T' {
34                    Method::Get
35                } else {
36                    Method::Unknown
37                }
38            }
39            b'P' => {
40                if b.len() < 3 {
41                    return Method::Unknown;
42                }
43                match b[1] {
44                    b'O' => {
45                        if b.len() == 4 && b[2] == b'S' && b[3] == b'T' {
46                            Method::Post
47                        } else {
48                            Method::Unknown
49                        }
50                    }
51                    b'U' => {
52                        if b.len() == 3 && b[2] == b'T' {
53                            Method::Put
54                        } else {
55                            Method::Unknown
56                        }
57                    }
58                    b'A' => {
59                        if b.len() == 5 && b[2] == b'T' && b[3] == b'C' && b[4] == b'H' {
60                            Method::Patch
61                        } else {
62                            Method::Unknown
63                        }
64                    }
65                    _ => Method::Unknown,
66                }
67            }
68            b'D' => {
69                if b == b"DELETE" {
70                    Method::Delete
71                } else {
72                    Method::Unknown
73                }
74            }
75            b'H' => {
76                if b == b"HEAD" {
77                    Method::Head
78                } else {
79                    Method::Unknown
80                }
81            }
82            b'O' => {
83                if b == b"OPTIONS" {
84                    Method::Options
85                } else {
86                    Method::Unknown
87                }
88            }
89            b'T' => {
90                if b == b"TRACE" {
91                    Method::Trace
92                } else {
93                    Method::Unknown
94                }
95            }
96            b'C' => {
97                if b == b"CONNECT" {
98                    Method::Connect
99                } else {
100                    Method::Unknown
101                }
102            }
103            _ => Method::Unknown,
104        }
105    }
106}
107
108pub const MAX_HEADERS: usize = 32;
109pub const MAX_PARAMS: usize = 4;
110
111/// A parsed HTTP request. All fields borrow from the connection's read buffer
112/// — no heap allocation occurs during request parsing.
113pub struct Request<'a> {
114    pub method: Method,
115    pub path: &'a str,
116    pub query: Option<&'a str>,
117    pub headers: [(&'a str, &'a str); MAX_HEADERS],
118    pub header_count: u8,
119    pub body: &'a [u8],
120}
121
122/// RAII wrapper for a file descriptor. Closes the fd on drop unless taken.
123pub struct OwnedFd(i32);
124
125impl OwnedFd {
126    /// Wrap an already-opened file descriptor.
127    pub fn new(fd: i32) -> Self {
128        Self(fd)
129    }
130
131    /// Take the raw fd, preventing Drop from closing it.
132    /// The caller assumes ownership of closing the fd.
133    pub(crate) fn take(&mut self) -> i32 {
134        let fd = self.0;
135        self.0 = -1;
136        fd
137    }
138
139    /// Peek at the raw fd without taking ownership.
140    #[allow(dead_code)]
141    pub fn raw(&self) -> i32 {
142        self.0
143    }
144}
145
146impl Drop for OwnedFd {
147    fn drop(&mut self) {
148        if self.0 >= 0 {
149            unsafe {
150                libc::close(self.0);
151            }
152        }
153    }
154}
155
156/// The body of an HTTP response.
157///
158/// Supports multiple storage strategies: zero-copy static slices, heap-allocated
159/// bytes, streaming iterators, and kernel-level `sendfile` for files.
160pub enum Body {
161    /// No body content.
162    Empty,
163    /// A compile-time static byte slice — zero allocation, zero copy.
164    Static(&'static [u8]),
165    /// Heap-allocated byte vector.
166    Bytes(Vec<u8>),
167    /// Chunked streaming body — each call to `next()` yields a chunk.
168    Stream(Box<dyn Iterator<Item = Vec<u8>> + Send>),
169    /// Zero-copy file body — served via `sendfile()` entirely in kernel space.
170    /// The fd is owned and will be closed when the response is consumed or dropped.
171    File { fd: OwnedFd, offset: u64, len: u64 },
172    /// Fully pre-baked raw HTTP response (status line + headers + body) as a
173    /// static byte slice. The worker writes this verbatim, bypassing ALL
174    /// header serialization logic. Maximum throughput — zero overhead.
175    ///
176    /// Use [`Response::raw`] to construct. You are responsible for producing a
177    /// valid HTTP/1.1 response including "\r\n\r\n" and the body.
178    Raw(&'static [u8]),
179}
180
181impl Body {
182    #[inline(always)]
183    pub fn len(&self) -> usize {
184        match self {
185            Body::Empty => 0,
186            Body::Static(b) => b.len(),
187            Body::Bytes(b) => b.len(),
188            Body::Stream(_) => 0, // unknown until streamed
189            Body::File { len, .. } => *len as usize,
190            Body::Raw(b) => b.len(), // full response bytes
191        }
192    }
193
194    #[inline(always)]
195    pub fn is_empty(&self) -> bool {
196        self.len() == 0
197    }
198
199    #[inline(always)]
200    pub fn as_bytes(&self) -> &[u8] {
201        match self {
202            Body::Empty => &[],
203            Body::Static(b) => b,
204            Body::Bytes(b) => b.as_slice(),
205            Body::Stream(_) => &[], // Streams must be polled/chunked iteratively
206            Body::File { .. } => &[], // File data lives on disk, sent via sendfile
207            Body::Raw(b) => b,      // raw full response
208        }
209    }
210
211    /// Returns `true` if this body will be served via zero-copy `sendfile`.
212    #[inline(always)]
213    pub fn is_file(&self) -> bool {
214        matches!(self, Body::File { .. })
215    }
216
217    /// Returns `true` if this body is a pre-baked full raw HTTP response.
218    #[inline(always)]
219    pub fn is_raw(&self) -> bool {
220        matches!(self, Body::Raw(_))
221    }
222}
223
224/// An HTTP response to be sent to the client.
225///
226/// Construct responses using the factory methods ([`Response::text`],
227/// [`Response::json`], [`Response::file`], etc.) and customise with
228/// [`Response::with_header`] and status code assignment.
229///
230/// # Examples
231///
232/// ```rust,ignore
233/// // Plain text
234/// Response::text("Hello, world!")
235///
236/// // JSON (Schema-JIT serialization)
237/// Response::json(&user)
238///
239/// // Custom status + headers
240/// let mut res = Response::json(&item);
241/// res.status = 201;
242/// res.with_header("Location", "/items/42")
243/// ```
244pub struct Response {
245    pub status: u16,
246    pub body: Body,
247    pub content_type: &'static str,
248    /// Custom response headers — stored inline (stack) for ≤8 headers,
249    /// falling back to heap for more. No allocation for common cases.
250    pub headers: Headers,
251}
252
253impl Response {
254    /// Create a response with no body and a given status code.
255    pub fn new(status: u16) -> Self {
256        Self {
257            status,
258            body: Body::Empty,
259            content_type: "text/plain",
260            headers: Headers::new(),
261        }
262    }
263
264    /// Builder-style method to append an HTTP response header.
265    ///
266    /// The value may be a `&'static str`, `String`, or any integer type.
267    /// Short values (≤ 64 bytes) are stored inline on the stack; longer
268    /// values fall back to heap allocation.
269    pub fn with_header(mut self, name: &'static str, value: impl IntoHeaderValue) -> Self {
270        self.headers.add(name, value);
271        self
272    }
273
274    /// 200 OK with a plain-text body.
275    pub fn text(body: impl Into<Vec<u8>>) -> Self {
276        Self {
277            status: 200,
278            body: Body::Bytes(body.into()),
279            content_type: "text/plain",
280            headers: Headers::new(),
281        }
282    }
283
284    /// 200 OK with a zero-copy static plain-text body.
285    /// Avoids heap allocation — ideal for fixed responses like TFB plaintext.
286    pub fn text_static(body: &'static [u8]) -> Self {
287        Self {
288            status: 200,
289            body: Body::Static(body),
290            content_type: "text/plain",
291            headers: Headers::new(),
292        }
293    }
294
295    /// 200 OK with a pre-serialized JSON byte body.
296    /// Use `Response::json()` when you have a typed value to serialize.
297    pub fn json_bytes(body: impl Into<Vec<u8>>) -> Self {
298        Self {
299            status: 200,
300            body: Body::Bytes(body.into()),
301            content_type: "application/json",
302            headers: Headers::new(),
303        }
304    }
305
306    /// 200 OK — serializes a typed value to JSON using the Schema-JIT engine.
307    /// This is the primary way to return structured data from a handler.
308    pub fn json<T: kowito_json::serialize::Serialize>(val: &T) -> Self {
309        let mut buf = Vec::with_capacity(128);
310        val.serialize(&mut buf);
311        Self::json_bytes(buf)
312    }
313
314    /// 200 OK with a zero-copy static pre-serialized JSON body.
315    ///
316    /// The fastest JSON response: `&'static [u8]` known at compile time.
317    /// Zero heap allocation on every request.
318    ///
319    /// # Example
320    /// ```ignore
321    /// // Pre-bake at compile time:
322    /// Response::json_static(b"{\"message\":\"Hello, World!\"}")
323    /// ```
324    #[inline(always)]
325    pub fn json_static(body: &'static [u8]) -> Self {
326        Self {
327            status: 200,
328            body: Body::Static(body),
329            content_type: "application/json",
330            headers: Headers::new(),
331        }
332    }
333
334    /// Emit a fully pre-baked HTTP/1.1 response verbatim.
335    ///
336    /// The supplied `bytes` must be a **complete**, valid HTTP/1.1 response
337    /// (status line + headers + blank line + body). The worker writes them
338    /// as-is, bypassing every header serialization step.
339    ///
340    /// This is the **absolute fastest** response path — a single `memcpy`
341    /// into the connection's `write_buf`, then one `write(2)` syscall.
342    ///
343    /// # Safety contract
344    /// You must include `Date:`, `Content-Length:`, and `Content-Type:` headers
345    /// yourself. Chopin will NOT add them for `Body::Raw` responses.
346    ///
347    /// # Example
348    /// ```ignore
349    /// // Build once at program start:
350    /// static PONG: &[u8] = b"HTTP/1.1 200 OK\r\n\
351    ///     Server: chopin\r\n\
352    ///     Content-Type: text/plain\r\n\
353    ///     Content-Length: 4\r\n\
354    ///     Connection: keep-alive\r\n\
355    ///     \r\n\
356    ///     pong";
357    ///
358    /// fn pong(_ctx: Context) -> Response { Response::raw(PONG) }
359    /// ```
360    #[inline(always)]
361    pub fn raw(bytes: &'static [u8]) -> Self {
362        Self {
363            status: 200,
364            body: Body::Raw(bytes),
365            content_type: "",
366            headers: Headers::new(),
367        }
368    }
369
370    /// 404 Not Found.
371    pub fn not_found() -> Self {
372        Self {
373            status: 404,
374            body: Body::Static(b"Not Found"),
375            content_type: "text/plain",
376            headers: Headers::new(),
377        }
378    }
379
380    /// 500 Internal Server Error.
381    pub fn server_error() -> Self {
382        Self {
383            status: 500,
384            body: Body::Static(b"Internal Server Error"),
385            content_type: "text/plain",
386            headers: Headers::new(),
387        }
388    }
389
390    /// 400 Bad Request.
391    pub fn bad_request() -> Self {
392        Self {
393            status: 400,
394            body: Body::Static(b"Bad Request"),
395            content_type: "text/plain",
396            headers: Headers::new(),
397        }
398    }
399
400    /// 401 Unauthorized.
401    pub fn unauthorized() -> Self {
402        Self {
403            status: 401,
404            body: Body::Static(b"Unauthorized"),
405            content_type: "text/plain",
406            headers: Headers::new(),
407        }
408    }
409
410    /// 403 Forbidden.
411    pub fn forbidden() -> Self {
412        Self {
413            status: 403,
414            body: Body::Static(b"Forbidden"),
415            content_type: "text/plain",
416            headers: Headers::new(),
417        }
418    }
419
420    /// Chunked streaming response with `application/octet-stream` content type.
421    pub fn stream(iter: impl Iterator<Item = Vec<u8>> + Send + 'static) -> Self {
422        Self {
423            status: 200,
424            body: Body::Stream(Box::new(iter)),
425            content_type: "application/octet-stream",
426            headers: Headers::new(),
427        }
428    }
429
430    /// Serve a file using zero-copy `sendfile`. Content-Type is inferred from the
431    /// file extension. Returns 404 if the file does not exist or cannot be opened.
432    pub fn file(path: &str) -> Self {
433        match Self::try_file(path) {
434            Ok(resp) => resp,
435            Err(_) => Self::not_found(),
436        }
437    }
438
439    /// Internal: attempt to open a file and build a zero-copy response.
440    fn try_file(path: &str) -> io::Result<Self> {
441        let fd = syscalls::open_file_readonly(path)?;
442        let size = match syscalls::file_size(fd) {
443            Ok(s) => s,
444            Err(e) => {
445                unsafe {
446                    libc::close(fd);
447                }
448                return Err(e);
449            }
450        };
451        let content_type = mime_from_path(path);
452
453        Ok(Self {
454            status: 200,
455            body: Body::File {
456                fd: OwnedFd::new(fd),
457                offset: 0,
458                len: size,
459            },
460            content_type,
461            headers: Headers::new(),
462        })
463    }
464
465    /// Serve a byte range of a file (e.g. for `Range` header support).
466    /// The caller provides an already-opened fd, offset, and length.
467    /// Ownership of the fd is transferred to the response.
468    pub fn sendfile(fd: i32, offset: u64, len: u64, content_type: &'static str) -> Self {
469        Self {
470            status: 200,
471            body: Body::File {
472                fd: OwnedFd::new(fd),
473                offset,
474                len,
475            },
476            content_type,
477            headers: Headers::new(),
478        }
479    }
480
481    /// Compress the response body with gzip encoding.
482    ///
483    /// Works on `Body::Bytes` and `Body::Static` variants — `Stream` and `File`
484    /// bodies are returned unchanged (they have their own delivery paths).
485    /// Adds `Content-Encoding: gzip` and `Vary: Accept-Encoding` headers.
486    #[cfg(feature = "compression")]
487    pub fn gzip(mut self) -> Self {
488        use flate2::Compression;
489        use flate2::write::GzEncoder;
490        use std::io::Write;
491
492        let raw = match &self.body {
493            Body::Static(b) => *b,
494            Body::Bytes(b) => b.as_slice(),
495            _ => return self,
496        };
497
498        if raw.is_empty() {
499            return self;
500        }
501
502        let mut encoder = GzEncoder::new(Vec::with_capacity(raw.len() / 2), Compression::fast());
503        if encoder.write_all(raw).is_ok() {
504            if let Ok(compressed) = encoder.finish() {
505                if compressed.len() < raw.len() {
506                    self.body = Body::Bytes(compressed);
507                    self.headers.add("Content-Encoding", "gzip");
508                    self.headers.add("Vary", "Accept-Encoding");
509                }
510            }
511        }
512        self
513    }
514}
515
516/// Infer a Content-Type from a file path's extension.
517/// Returns a `&'static str` so it can be stored directly in Response.
518fn mime_from_path(path: &str) -> &'static str {
519    let ext = match path.rsplit('.').next() {
520        Some(e) => e,
521        None => return "application/octet-stream",
522    };
523    match ext {
524        // Text
525        "html" | "htm" => "text/html; charset=utf-8",
526        "css" => "text/css; charset=utf-8",
527        "js" | "mjs" => "application/javascript; charset=utf-8",
528        "json" => "application/json; charset=utf-8",
529        "xml" => "application/xml; charset=utf-8",
530        "txt" => "text/plain; charset=utf-8",
531        "csv" => "text/csv; charset=utf-8",
532        "svg" => "image/svg+xml",
533        // Images
534        "png" => "image/png",
535        "jpg" | "jpeg" => "image/jpeg",
536        "gif" => "image/gif",
537        "webp" => "image/webp",
538        "ico" => "image/x-icon",
539        "avif" => "image/avif",
540        // Fonts
541        "woff" => "font/woff",
542        "woff2" => "font/woff2",
543        "ttf" => "font/ttf",
544        "otf" => "font/otf",
545        // Media
546        "mp4" => "video/mp4",
547        "webm" => "video/webm",
548        "mp3" => "audio/mpeg",
549        "ogg" => "audio/ogg",
550        // Archives / binary
551        "wasm" => "application/wasm",
552        "pdf" => "application/pdf",
553        "zip" => "application/zip",
554        "gz" | "gzip" => "application/gzip",
555        "tar" => "application/x-tar",
556        _ => "application/octet-stream",
557    }
558}
559
560/// Trait for types that can be converted into an HTTP [`Response`].
561///
562/// Implemented for `Response`, `String`, `&'static str`, and
563/// `Result<T, E>` where both `T` and `E` implement `IntoResponse`.
564pub trait IntoResponse {
565    fn into_response(self) -> Response;
566}
567
568impl IntoResponse for Response {
569    fn into_response(self) -> Response {
570        self
571    }
572}
573
574impl IntoResponse for String {
575    fn into_response(self) -> Response {
576        Response::text(self.into_bytes())
577    }
578}
579
580impl IntoResponse for &'static str {
581    fn into_response(self) -> Response {
582        Response::text(self.as_bytes().to_vec())
583    }
584}
585
586impl<T: IntoResponse, E: IntoResponse> IntoResponse for Result<T, E> {
587    fn into_response(self) -> Response {
588        match self {
589            Ok(v) => v.into_response(),
590            Err(e) => e.into_response(),
591        }
592    }
593}
594
595/// The request context passed to every handler.
596///
597/// Provides access to the parsed [`Request`], URL path parameters, headers,
598/// and typed extractors via [`Context::extract`].
599///
600/// # Examples
601///
602/// ```rust,ignore
603/// fn handler(ctx: Context) -> Response {
604///     // Path parameter
605///     let id = ctx.param("id").unwrap_or("0");
606///
607///     // Header
608///     let ua = ctx.header("user-agent").unwrap_or("unknown");
609///
610///     // JSON body extractor
611///     let Json(body) = ctx.extract::<Json<MyPayload>>().unwrap();
612///
613///     Response::text("ok")
614/// }
615/// ```
616pub struct Context<'a> {
617    pub req: Request<'a>,
618    pub params: [(&'a str, &'a str); MAX_PARAMS],
619    pub param_count: u8,
620}
621
622impl<'a> Context<'a> {
623    /// Extract a URL path parameter by name, e.g. `:id` → `ctx.param("id")`.
624    pub fn param(&self, key: &str) -> Option<&'a str> {
625        for i in 0..self.param_count as usize {
626            if self.params[i].0 == key {
627                return Some(self.params[i].1);
628            }
629        }
630        None
631    }
632
633    /// Retrieve a request header value by name (case-insensitive).
634    pub fn header(&self, key: &str) -> Option<&'a str> {
635        for i in 0..self.req.header_count as usize {
636            if self.req.headers[i].0.eq_ignore_ascii_case(key) {
637                return Some(self.req.headers[i].1);
638            }
639        }
640        None
641    }
642
643    /// Parse the request body as a multipart/form-data stream.
644    /// Returns `None` if the `Content-Type` header is not `multipart/form-data`.
645    #[allow(clippy::collapsible_if)]
646    pub fn multipart(&self) -> Option<crate::multipart::Multipart<'a>> {
647        let ct = self.header("content-type")?;
648        if ct.starts_with("multipart/form-data") {
649            if let Some(idx) = ct.find("boundary=") {
650                let boundary = &ct[idx + 9..];
651                return Some(crate::multipart::Multipart::new(self.req.body, boundary));
652            }
653        }
654        None
655    }
656
657    /// Use the extractor pattern to parse typed data from the request
658    /// (e.g. `ctx.extract::<Json<MyBody>>()`).
659    pub fn extract<T: crate::extract::FromRequest<'a>>(&'a self) -> Result<T, T::Error> {
660        T::from_request(self)
661    }
662
663    /// Serialize a typed value to JSON and return a `200 OK` response.
664    /// Shorthand for `Response::json(val)` inside a handler.
665    pub fn json<T: crate::json::Serialize>(&self, val: &T) -> Response {
666        Response::json(val)
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    // ─── Method::from_bytes ───────────────────────────────────────────────────
675
676    #[test]
677    fn test_method_get() {
678        assert_eq!(Method::from_bytes(b"GET"), Method::Get);
679    }
680    #[test]
681    fn test_method_post() {
682        assert_eq!(Method::from_bytes(b"POST"), Method::Post);
683    }
684    #[test]
685    fn test_method_put() {
686        assert_eq!(Method::from_bytes(b"PUT"), Method::Put);
687    }
688    #[test]
689    fn test_method_delete() {
690        assert_eq!(Method::from_bytes(b"DELETE"), Method::Delete);
691    }
692    #[test]
693    fn test_method_patch() {
694        assert_eq!(Method::from_bytes(b"PATCH"), Method::Patch);
695    }
696    #[test]
697    fn test_method_head() {
698        assert_eq!(Method::from_bytes(b"HEAD"), Method::Head);
699    }
700    #[test]
701    fn test_method_options() {
702        assert_eq!(Method::from_bytes(b"OPTIONS"), Method::Options);
703    }
704    #[test]
705    fn test_method_trace() {
706        assert_eq!(Method::from_bytes(b"TRACE"), Method::Trace);
707    }
708    #[test]
709    fn test_method_connect() {
710        assert_eq!(Method::from_bytes(b"CONNECT"), Method::Connect);
711    }
712
713    #[test]
714    fn test_method_empty_is_unknown() {
715        assert_eq!(Method::from_bytes(b""), Method::Unknown);
716    }
717
718    #[test]
719    fn test_method_lowercase_is_unknown() {
720        assert_eq!(Method::from_bytes(b"get"), Method::Unknown);
721        assert_eq!(Method::from_bytes(b"post"), Method::Unknown);
722    }
723
724    #[test]
725    fn test_method_truncated_is_unknown() {
726        assert_eq!(Method::from_bytes(b"GE"), Method::Unknown);
727        assert_eq!(Method::from_bytes(b"POS"), Method::Unknown);
728        assert_eq!(Method::from_bytes(b"DEL"), Method::Unknown);
729    }
730
731    #[test]
732    fn test_method_junk_is_unknown() {
733        assert_eq!(Method::from_bytes(b"GETX"), Method::Unknown);
734        assert_eq!(Method::from_bytes(b"XPOST"), Method::Unknown);
735    }
736
737    #[test]
738    fn test_method_eq_and_copy() {
739        let m = Method::Get;
740        let m2 = m; // Copy
741        assert_eq!(m, m2);
742        assert_ne!(Method::Get, Method::Post);
743    }
744
745    // ─── Response constructors ────────────────────────────────────────────────
746
747    #[test]
748    fn test_response_new_status() {
749        let r = Response::new(204);
750        assert_eq!(r.status, 204);
751        assert!(r.body.is_empty());
752    }
753
754    #[test]
755    fn test_response_text_status_and_ct() {
756        let r = Response::text(b"hello".to_vec());
757        assert_eq!(r.status, 200);
758        assert_eq!(r.content_type, "text/plain");
759        assert_eq!(r.body.as_bytes(), b"hello");
760    }
761
762    #[test]
763    fn test_response_text_static() {
764        let r = Response::text_static(b"static");
765        assert_eq!(r.status, 200);
766        assert_eq!(r.content_type, "text/plain");
767        assert_eq!(r.body.as_bytes(), b"static");
768    }
769
770    #[test]
771    fn test_response_json_bytes() {
772        let r = Response::json_bytes(b"{}".to_vec());
773        assert_eq!(r.status, 200);
774        assert_eq!(r.content_type, "application/json");
775        assert_eq!(r.body.as_bytes(), b"{}");
776    }
777
778    #[test]
779    fn test_response_not_found() {
780        let r = Response::not_found();
781        assert_eq!(r.status, 404);
782    }
783
784    #[test]
785    fn test_response_server_error() {
786        let r = Response::server_error();
787        assert_eq!(r.status, 500);
788    }
789
790    #[test]
791    fn test_response_bad_request() {
792        let r = Response::bad_request();
793        assert_eq!(r.status, 400);
794    }
795
796    #[test]
797    fn test_response_unauthorized() {
798        let r = Response::unauthorized();
799        assert_eq!(r.status, 401);
800    }
801
802    #[test]
803    fn test_response_forbidden() {
804        let r = Response::forbidden();
805        assert_eq!(r.status, 403);
806    }
807
808    #[test]
809    fn test_response_with_header_adds_header() {
810        let r = Response::new(200).with_header("x-custom", "value");
811        assert_eq!(r.status, 200);
812        // Headers should contain the custom header
813        let found = r
814            .headers
815            .iter()
816            .any(|h| h.name == "x-custom" && h.value.as_str() == "value");
817        assert!(found, "header x-custom: value not found");
818    }
819
820    // ─── Body ─────────────────────────────────────────────────────────────────
821
822    #[test]
823    fn test_body_empty() {
824        let b = Body::Empty;
825        assert_eq!(b.len(), 0);
826        assert!(b.is_empty());
827        assert_eq!(b.as_bytes(), b"");
828        assert!(!b.is_file());
829    }
830
831    #[test]
832    fn test_body_static() {
833        let b = Body::Static(b"hello");
834        assert_eq!(b.len(), 5);
835        assert!(!b.is_empty());
836        assert_eq!(b.as_bytes(), b"hello");
837    }
838
839    #[test]
840    fn test_body_bytes() {
841        let v = b"world".to_vec();
842        let b = Body::Bytes(v.clone());
843        assert_eq!(b.len(), 5);
844        assert_eq!(b.as_bytes(), b"world");
845    }
846
847    #[test]
848    fn test_body_stream_len_is_zero() {
849        let b = Body::Stream(Box::new(std::iter::empty()));
850        assert_eq!(b.len(), 0);
851        assert!(b.is_empty());
852        assert!(!b.is_file());
853    }
854}