Skip to main content

io_http/rfc9110/
response.rs

1//! HTTP response type ([RFC 9110 §15]).
2//!
3//! [RFC 9110 §15]: https://www.rfc-editor.org/rfc/rfc9110#section-15
4
5use core::{fmt, str};
6
7use alloc::{borrow::ToOwned, format, string::String, vec::Vec};
8
9use crate::rfc9110::{headers::HTTP_SENSITIVE_HEADERS, status::HttpStatusCode};
10
11/// An incoming HTTP response. Header names are stored lowercase.
12#[derive(Clone)]
13pub struct HttpResponse {
14    /// The response status code.
15    pub status: HttpStatusCode,
16    /// The protocol version token found on the status line.
17    pub version: String,
18    /// The response headers, in order, names lowercased.
19    pub headers: Vec<(String, String)>,
20    /// The response body, decoded when chunked.
21    pub body: Vec<u8>,
22}
23
24impl HttpResponse {
25    /// Returns the first header matching `name` (case-insensitive).
26    pub fn header(&self, name: &str) -> Option<&str> {
27        self.headers
28            .iter()
29            .find(|(k, _)| k.eq_ignore_ascii_case(name))
30            .map(|(_, v)| v.as_str())
31    }
32}
33
34impl fmt::Debug for HttpResponse {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        let headers: Vec<(&str, &str)> = self
37            .headers
38            .iter()
39            .map(|(k, v)| {
40                let sensitive = HTTP_SENSITIVE_HEADERS
41                    .iter()
42                    .any(|s| k.eq_ignore_ascii_case(s));
43                let v = if sensitive { "[REDACTED]" } else { v.as_str() };
44                (k.as_str(), v)
45            })
46            .collect();
47
48        f.debug_struct("HttpResponse")
49            .field("status", &self.status)
50            .field("version", &self.version)
51            .field("headers", &headers)
52            .field(
53                "body",
54                &match str::from_utf8(&self.body) {
55                    Ok(body) => body.to_owned(),
56                    Err(_) => format!("[{} BYTES]", self.body.len()),
57                },
58            )
59            .finish()
60    }
61}
62
63/// Incremental builder for [`HttpResponse`].
64#[derive(Clone, Debug)]
65pub(crate) struct HttpResponseBuilder {
66    pub(crate) status: Option<HttpStatusCode>,
67    pub(crate) version: String,
68    pub(crate) headers: Vec<(String, String)>,
69}
70
71impl Default for HttpResponseBuilder {
72    fn default() -> Self {
73        Self {
74            status: None,
75            version: "HTTP/1.1".into(),
76            headers: Vec::new(),
77        }
78    }
79}
80
81impl HttpResponseBuilder {
82    pub(crate) fn header(&mut self, name: &str, value: &[u8]) {
83        let value = String::from_utf8_lossy(value).into_owned();
84        self.headers.push((name.to_lowercase(), value));
85    }
86
87    pub(crate) fn get_header(&self, name: &str) -> Option<&str> {
88        self.headers
89            .iter()
90            .find(|(k, _)| k.eq_ignore_ascii_case(name))
91            .map(|(_, v)| v.as_str())
92    }
93
94    pub(crate) fn build(self, body: Vec<u8>) -> HttpResponse {
95        HttpResponse {
96            status: self.status.unwrap_or(HttpStatusCode(200)),
97            version: self.version,
98            headers: self.headers,
99            body,
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use alloc::vec;
107
108    use crate::rfc9110::response::*;
109
110    #[test]
111    fn header_case_insensitive() {
112        let response = HttpResponse {
113            status: HttpStatusCode(200),
114            version: String::new(),
115            headers: vec![("Content-Type".into(), "text/html".into())],
116            body: vec![],
117        };
118        assert_eq!(response.header("content-type"), Some("text/html"));
119        assert_eq!(response.header("CONTENT-TYPE"), Some("text/html"));
120        assert_eq!(response.header("Content-Type"), Some("text/html"));
121    }
122
123    #[test]
124    fn header_missing_returns_none() {
125        let response = HttpResponse {
126            status: HttpStatusCode(200),
127            version: String::new(),
128            headers: vec![],
129            body: vec![],
130        };
131        assert_eq!(response.header("x-missing"), None);
132    }
133
134    #[test]
135    fn header_returns_first_match() {
136        let response = HttpResponse {
137            status: HttpStatusCode(200),
138            version: String::new(),
139            headers: vec![
140                ("X-Foo".into(), "first".into()),
141                ("x-foo".into(), "second".into()),
142            ],
143            body: vec![],
144        };
145        assert_eq!(response.header("x-foo"), Some("first"));
146    }
147
148    #[test]
149    fn builder_stores_headers_lowercase() {
150        let mut builder = HttpResponseBuilder::default();
151        builder.header("Content-Type", b"text/plain");
152        assert_eq!(builder.headers[0].0, "content-type");
153    }
154
155    #[test]
156    fn builder_get_header_case_insensitive() {
157        let mut builder = HttpResponseBuilder::default();
158        builder.header("Content-Type", b"text/html");
159        assert_eq!(builder.get_header("Content-Type"), Some("text/html"));
160        assert_eq!(builder.get_header("content-type"), Some("text/html"));
161        assert_eq!(builder.get_header("CONTENT-TYPE"), Some("text/html"));
162    }
163
164    #[test]
165    fn builder_build_defaults_to_200() {
166        let response = HttpResponseBuilder::default().build(vec![]);
167        assert_eq!(*response.status, 200);
168    }
169
170    #[test]
171    fn builder_default_version_is_http11() {
172        let response = HttpResponseBuilder::default().build(vec![]);
173        assert_eq!(response.version, "HTTP/1.1");
174    }
175
176    #[test]
177    fn builder_build_transfers_fields() {
178        let mut builder = HttpResponseBuilder {
179            status: Some(HttpStatusCode(404)),
180            ..Default::default()
181        };
182        builder.header("X-Custom", b"value");
183        let response = builder.build(b"not found".to_vec());
184        assert_eq!(*response.status, 404);
185        assert_eq!(response.header("x-custom"), Some("value"));
186        assert_eq!(response.body, b"not found");
187    }
188}