AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/http/src/request.rs

Latest change 1f2c9f9983d36d42b1f177ad7bcea92a8fe8dc4d - feat: built-in server access log, default on by Ólafur Búi Ólafsson

//! Parsing the HTTP/1.1 request head: request line + headers.
//!
//! Scope is deliberately narrow — this module turns the bytes *up to and
//! including* the blank line that ends the headers into a [`Request`]. Body
//! reading, streaming, and limits live elsewhere; keeping the parser pure
//! (bytes in, value out, no I/O) makes it trivially testable.

use std::fmt;
use std::net::SocketAddr;

/// An HTTP request method. Common verbs are named; anything else is preserved
/// verbatim in [`Method::Other`] so we never reject a valid-but-unusual method.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Method {
    Get,
    Post,
    Put,
    Patch,
    Delete,
    Head,
    Options,
    Other(String),
}

/// The canonical wire spelling of the method (`GET`, `POST`, …); `Other`
/// prints verbatim. Used by the router, the access log, and error messages.
impl fmt::Display for Method {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Method::Get => "GET",
            Method::Post => "POST",
            Method::Put => "PUT",
            Method::Patch => "PATCH",
            Method::Delete => "DELETE",
            Method::Head => "HEAD",
            Method::Options => "OPTIONS",
            Method::Other(s) => s.as_str(),
        })
    }
}

impl Method {
    fn parse(token: &str) -> Method {
        match token {
            "GET" => Method::Get,
            "POST" => Method::Post,
            "PUT" => Method::Put,
            "PATCH" => Method::Patch,
            "DELETE" => Method::Delete,
            "HEAD" => Method::Head,
            "OPTIONS" => Method::Options,
            other => Method::Other(other.to_string()),
        }
    }
}

/// A parsed request head.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request {
    pub method: Method,
    /// Path portion of the target, percent-encoding left intact (decoding is a
    /// later concern for the router).
    pub path: String,
    /// Raw query string after `?`, if present (without the `?`).
    pub query: Option<String>,
    /// e.g. `"HTTP/1.1"`.
    pub version: String,
    /// Header name/value pairs, in wire order. Names are stored as received;
    /// use [`Request::header`] for case-insensitive lookup.
    pub headers: Vec<(String, String)>,
    /// The request body. Empty for `GET` and for any request without a
    /// `Content-Length`; the server reads it after the head and fills it in.
    pub body: Vec<u8>,
    /// The peer (client) socket address. The server stamps this after
    /// accepting the connection; it is `None` for heads parsed outside a live
    /// connection (tests, tools, replays).
    pub peer: Option<SocketAddr>,
}

impl Request {
    /// Case-insensitive header lookup, returning the first match.
    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case(name))
            .map(|(_, v)| v.as_str())
    }

    /// The declared body length from `Content-Length`, if present and valid.
    pub fn content_length(&self) -> Option<usize> {
        self.header("Content-Length")?.trim().parse().ok()
    }

    /// The body as text (lossy UTF-8) — convenient for form and JSON bodies.
    pub fn body_str(&self) -> std::borrow::Cow<'_, str> {
        String::from_utf8_lossy(&self.body)
    }

    /// Parse a request head from raw bytes (request line + headers, terminated
    /// by a blank line). Bytes must be valid UTF-8 for now; binary header
    /// values are out of scope until we need them.
    pub fn parse_head(bytes: &[u8]) -> Result<Request, ParseError> {
        let text = std::str::from_utf8(bytes).map_err(|_| ParseError::NotUtf8)?;

        // Split into lines on CRLF (tolerating bare LF), dropping the trailing
        // blank line that ends the head.
        let mut lines = text.split("\r\n").flat_map(|l| l.split('\n'));

        let request_line = lines
            .next()
            .filter(|l| !l.is_empty())
            .ok_or(ParseError::Empty)?;
        let (method, path, query, version) = parse_request_line(request_line)?;

        let mut headers = Vec::new();
        for line in lines {
            if line.is_empty() {
                break; // blank line ends the head
            }
            let (name, value) = line.split_once(':').ok_or(ParseError::MalformedHeader)?;
            let name = name.trim();
            if name.is_empty() {
                return Err(ParseError::MalformedHeader);
            }
            headers.push((name.to_string(), value.trim().to_string()));
        }

        Ok(Request {
            method,
            path,
            query,
            version,
            headers,
            body: Vec::new(),
            peer: None,
        })
    }
}

fn parse_request_line(line: &str) -> Result<(Method, String, Option<String>, String), ParseError> {
    let mut parts = line.splitn(3, ' ');
    let method = parts
        .next()
        .filter(|s| !s.is_empty())
        .ok_or(ParseError::MalformedRequestLine)?;
    let target = parts
        .next()
        .filter(|s| !s.is_empty())
        .ok_or(ParseError::MalformedRequestLine)?;
    let version = parts
        .next()
        .filter(|s| !s.is_empty())
        .ok_or(ParseError::MalformedRequestLine)?;

    if !version.starts_with("HTTP/") {
        return Err(ParseError::MalformedRequestLine);
    }

    let (path, query) = match target.split_once('?') {
        Some((p, q)) => (p.to_string(), Some(q.to_string())),
        None => (target.to_string(), None),
    };

    Ok((Method::parse(method), path, query, version.to_string()))
}

/// Why a request head failed to parse.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
    Empty,
    NotUtf8,
    MalformedRequestLine,
    MalformedHeader,
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let msg = match self {
            ParseError::Empty => "empty request",
            ParseError::NotUtf8 => "request head was not valid UTF-8",
            ParseError::MalformedRequestLine => "malformed request line",
            ParseError::MalformedHeader => "malformed header line",
        };
        f.write_str(msg)
    }
}

impl std::error::Error for ParseError {}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse(s: &str) -> Result<Request, ParseError> {
        Request::parse_head(s.as_bytes())
    }

    #[test]
    fn parses_a_simple_get() {
        let req = parse("GET /hello HTTP/1.1\r\nHost: example.com\r\n\r\n").unwrap();
        assert_eq!(req.method, Method::Get);
        assert_eq!(req.path, "/hello");
        assert_eq!(req.query, None);
        assert_eq!(req.version, "HTTP/1.1");
        assert_eq!(req.header("host"), Some("example.com"));
    }

    #[test]
    fn splits_path_and_query() {
        let req = parse("GET /search?q=rust&page=2 HTTP/1.1\r\n\r\n").unwrap();
        assert_eq!(req.path, "/search");
        assert_eq!(req.query.as_deref(), Some("q=rust&page=2"));
    }

    #[test]
    fn header_lookup_is_case_insensitive() {
        let req = parse("GET / HTTP/1.1\r\nContent-Type: application/json\r\n\r\n").unwrap();
        assert_eq!(req.header("CONTENT-TYPE"), Some("application/json"));
        assert_eq!(req.header("content-type"), Some("application/json"));
        assert_eq!(req.header("missing"), None);
    }

    #[test]
    fn trims_header_whitespace() {
        let req = parse("GET / HTTP/1.1\r\nX-Trim:    spaced   \r\n\r\n").unwrap();
        assert_eq!(req.header("x-trim"), Some("spaced"));
    }

    #[test]
    fn preserves_unknown_methods() {
        let req = parse("PROPFIND / HTTP/1.1\r\n\r\n").unwrap();
        assert_eq!(req.method, Method::Other("PROPFIND".to_string()));
    }

    #[test]
    fn recognizes_common_verbs() {
        for (s, m) in [
            ("POST", Method::Post),
            ("PUT", Method::Put),
            ("PATCH", Method::Patch),
            ("DELETE", Method::Delete),
        ] {
            let req = parse(&format!("{s} / HTTP/1.1\r\n\r\n")).unwrap();
            assert_eq!(req.method, m);
        }
    }

    #[test]
    fn tolerates_bare_lf_line_endings() {
        let req = parse("GET / HTTP/1.1\nHost: x\n\n").unwrap();
        assert_eq!(req.header("host"), Some("x"));
    }

    #[test]
    fn empty_input_is_an_error() {
        assert_eq!(parse(""), Err(ParseError::Empty));
        assert_eq!(parse("\r\n"), Err(ParseError::Empty));
    }

    #[test]
    fn request_line_needs_three_parts() {
        assert_eq!(
            parse("GET /\r\n\r\n"),
            Err(ParseError::MalformedRequestLine)
        );
        assert_eq!(parse("GET\r\n\r\n"), Err(ParseError::MalformedRequestLine));
    }

    #[test]
    fn rejects_non_http_version() {
        assert_eq!(
            parse("GET / FTP/1.1\r\n\r\n"),
            Err(ParseError::MalformedRequestLine)
        );
    }

    #[test]
    fn rejects_header_without_colon() {
        assert_eq!(
            parse("GET / HTTP/1.1\r\nBadHeader\r\n\r\n"),
            Err(ParseError::MalformedHeader)
        );
    }

    #[test]
    fn rejects_non_utf8() {
        assert_eq!(Request::parse_head(&[0xff, 0xfe]), Err(ParseError::NotUtf8));
    }
}