Chapter 06
Parsing HTTP by Hand
Turn raw bytes into structured data. We'll dissect the HTTP protocol, parse requests into our types from Chapter 4, and build responses that browsers actually understand.
The Anatomy of an HTTP Request
In Chapter 5 we saw the raw text that arrives on a TCP connection. Let's formalize it. Every HTTP/1.1 request has this structure:
METHOD PATH HTTP/1.1\r\n
Header-Name: Header-Value\r\n
Header-Name: Header-Value\r\n
\r\n
optional body bytesConcretely, a GET request from curl looks like:
GET /index.html HTTP/1.1\r\n
Host: 127.0.0.1:8080\r\n
User-Agent: curl/8.7.1\r\n
Accept: */*\r\n
\r\nFour parts, always in this order:
- Request line - the method (
GET), the path (/index.html), and the protocol version. Always exactly one line. - Headers - key-value pairs, one per line, separated by
:. There can be zero or many. - Blank line - a
\r\nwith nothing before it. This signals "headers are done." - Body - optional. Present on POST/PUT requests. The
Content-Lengthheader says how many bytes to read.
\r\n (CRLF), not just \n. This is mandated by the HTTP spec. When you parse, always split on \r\n. When you generate, always write \r\n.The Anatomy of an HTTP Response
A response follows the same structure, with a status line instead of a request line:
HTTP/1.1 STATUS_CODE REASON_PHRASE\r\n
Header-Name: Header-Value\r\n
Header-Name: Header-Value\r\n
\r\n
body bytesFor example:
HTTP/1.1 200 OK\r\n
Content-Type: text/html\r\n
Content-Length: 45\r\n
Connection: close\r\n
\r\n
<html><body><h1>Hello!</h1></body></html>The status code is a three-digit number. The reason phrase is human-readable and technically optional (but clients expect it). Common pairs: 200 OK, 404 Not Found, 500 Internal Server Error.
Parsing the Request Line from Raw Bytes
Let's write a parser. We'll start with the types from Chapter 4 and add a parse function. The strategy is simple: split the raw text on \r\n to get lines, parse the first line as the request line, then parse the remaining lines as headers.
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
// --- Types ---
#[derive(Debug)]
enum Method {
Get,
Post,
Put,
Delete,
Unknown(String),
}
impl Method {
fn from_str(s: &str) -> Method {
match s {
"GET" => Method::Get,
"POST" => Method::Post,
"PUT" => Method::Put,
"DELETE" => Method::Delete,
other => Method::Unknown(other.to_string()),
}
}
}
#[derive(Debug)]
struct Request {
method: Method,
path: String,
headers: Vec<(String, String)>,
body: Option<String>,
}Now the parsing function. It takes the raw text and returns an Option<Request> - None if the input is malformed:
impl Request {
fn parse(raw: &str) -> Option<Request> {
// Split into lines on \r\n
let mut lines = raw.split("\r\n");
// First line: "GET /path HTTP/1.1"
let request_line = lines.next()?;
let mut parts = request_line.split_whitespace();
let method = Method::from_str(parts.next()?);
let path = parts.next()?.to_string();
// We ignore the HTTP version for now
// Parse headers until we hit an empty line
let mut headers = Vec::new();
let mut body_start = false;
for line in lines {
if line.is_empty() {
body_start = true;
break;
}
// Split "Key: Value" on the first ':'
if let Some((key, value)) = line.split_once(':') {
headers.push((
key.trim().to_string(),
value.trim().to_string(),
));
}
}
// Everything after the blank line is the body
let body = if body_start {
let remaining: String = raw
.split("\r\n\r\n")
.nth(1)
.unwrap_or("")
.to_string();
if remaining.is_empty() { None } else { Some(remaining) }
} else {
None
};
Some(Request { method, path, headers, body })
}
/// Look up a header value by name (case-insensitive).
fn get_header(&self, name: &str) -> Option<&str> {
for (key, value) in &self.headers {
if key.eq_ignore_ascii_case(name) {
return Some(value);
}
}
None
}
}Let's trace through the key techniques used here:
raw.split("\r\n")returns an iterator over lines. We call.next()to pull the first line, then iterate the rest.request_line.split_whitespace()splits"GET /path HTTP/1.1"into three pieces.- The
?operator onOption- when we writeparts.next()?, if there's no next element, the entire function returnsNone. This is early return forOption, just like the?forResultwe'll cover in Chapter 8. line.split_once(':')splits on the first colon only, returningOption<(&str, &str)>. This handles header values that contain colons (likeHost: localhost:8080).raw.split("\r\n\r\n").nth(1)grabs everything after the blank line - the body.
? operator is one of the most useful features in Rust. Inside a function that returns Option<T>, writing expr? is equivalent to match expr { Some(v) => v, None => return None }. It's concise error/absence propagation without nesting.Building Response Structs
Now let's formalize the response side. Instead of building a raw string every time, we'll use a struct with methods that serialize to the HTTP wire format:
struct Response {
status: u16,
reason: String,
headers: Vec<(String, String)>,
body: String,
}
impl Response {
fn new(status: u16, reason: &str, body: &str) -> Self {
Response {
status,
reason: reason.to_string(),
headers: vec![],
body: body.to_string(),
}
}
fn ok(body: &str) -> Self {
Self::new(200, "OK", body)
}
fn not_found() -> Self {
Self::new(404, "Not Found", "<h1>404 - Not Found</h1>")
}
fn bad_request(msg: &str) -> Self {
Self::new(400, "Bad Request", msg)
}
fn add_header(&mut self, name: &str, value: &str) {
self.headers.push((name.to_string(), value.to_string()));
}
/// Serialize to the HTTP wire format.
fn to_bytes(&self) -> Vec<u8> {
let mut output = format!(
"HTTP/1.1 {} {}\r\n",
self.status, self.reason
);
// Add Content-Length automatically
output.push_str(
&format!("Content-Length: {}\r\n", self.body.len())
);
// Add Connection: close so clients don't hang
output.push_str("Connection: close\r\n");
// Add custom headers
for (name, value) in &self.headers {
output.push_str(&format!("{}: {}\r\n", name, value));
}
// Blank line, then body
output.push_str("\r\n");
output.push_str(&self.body);
output.into_bytes()
}
}The to_bytes method produces the exact byte sequence that goes on the wire. It adds Content-Length and Connection: close automatically so callers don't forget them. The method returns Vec<u8> - owned bytes ready to write to a TcpStream.
Serving a Static HTML Page
Now we can wire everything together: parse the request, route based on method and path, and send a proper response. Let's serve a real HTML page with a small router:
/// Route a parsed request to a response.
fn handle_request(request: &Request) -> Response {
match (&request.method, request.path.as_str()) {
(Method::Get, "/") | (Method::Get, "/index.html") => {
let mut resp = Response::ok(HOME_PAGE);
resp.add_header("Content-Type", "text/html");
resp
}
(Method::Get, "/about") => {
let mut resp = Response::ok(ABOUT_PAGE);
resp.add_header("Content-Type", "text/html");
resp
}
(Method::Get, _) => {
let mut resp = Response::not_found();
resp.add_header("Content-Type", "text/html");
resp
}
_ => Response::new(405, "Method Not Allowed", "Method not supported"),
}
}
const HOME_PAGE: &str = "\
<!DOCTYPE html>
<html>
<head>
<meta charset=\"utf-8\">
<title>Rust Webserver</title>
<style>
body { font-family: system-ui; max-width: 600px; margin: 40px auto; padding: 0 20px; }
h1 { color: #dea584; }
a { color: #4f8fea; }
</style>
</head>
<body>
<h1>Hello from Rust!</h1>
<p>This page was served by a webserver we built from scratch.</p>
<p><a href=\"/about\">About this server</a></p>
</body>
</html>";
const ABOUT_PAGE: &str = "\
<!DOCTYPE html>
<html>
<head>
<meta charset=\"utf-8\">
<title>About - Rust Webserver</title>
<style>
body { font-family: system-ui; max-width: 600px; margin: 40px auto; padding: 0 20px; }
h1 { color: #dea584; }
a { color: #4f8fea; }
</style>
</head>
<body>
<h1>About</h1>
<p>A simple HTTP server written in Rust, handling raw TCP connections.</p>
<p><a href=\"/\">Back home</a></p>
</body>
</html>";A few things to note about the router:
- We match on a tuple of
(&method, path). This is a powerful pattern - Rust lets you destructure tuples insidematcharms. (Method::Get, "/") | (Method::Get, "/index.html")handles two paths with one arm using the|operator.- The HTML pages are
conststring literals (&'static str). They're baked into the binary at compile time - no file reads at runtime. We'll serve files from disk in Chapter 15.
Putting It Together
Here's the complete server with parsing, routing, and proper responses. This is a meaningful milestone - a real webserver that understands HTTP:
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
// --- Method enum ---
#[derive(Debug)]
enum Method {
Get,
Post,
Unknown(String),
}
impl Method {
fn from_str(s: &str) -> Method {
match s {
"GET" => Method::Get,
"POST" => Method::Post,
other => Method::Unknown(other.to_string()),
}
}
}
// --- Request ---
#[derive(Debug)]
struct Request {
method: Method,
path: String,
headers: Vec<(String, String)>,
}
impl Request {
fn parse(raw: &str) -> Option<Request> {
let mut lines = raw.split("\r\n");
let request_line = lines.next()?;
let mut parts = request_line.split_whitespace();
let method = Method::from_str(parts.next()?);
let path = parts.next()?.to_string();
let mut headers = Vec::new();
for line in lines {
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once(':') {
headers.push((
key.trim().to_string(),
value.trim().to_string(),
));
}
}
Some(Request { method, path, headers })
}
}
// --- Response ---
struct Response {
status: u16,
reason: String,
headers: Vec<(String, String)>,
body: String,
}
impl Response {
fn new(status: u16, reason: &str, body: &str) -> Self {
Response {
status,
reason: reason.to_string(),
headers: vec![],
body: body.to_string(),
}
}
fn ok(body: &str) -> Self {
Self::new(200, "OK", body)
}
fn not_found() -> Self {
Self::new(404, "Not Found", "<h1>404 - Not Found</h1>")
}
fn add_header(&mut self, name: &str, value: &str) {
self.headers.push((name.to_string(), value.to_string()));
}
fn to_bytes(&self) -> Vec<u8> {
let mut output = format!(
"HTTP/1.1 {} {}\r\n",
self.status, self.reason
);
output.push_str(
&format!("Content-Length: {}\r\n", self.body.len())
);
output.push_str("Connection: close\r\n");
for (name, value) in &self.headers {
output.push_str(&format!("{}: {}\r\n", name, value));
}
output.push_str("\r\n");
output.push_str(&self.body);
output.into_bytes()
}
}
// --- Router ---
fn handle_request(request: &Request) -> Response {
match (&request.method, request.path.as_str()) {
(Method::Get, "/") | (Method::Get, "/index.html") => {
let mut resp = Response::ok(HOME_PAGE);
resp.add_header("Content-Type", "text/html");
resp
}
(Method::Get, "/about") => {
let mut resp = Response::ok(ABOUT_PAGE);
resp.add_header("Content-Type", "text/html");
resp
}
(Method::Get, _) => {
let mut resp = Response::not_found();
resp.add_header("Content-Type", "text/html");
resp
}
_ => Response::new(405, "Method Not Allowed", "Method not supported"),
}
}
// --- Connection handler ---
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0u8; 4096];
let bytes_read = match stream.read(&mut buffer) {
Ok(0) => return, // client closed immediately
Ok(n) => n,
Err(e) => {
eprintln!("Read error: {}", e);
return;
}
};
let raw = String::from_utf8_lossy(&buffer[..bytes_read]);
let response = match Request::parse(&raw) {
Some(request) => {
println!("{:?} {}", request.method, request.path);
handle_request(&request)
}
None => {
eprintln!("Malformed request");
Response::new(400, "Bad Request", "Could not parse request")
}
};
if let Err(e) = stream.write_all(&response.to_bytes()) {
eprintln!("Write error: {}", e);
}
let _ = stream.flush();
}
// --- HTML pages ---
const HOME_PAGE: &str = "\
<!DOCTYPE html>
<html>
<head>
<meta charset=\"utf-8\">
<title>Rust Webserver</title>
<style>
body { font-family: system-ui; max-width: 600px; margin: 40px auto; padding: 0 20px; }
h1 { color: #dea584; }
a { color: #4f8fea; }
</style>
</head>
<body>
<h1>Hello from Rust!</h1>
<p>This page is served by a webserver written from scratch in Rust.</p>
<p><a href=\"/about\">About this server</a></p>
</body>
</html>";
const ABOUT_PAGE: &str = "\
<!DOCTYPE html>
<html>
<head>
<meta charset=\"utf-8\">
<title>About - Rust Webserver</title>
<style>
body { font-family: system-ui; max-width: 600px; margin: 40px auto; padding: 0 20px; }
h1 { color: #dea584; }
a { color: #4f8fea; }
</style>
</head>
<body>
<h1>About</h1>
<p>A simple HTTP/1.1 server handling raw TCP connections.</p>
<p>It parses requests by hand, routes by method and path, and sends structured responses.</p>
<p><a href=\"/\">Back home</a></p>
</body>
</html>";
// --- Entry point ---
fn main() {
let addr = "127.0.0.1:8080";
let listener = TcpListener::bind(addr).unwrap();
println!("Listening on http://{}", addr);
for stream in listener.incoming() {
match stream {
Ok(stream) => handle_connection(stream),
Err(e) => eprintln!("Connection failed: {}", e),
}
}
}Run it with cargo run and try these in another terminal:
# Home page
curl -i http://127.0.0.1:8080/
# About page
curl -i http://127.0.0.1:8080/about
# 404
curl -i http://127.0.0.1:8080/nope
# 405 - wrong method
curl -i -X DELETE http://127.0.0.1:8080/
# Malformed request
echo "GARBAGE" | nc localhost 8080Open http://127.0.0.1:8080 in a browser too. You should see a styled HTML page with a working link to the about page. Click between them - your server is parsing each request, routing it, and sending back the right page.
GET /favicon.ico is the most common. Our server returns 404 for those, which is correct. A real server would serve an icon file.What We Built
Let's step back and appreciate the architecture. We now have a clean separation of concerns:
handle_connection - reads raw bytes, calls the parser, writes the response. Deals with I/O errors.
Request::parse - turns raw text into a structured Request. Returns None for malformed input.
handle_request - pattern-matches on method and path. Returns a Response. Pure logic, no I/O.
Response::to_bytes - serializes a structured response into HTTP wire format.
Each layer knows nothing about the others. The router doesn't know about TCP. The parser doesn't know about HTML. This is the same architecture that production web frameworks use - we just built each layer by hand.
Exercise
- Add a
/headersroute that responds with an HTML page listing all the headers the client sent. Loop overrequest.headersand build the HTML string dynamically. - Add a
POST /echoroute that reads the request body (add thebodyfield back toRequest) and echoes it back as the response. Test withcurl -X POST -d "hello rust" http://127.0.0.1:8080/echo. - Improve the 404 page with a proper HTML template that shows the requested path: "The page
/whateverwas not found." - Add a
Server: rust-webserver/0.1header to every response. Where's the best place to add it - in each route, or into_bytes? - What happens if you send a request larger than the 4096-byte buffer? Test by sending a request with a large body or many headers. What would you need to change to handle this?
What's Next
We have a working HTTP server with request parsing, routing, and structured responses. It serves multiple pages, returns proper status codes, and handles malformed requests gracefully.
The code is getting longer though - all in one file, with types and functions mixed together. In Chapter 7, we'll learn about collections and the standard library - vectors, hashmaps, and iterators - which will make our parsing and routing code more concise. Then in Chapter 9, we'll split everything into modules.