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