//! Incremental HTTP/1.1 request parser. //! //! Adapted from `.dev/reference/crates/wo-http/src/request.rs`. v1 only parsed //! request headers (the v1 blog is read-only HTML). The phase-04 cutover //! needs JSON request bodies, so this parser also drains a //! `Content-Length`-delimited body. Chunked transfer encoding is not //! supported (no sample sends one — see `docs/plan/03-hand-rolled-http.md`). use std::collections::HashMap; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Method { Get, Post, Patch, Put, Delete, Head, Options, Other, } impl Method { fn parse(token: &str) -> Method { match token { "GET" => Method::Get, "POST" => Method::Post, "PATCH" => Method::Patch, "PUT" => Method::Put, "DELETE" => Method::Delete, "HEAD" => Method::Head, "OPTIONS" => Method::Options, _ => Method::Other, } } } #[derive(Debug, Clone)] pub struct Request { pub method: Method, pub path: String, pub query: Option, pub headers: HashMap, pub body: Vec, /// HTTP/1.1 defaults to keep-alive unless `Connection: close`; /// HTTP/1.0 defaults to close unless `Connection: keep-alive`. pub keep_alive: bool, } pub enum ParseResult { /// A full request plus the number of bytes it consumed — the caller /// trims its buffer so a pipelined follow-up request survives. Complete(Request, usize), Incomplete, Error(String), } const MAX_HEADER_BYTES: usize = 8 * 1024; const MAX_BODY_BYTES: usize = 16 * 1024 * 1024; /// Try to parse a complete HTTP/1.1 request out of `buf`. /// Returns `Complete(req, bytes_consumed)` once headers + body are present. pub fn parse(buf: &[u8]) -> ParseResult { let header_end = match find_header_end(buf) { Some(p) => p, None => { if buf.len() > MAX_HEADER_BYTES { return ParseResult::Error("request headers too large".into()); } return ParseResult::Incomplete; } }; let header_str = match std::str::from_utf8(&buf[..header_end]) { Ok(s) => s, Err(_) => return ParseResult::Error("non-UTF-8 in headers".into()), }; let mut lines = header_str.lines(); let request_line = match lines.next() { Some(l) => l, None => return ParseResult::Error("empty request".into()), }; let mut parts = request_line.split_whitespace(); let method = match parts.next() { Some(t) => Method::parse(t), None => return ParseResult::Error("missing method".into()), }; let raw_path = match parts.next() { Some(p) => p, None => return ParseResult::Error("missing path".into()), }; let http10 = parts.next() == Some("HTTP/1.0"); let (path, query) = match raw_path.split_once('?') { Some((p, q)) => (p.to_string(), Some(q.to_string())), None => (raw_path.to_string(), None), }; let mut headers = HashMap::new(); for line in lines { if line.is_empty() { break; } if let Some((k, v)) = line.split_once(':') { headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); } } let body_len: usize = headers .get("content-length") .and_then(|v| v.parse().ok()) .unwrap_or(0); if body_len > MAX_BODY_BYTES { return ParseResult::Error("Content-Length exceeds limit".into()); } let header_bytes = header_end + 4; // include the trailing \r\n\r\n let total = header_bytes + body_len; if buf.len() < total { return ParseResult::Incomplete; } let body = buf[header_bytes..total].to_vec(); let conn_hdr = headers.get("connection").map(|v| v.to_ascii_lowercase()); let keep_alive = if http10 { conn_hdr.as_deref() == Some("keep-alive") } else { conn_hdr.as_deref() != Some("close") }; ParseResult::Complete(Request { method, path, query, headers, body, keep_alive }, total) } fn find_header_end(buf: &[u8]) -> Option { buf.windows(4).position(|w| w == b"\r\n\r\n") } #[cfg(test)] mod tests { use super::*; #[test] fn parses_simple_get() { let raw = b"GET /api/articles HTTP/1.1\r\nHost: localhost\r\n\r\n"; let ParseResult::Complete(req, _) = parse(raw) else { panic!("expected Complete") }; assert_eq!(req.method, Method::Get); assert_eq!(req.path, "/api/articles"); assert!(req.query.is_none()); assert!(req.body.is_empty()); } #[test] fn parses_query_string() { let raw = b"GET /tag/rust?page=2 HTTP/1.1\r\n\r\n"; let ParseResult::Complete(req, _) = parse(raw) else { panic!() }; assert_eq!(req.path, "/tag/rust"); assert_eq!(req.query.as_deref(), Some("page=2")); } #[test] fn parses_post_with_body() { let body = b"{\"title\":\"hi\"}"; let mut raw = Vec::new(); raw.extend_from_slice(b"POST /api/articles HTTP/1.1\r\n"); raw.extend_from_slice(b"Host: localhost\r\n"); raw.extend_from_slice(b"Content-Type: application/json\r\n"); raw.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes()); raw.extend_from_slice(b"\r\n"); raw.extend_from_slice(body); let ParseResult::Complete(req, consumed) = parse(&raw) else { panic!() }; assert_eq!(consumed, raw.len()); assert_eq!(req.method, Method::Post); assert_eq!(req.body, body); } #[test] fn incomplete_when_body_truncated() { let raw = b"POST / HTTP/1.1\r\nContent-Length: 10\r\n\r\nshort"; assert!(matches!(parse(raw), ParseResult::Incomplete)); } #[test] fn incomplete_when_headers_truncated() { let raw = b"GET / HTTP/1.1\r\nHost: local"; assert!(matches!(parse(raw), ParseResult::Incomplete)); } #[test] fn parses_patch_method() { let raw = b"PATCH /api/articles/1 HTTP/1.1\r\nContent-Length: 0\r\n\r\n"; let ParseResult::Complete(req, _) = parse(raw) else { panic!() }; assert_eq!(req.method, Method::Patch); } }