Skip to main content

rustio_core/
http.rs

1//! HTTP primitives: [`Request`], [`Response`], response builders, and a small
2//! [`FormData`] parser shared by query strings and form bodies.
3//!
4//! [`Request`] wraps [`hyper::Request`] and adds a typed per-request
5//! [`Context`]. It derefs to the underlying hyper request so the usual
6//! accessors (`.method()`, `.uri()`, `.headers()`, `.body_mut()`) are
7//! available directly.
8
9use std::collections::HashMap;
10use std::ops::{Deref, DerefMut};
11
12use bytes::Bytes;
13use http_body_util::Full;
14
15use crate::context::Context;
16
17pub type Response = hyper::Response<Full<Bytes>>;
18
19/// Incoming HTTP request with an attached per-request [`Context`].
20pub struct Request {
21    inner: hyper::Request<hyper::body::Incoming>,
22    ctx: Context,
23}
24
25impl Request {
26    pub(crate) fn new(inner: hyper::Request<hyper::body::Incoming>) -> Self {
27        Self {
28            inner,
29            ctx: Context::new(),
30        }
31    }
32
33    /// Read-only access to the per-request [`Context`].
34    pub fn ctx(&self) -> &Context {
35        &self.ctx
36    }
37
38    /// Mutable access to the per-request [`Context`].
39    pub fn ctx_mut(&mut self) -> &mut Context {
40        &mut self.ctx
41    }
42
43    /// Parse the URL query string into a [`FormData`].
44    ///
45    /// Returns an empty `FormData` when the request has no query string.
46    pub fn query(&self) -> FormData {
47        FormData::parse(self.inner.uri().query().unwrap_or(""))
48    }
49
50    /// Look up a single cookie value by name.
51    ///
52    /// Returns `None` if the request has no `Cookie` header, the header is
53    /// not valid UTF-8, or no cookie with this name is present. The value
54    /// is returned as-is (not URL-decoded).
55    pub fn cookie(&self, name: &str) -> Option<String> {
56        let header = self
57            .inner
58            .headers()
59            .get(hyper::header::COOKIE)?
60            .to_str()
61            .ok()?;
62        for pair in header.split(';') {
63            let pair = pair.trim();
64            if let Some((k, v)) = pair.split_once('=') {
65                if k == name {
66                    return Some(v.to_string());
67                }
68            }
69        }
70        None
71    }
72
73    /// Consume this request, returning the underlying hyper parts, body,
74    /// and the attached context.
75    pub fn into_parts(self) -> (hyper::http::request::Parts, hyper::body::Incoming, Context) {
76        let (parts, body) = self.inner.into_parts();
77        (parts, body, self.ctx)
78    }
79}
80
81impl Deref for Request {
82    type Target = hyper::Request<hyper::body::Incoming>;
83    fn deref(&self) -> &Self::Target {
84        &self.inner
85    }
86}
87
88impl DerefMut for Request {
89    fn deref_mut(&mut self) -> &mut Self::Target {
90        &mut self.inner
91    }
92}
93
94/// Build a `200 OK` response with `text/plain` content type.
95pub fn text(body: impl Into<String>) -> Response {
96    response(200, "text/plain; charset=utf-8", body.into().into_bytes())
97}
98
99/// Build a `200 OK` response with `text/html` content type.
100pub fn html(body: impl Into<String>) -> Response {
101    response(200, "text/html; charset=utf-8", body.into().into_bytes())
102}
103
104/// Build a `200 OK` response with `application/json` content type.
105///
106/// The body is written verbatim; it is the caller's responsibility to pass
107/// a valid JSON document (e.g. from `serde_json::to_string(&value)?`).
108pub fn json_raw(body: impl Into<String>) -> Response {
109    response(
110        200,
111        "application/json; charset=utf-8",
112        body.into().into_bytes(),
113    )
114}
115
116/// Build a response with an arbitrary status code and a `text/plain` body.
117pub fn status_text(status: u16, body: impl Into<String>) -> Response {
118    response(
119        status,
120        "text/plain; charset=utf-8",
121        body.into().into_bytes(),
122    )
123}
124
125/// Append a `Set-Cookie` header to a response.
126///
127/// The caller is responsible for formatting `value` as a valid
128/// `Set-Cookie` string (e.g. `"name=val; Path=/; HttpOnly; SameSite=Lax"`).
129/// Returns silently if `value` contains characters that aren't valid in
130/// an HTTP header.
131pub fn set_cookie(resp: &mut Response, value: &str) {
132    if let Ok(hv) = value.parse() {
133        resp.headers_mut().append(hyper::header::SET_COOKIE, hv);
134    }
135}
136
137fn response(status: u16, content_type: &'static str, body: Vec<u8>) -> Response {
138    hyper::Response::builder()
139        .status(status)
140        .header("content-type", content_type)
141        .body(Full::new(Bytes::from(body)))
142        .expect("valid response")
143}
144
145/// Parsed `application/x-www-form-urlencoded` data.
146///
147/// Used for both URL query strings (via [`Request::query`]) and POST
148/// request bodies (the admin layer reads form submissions this way).
149pub struct FormData {
150    map: HashMap<String, String>,
151}
152
153impl FormData {
154    /// Parse a URL-encoded key/value string.
155    pub fn parse(body: &str) -> Self {
156        let mut map = HashMap::new();
157        for pair in body.split('&') {
158            if pair.is_empty() {
159                continue;
160            }
161            let mut iter = pair.splitn(2, '=');
162            let raw_key = match iter.next() {
163                Some(k) if !k.is_empty() => k,
164                _ => continue,
165            };
166            let raw_val = iter.next().unwrap_or("");
167            map.insert(percent_decode(raw_key), percent_decode(raw_val));
168        }
169        FormData { map }
170    }
171
172    pub fn get(&self, key: &str) -> Option<&str> {
173        self.map.get(key).map(String::as_str)
174    }
175
176    pub fn len(&self) -> usize {
177        self.map.len()
178    }
179
180    pub fn is_empty(&self) -> bool {
181        self.map.is_empty()
182    }
183}
184
185pub(crate) fn percent_decode(input: &str) -> String {
186    let bytes = input.as_bytes();
187    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
188    let mut i = 0;
189    while i < bytes.len() {
190        let b = bytes[i];
191        if b == b'+' {
192            out.push(b' ');
193            i += 1;
194        } else if b == b'%' && i + 2 < bytes.len() {
195            if let (Some(h), Some(l)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
196                out.push((h << 4) | l);
197                i += 3;
198                continue;
199            }
200            out.push(b);
201            i += 1;
202        } else {
203            out.push(b);
204            i += 1;
205        }
206    }
207    String::from_utf8_lossy(&out).into_owned()
208}
209
210fn hex_digit(b: u8) -> Option<u8> {
211    match b {
212        b'0'..=b'9' => Some(b - b'0'),
213        b'a'..=b'f' => Some(b - b'a' + 10),
214        b'A'..=b'F' => Some(b - b'A' + 10),
215        _ => None,
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn form_parse_decodes_basic_pairs() {
225        let form = FormData::parse("a=1&b=2");
226        assert_eq!(form.get("a"), Some("1"));
227        assert_eq!(form.get("b"), Some("2"));
228    }
229
230    #[test]
231    fn form_parse_decodes_plus_as_space() {
232        let form = FormData::parse("name=John+Doe");
233        assert_eq!(form.get("name"), Some("John Doe"));
234    }
235
236    #[test]
237    fn form_parse_decodes_percent_encoded() {
238        let form = FormData::parse("q=hello%20world%21");
239        assert_eq!(form.get("q"), Some("hello world!"));
240    }
241
242    #[test]
243    fn form_parse_handles_empty_values() {
244        let form = FormData::parse("a=&b=x");
245        assert_eq!(form.get("a"), Some(""));
246        assert_eq!(form.get("b"), Some("x"));
247    }
248
249    #[test]
250    fn form_parse_ignores_empty_pairs() {
251        let form = FormData::parse("&a=1&&b=2&");
252        assert_eq!(form.get("a"), Some("1"));
253        assert_eq!(form.get("b"), Some("2"));
254        assert_eq!(form.len(), 2);
255    }
256
257    #[test]
258    fn form_missing_key_is_none() {
259        let form = FormData::parse("a=1");
260        assert!(form.get("missing").is_none());
261    }
262
263    #[test]
264    fn percent_decode_passes_through_unreserved() {
265        assert_eq!(percent_decode("abcXYZ123-_.~"), "abcXYZ123-_.~");
266    }
267
268    #[test]
269    fn percent_decode_handles_lowercase_and_uppercase_hex() {
270        assert_eq!(percent_decode("%2f%2F"), "//");
271    }
272
273    #[test]
274    fn percent_decode_leaves_invalid_percent_sequences_alone() {
275        assert_eq!(percent_decode("%GG"), "%GG");
276        assert_eq!(percent_decode("end%"), "end%");
277    }
278}