Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
Project E: HTTP Server from Scratch (1/4)
What will I learn
- You will learn how to build a TCP accept loop tuned for HTTP: keep-alive and timeouts;
- You will learn how to parse the HTTP request line: method, path, and protocol version;
- You will learn how to parse headers as key-value pairs separated by CRLF;
- You will learn how to design a Request struct that holds method, path, headers, and body;
- You will learn how to handle Content-Length for reading request bodies;
- You will learn how to use buffered reading for efficient header parsing;
- You will learn how to generate error responses for malformed requests (400 Bad Request);
- You will learn how to test your parser by sending raw HTTP bytes and verifying the parsed result.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18) - Async Concepts and Event Loops
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing (this post)
Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
New project time! After four episodes of building a Unix shell -- parsing, spawning processes, built-ins, job control -- we're shifting gears completely. Project E is an HTTP server from scratch. No frameworks, no libraries, just raw TCP sockets and the HTTP/1.1 spec.
Why an HTTP server? Because it's the single most practical systems programming project you can build. Every web application, every REST API, every microservice sits on top of HTTP. And if you've been following this series, you already have all the building blocks. We built TCP sockets in episode 21, we did buffered I/O and parsing in the markdown project (episodes 37-39), and the KV store's TCP server (episode 42) showed us the accept loop pattern. This time we're putting it all together into something that actually speaks HTTP.
This episode covers the foundation: the accept loop, request parsing, and error handling. By the end you'll have a server that can receive real HTTP requests from curl or your browser, parse them into a clean Request struct, and send back proper error responses when something is malformed. Here we go!
The accept loop: listening for connections
If you remember episode 21, a TCP server follows a simple pattern: bind to an address, listen for connections, accept them in a loop. For an HTTP server we need to add a few things on top of that -- read timeouts (so a slow client doesn't hold a connection forever), and proper cleanup when the connection is done.
Let's start with the server struct and the accept loop:
const std = @import("std");
const net = std.net;
const posix = std.posix;
const Server = struct {
listener: net.Server,
allocator: std.mem.Allocator,
const max_header_size = 8192; // 8 KB max for headers
const read_timeout_ms = 30_000; // 30 second read timeout
const max_request_body = 1_048_576; // 1 MB max body
fn init(allocator: std.mem.Allocator, port: u16) !Server {
const address = net.Address.initIp4(.{ 0, 0, 0, 0 }, port);
var listener = try address.listen(.{
.reuse_address = true,
});
// Set the socket to allow address reuse -- without this,
// restarting the server immediately after stopping gives
// "Address already in use" for up to 60 seconds
return .{
.listener = listener,
.allocator = allocator,
};
}
fn deinit(self: *Server) void {
self.listener.deinit();
}
fn run(self: *Server) !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Listening on port {d}...\n", .{
self.listener.listen_address.getPort(),
});
while (true) {
const conn = self.listener.accept() catch |err| {
std.log.err("Accept failed: {}", .{err});
continue;
};
self.handleConnection(conn) catch |err| {
std.log.err("Connection error: {}", .{err});
};
}
}
fn handleConnection(self: *Server, conn: net.Server.Connection) !void {
defer conn.stream.close();
// Set read timeout so we don't hang on slow clients
const timeout = posix.timeval{
.sec = @intCast(read_timeout_ms / 1000),
.usec = @intCast((read_timeout_ms % 1000) * 1000),
};
posix.setsockopt(
conn.stream.handle,
posix.SOL.SOCKET,
posix.SO.RCVTIMEO,
&std.mem.toBytes(timeout),
) catch {};
self.processRequest(conn.stream) catch |err| {
// Send 400 for parse errors, 500 for internal errors
switch (err) {
error.MalformedRequest,
error.HeaderTooLarge,
error.UnsupportedMethod,
error.InvalidContentLength,
=> sendErrorResponse(conn.stream, 400, "Bad Request"),
error.BodyTooLarge => sendErrorResponse(conn.stream, 413, "Payload Too Large"),
error.RequestTimeout => sendErrorResponse(conn.stream, 408, "Request Timeout"),
else => sendErrorResponse(conn.stream, 500, "Internal Server Error"),
}
};
}
};
A few things to note here. The reuse_address option on the listener is critical for development -- without it, the OS holds onto the bound port for a while after the server shuts down (the TIME_WAIT state from TCP), and restarting immeditaly gives you "Address already in use". The SO_RCVTIMEO socket option sets a read timeout so that a client that connects but never sends anything (or sends data very slowly) doesn't block our server forever. Thirty seconds is generous -- most production servers use 5-15 seconds.
This is a single-threaded server. It handles one connection at a time. That's fine for learning (and honestly fine for low-traffic use cases too). We could add threading or async I/O later if we wanted concurrency, but the parsing logic is the same regardless of the concurrency model. One thing at a time.
The Request struct: what we're building toward
Before we write the parser, let's define what a parsed HTTP request looks like. This gives us a clear target:
const Method = enum {
GET,
POST,
PUT,
DELETE,
HEAD,
OPTIONS,
PATCH,
fn fromString(str: []const u8) !Method {
if (std.mem.eql(u8, str, "GET")) return .GET;
if (std.mem.eql(u8, str, "POST")) return .POST;
if (std.mem.eql(u8, str, "PUT")) return .PUT;
if (std.mem.eql(u8, str, "DELETE")) return .DELETE;
if (std.mem.eql(u8, str, "HEAD")) return .HEAD;
if (std.mem.eql(u8, str, "OPTIONS")) return .OPTIONS;
if (std.mem.eql(u8, str, "PATCH")) return .PATCH;
return error.UnsupportedMethod;
}
};
const Header = struct {
name: []const u8,
value: []const u8,
};
const Request = struct {
method: Method,
path: []const u8,
version: []const u8,
headers: []const Header,
body: ?[]const u8,
// Convenience: find a header by name (case-insensitive)
fn getHeader(self: *const Request, name: []const u8) ?[]const u8 {
for (self.headers) |header| {
if (std.ascii.eqlIgnoreCase(header.name, name)) {
return header.value;
}
}
return null;
}
fn getContentLength(self: *const Request) ?usize {
const val = self.getHeader("Content-Length") orelse return null;
return std.fmt.parseInt(usize, val, 10) catch null;
}
fn deinit(self: *const Request, allocator: std.mem.Allocator) void {
allocator.free(self.headers);
if (self.body) |body| allocator.free(body);
}
};
The Method enum uses a simple if-chain for parsing. You might be tempted to use std.StaticStringMap here (like we did for built-in commands in the shell project, episode 49), and that would work fine. But with only 7 entries and string comparisons that short, the if-chain is just as fast and arguably more readable. The compiler will probably optimize it to something similar anyway.
The getHeader function does case-insensitive matching because HTTP header names are case-insensitive per the spec. Content-Type and content-type and CONTENT-TYPE all mean the same thing. Zig's std.ascii.eqlIgnoreCase handles this cleanly.
Notice that Request does NOT own the raw data buffer that path, version, and header name/value slices point into. Those slices reference the original read buffer. This is deliberate -- we avoid copying strings during parsing, which is a lot faster. The trade-off is that the Request is only valid for as long as the underlying buffer exists. Since we process the request and send a response before moving on to the next connection, that's perfectly fine for our use case.
Buffered reading: efficient header parsing
HTTP requests come over TCP as a byte stream. Headers are terminated by \r\n\r\n (a blank line), and we need to find that boundary before we can parse anything. The problem is that TCP gives us data in chunks of arbitrary size -- a single read() might return the entire request, or just the first 3 bytes. We need a buffer that accumulates data until we have enough.
const ReadBuffer = struct {
buf: [Server.max_header_size]u8,
len: usize,
fn init() ReadBuffer {
return .{
.buf = undefined,
.len = 0,
};
}
fn readUntilHeaders(self: *ReadBuffer, stream: net.Stream) !usize {
// Read data until we find \r\n\r\n (end of headers)
while (self.len < self.buf.len) {
const bytes_read = stream.read(self.buf[self.len..]) catch |err| {
if (err == error.WouldBlock) return error.RequestTimeout;
return err;
};
if (bytes_read == 0) {
if (self.len == 0) return error.ConnectionClosed;
return error.MalformedRequest; // Partial request
}
self.len += bytes_read;
// Search for the header terminator
if (self.findHeaderEnd()) |pos| {
return pos;
}
}
return error.HeaderTooLarge;
}
fn findHeaderEnd(self: *const ReadBuffer) ?usize {
if (self.len < 4) return null;
// Look for \r\n\r\n
var i: usize = 0;
while (i + 3 < self.len) : (i += 1) {
if (self.buf[i] == '\r' and
self.buf[i + 1] == '\n' and
self.buf[i + 2] == '\r' and
self.buf[i + 3] == '\n')
{
return i;
}
}
return null;
}
fn data(self: *const ReadBuffer) []const u8 {
return self.buf[0..self.len];
}
fn remaining(self: *const ReadBuffer, header_end: usize) []const u8 {
// Data after the \r\n\r\n that might be the start of the body
const body_start = header_end + 4;
if (body_start >= self.len) return &.{};
return self.buf[body_start..self.len];
}
};
The readUntilHeaders function is the core of the I/O layer. It reads data in a loop, appending to our buffer, and after each read it scans for the \r\n\r\n terminator. When found, it returns the position. If we fill the entire 8 KB buffer without finding the header terminator, we return HeaderTooLarge -- the client is trying to send headers that are too big (a common attack vector, actually). The 8 KB limit matches what Apache and nginx use by default.
The WouldBlock error means the read timed out (because of the SO_RCVTIMEO we set earlier). We map that to RequestTimeout so we can send a 408 response.
The remaining function is interesting -- it returns any data that came after the header terminator. This matters because a POST request sends headers followed by a body, and TCP might deliver parts of the body in the same read that contained the end of the headers. We need to account for those already-read body bytes and not read them again.
Parsing the request line
An HTTP request starts with a request line -- the method, path, and version separated by spaces:
GET /index.html HTTP/1.1\r\n
Let's parse it:
const ParseError = error{
MalformedRequest,
UnsupportedMethod,
HeaderTooLarge,
InvalidContentLength,
BodyTooLarge,
RequestTimeout,
ConnectionClosed,
};
fn parseRequestLine(line: []const u8) ParseError!struct {
method: Method,
path: []const u8,
version: []const u8,
} {
// Find method: everything before first space
const method_end = std.mem.indexOf(u8, line, " ") orelse
return error.MalformedRequest;
const method_str = line[0..method_end];
const rest = line[method_end + 1 ..];
// Find path: everything between first and second space
const path_end = std.mem.indexOf(u8, rest, " ") orelse
return error.MalformedRequest;
const path = rest[0..path_end];
if (path.len == 0) return error.MalformedRequest;
// Version: everything after the second space
const version = rest[path_end + 1 ..];
// Validate version starts with HTTP/
if (!std.mem.startsWith(u8, version, "HTTP/")) {
return error.MalformedRequest;
}
const method = Method.fromString(method_str) catch
return error.UnsupportedMethod;
return .{
.method = method,
.path = path,
.version = version,
};
}
This is strightforward string splitting. Find the first space to get the method, find the second space to get the path, and the rest is the version. We validate that the version string starts with HTTP/ -- if someone sends garbage, we reject it immediately.
One thing I want to call out: the return type is an anonymous struct. We covered this briefly in episode 6 -- Zig lets you return unnamed structs from functions, which is perfect for multi-value returns that don't need their own named type. The caller destructures it at the call site and that's that.
Parsing headers: key-value pairs separated by CRLF
After the request line, HTTP headers come one per line. Each header is a name, followed by a colon, followed by the value:
Host: example.com\r\n
Content-Type: application/json\r\n
Content-Length: 42\r\n
\r\n
The blank line (\r\n\r\n) marks the end of headers. Let's parse them:
fn parseHeaders(
allocator: std.mem.Allocator,
header_data: []const u8,
) ![]Header {
var headers = std.ArrayList(Header).init(allocator);
errdefer headers.deinit();
var line_start: usize = 0;
while (line_start < header_data.len) {
// Find the end of this line (\r\n)
const line_end = std.mem.indexOf(
u8,
header_data[line_start..],
"\r\n",
) orelse {
// Last line without \r\n -- still try to parse it
if (line_start < header_data.len) {
const line = header_data[line_start..];
if (line.len > 0) {
const header = try parseSingleHeader(line);
try headers.append(header);
}
}
break;
};
const line = header_data[line_start .. line_start + line_end];
line_start += line_end + 2; // skip past \r\n
if (line.len == 0) break; // Empty line = end of headers
const header = try parseSingleHeader(line);
try headers.append(header);
}
return try headers.toOwnedSlice();
}
fn parseSingleHeader(line: []const u8) !Header {
const colon_pos = std.mem.indexOf(u8, line, ":") orelse
return error.MalformedRequest;
const name = line[0..colon_pos];
if (name.len == 0) return error.MalformedRequest;
// Value: everything after the colon, with leading/trailing whitespace stripped
var value = line[colon_pos + 1 ..];
value = std.mem.trim(u8, value, " \t");
return .{
.name = name,
.value = value,
};
}
We use an ArrayList to collect headers since we don't know how many there will be ahead of time. The errdefer headers.deinit() is the standard pattern from episode 7 -- if any allocation fails partway through, we clean up what we've already built. At the end, toOwnedSlice converts the ArrayList into a plain slice that the caller owns.
The header value trimming is important. The HTTP spec says that optional whitespace (OWS) can appear after the colon, and most clients send Header: value with a space. We trim both spaces and tabs to handle all valid formats.
Having said that, there's a subtlety we're deliberately skipping: header folding. The HTTP/1.1 spec technically allows multi-line headers where continuation lines start with whitespace. So this is valid (but deprecated):
X-Custom: value starts here
and continues on this line
Virtually no modern HTTP client sends folded headers, and HTTP/2 explicitly forbids them, so we're ignoring that for our implementation. If you encounter folded headers in the wild in 2026, something is very wrong ;-)
Putting the parser together
Now we combine the request line parser, header parser, and body reading into one function that produces a complete Request:
fn processRequest(self: *Server, stream: net.Stream) !void {
var read_buf = ReadBuffer.init();
// Read until we have all headers
const header_end = try read_buf.readUntilHeaders(stream);
// Split into request line and header block
const raw = read_buf.data()[0..header_end];
// Find the end of the request line
const req_line_end = std.mem.indexOf(u8, raw, "\r\n") orelse
return error.MalformedRequest;
const request_line = raw[0..req_line_end];
const header_block = raw[req_line_end + 2 ..];
// Parse request line
const req_info = try parseRequestLine(request_line);
// Parse headers
const headers = try parseHeaders(self.allocator, header_block);
defer self.allocator.free(headers);
// Build the request (no body yet)
var request = Request{
.method = req_info.method,
.path = req_info.path,
.version = req_info.version,
.headers = headers,
.body = null,
};
// Read body if Content-Length is present
if (request.getContentLength()) |content_length| {
if (content_length > Server.max_request_body) {
return error.BodyTooLarge;
}
if (content_length > 0) {
const body = try readBody(
self.allocator,
stream,
content_length,
read_buf.remaining(header_end),
);
request.body = body;
}
}
defer if (request.body) |body| self.allocator.free(body);
// At this point we have a fully parsed request. For now,
// just send a simple response so we can test.
try sendResponse(stream, &request);
}
The flow is: read until we find the header terminator, split into request line and headers, parse both, check for a body, read the body if needed, then process the request. The defer statements ensure we clean up allocations even if processing fails partway through.
Reading the request body
For POST and PUT requests, the client sends a body after the headers. The Content-Length header tells us exactly how many bytes to expect:
fn readBody(
allocator: std.mem.Allocator,
stream: net.Stream,
content_length: usize,
already_read: []const u8,
) ![]u8 {
const body = try allocator.alloc(u8, content_length);
errdefer allocator.free(body);
// Copy any bytes we already read past the header terminator
var total_read = already_read.len;
if (total_read > content_length) total_read = content_length;
@memcpy(body[0..total_read], already_read[0..total_read]);
// Read the rest from the stream
while (total_read < content_length) {
const n = stream.read(body[total_read..content_length]) catch |err| {
if (err == error.WouldBlock) return error.RequestTimeout;
return err;
};
if (n == 0) return error.MalformedRequest; // Connection closed early
total_read += n;
}
return body;
}
The already_read parameter is where the ReadBuffer.remaining function pays off. If the header read happened to also grab some body bytes (very common -- TCP doesn't care about HTTP boundaries), we don't want to lose those bytes. We copy them into the body buffer first, then read only the remaining bytes from the stream.
The errdefer allocator.free(body) ensures we don't leak the allocated body buffer if the read loop fails midway. Without it, a timeout while reading the body would leak memory. We first introduced this pattern all the way back in episode 7 and it keeps proving its worth in every project.
Error responses: telling clients what went wrong
When a request is malformed, we need to send a proper HTTP error response. Not just close the connection -- that would leave the client confused. A proper response includes a status line, headers, and optionally a body:
fn sendErrorResponse(
stream: net.Stream,
status_code: u16,
reason: []const u8,
) void {
var buf: [512]u8 = undefined;
const response = std.fmt.bufPrint(&buf,
"HTTP/1.1 {d} {s}\r\n" ++
"Content-Type: text/plain\r\n" ++
"Content-Length: {d}\r\n" ++
"Connection: close\r\n" ++
"\r\n" ++
"{s}\n",
.{ status_code, reason, reason.len + 1, reason },
) catch return;
_ = stream.write(response) catch {};
}
fn sendResponse(stream: net.Stream, request: *const Request) !void {
// For now, send a simple "Hello" response with request info
var body_buf: [1024]u8 = undefined;
const body = std.fmt.bufPrint(&body_buf,
"Hello from Zig HTTP server!\n\n" ++
"Method: {s}\n" ++
"Path: {s}\n" ++
"Version: {s}\n" ++
"Headers: {d}\n",
.{
@tagName(request.method),
request.path,
request.version,
request.headers.len,
},
) catch return error.MalformedRequest;
var resp_buf: [2048]u8 = undefined;
const response = std.fmt.bufPrint(&resp_buf,
"HTTP/1.1 200 OK\r\n" ++
"Content-Type: text/plain\r\n" ++
"Content-Length: {d}\r\n" ++
"Connection: close\r\n" ++
"\r\n" ++
"{s}",
.{ body.len, body },
) catch return error.MalformedRequest;
_ = try stream.write(response);
}
The error response function catches and ignores write errors -- if we can't even send the error response to the client, there's nothing useful we can do about it. The client might have already disconnected, and that's their problem. The Connection: close header tells the client we're closing the connection after this response, which is the simplest approach (no keep-alive for errors).
The sendResponse function is a placeholder -- it just echoes back information about the request so we can verify our parser is working. In the next episode we'll replace this with a proper router that maps URL paths to handler functions.
The main function: bringing it together
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const check = gpa.deinit();
if (check == .leak) std.debug.print("WARNING: memory leak detected\n", .{});
}
var server = try Server.init(gpa.allocator(), 8080);
defer server.deinit();
server.run() catch |err| {
std.log.err("Server failed: {}", .{err});
return err;
};
}
The GPA (General Purpose Allocator) with leak detection is our friend as always. If we have any memory management bugs, we'll hear about them on shutdown. This is one of those things I genuinely love about Zig -- in C you'd need Valgrind or AddressSanitizer to catch leaks, but Zig gives you a built-in leak detector that costs nothing in release builds.
Testing: send raw HTTP bytes, verify the parser
We want to verify that our parser handles real HTTP requests correctly, including various edge cases and malformed input. The approach is simple: create a pipe (or a mock stream), write raw HTTP bytes into it, and check what the parser produces.
For unit-testing the parsing functions directly, we can call them with string literals:
test "parse simple GET request line" {
const result = try parseRequestLine("GET /index.html HTTP/1.1");
try std.testing.expect(result.method == .GET);
try std.testing.expectEqualStrings("/index.html", result.path);
try std.testing.expectEqualStrings("HTTP/1.1", result.version);
}
test "parse POST request line" {
const result = try parseRequestLine("POST /api/users HTTP/1.1");
try std.testing.expect(result.method == .POST);
try std.testing.expectEqualStrings("/api/users", result.path);
}
test "reject malformed request line - no spaces" {
const result = parseRequestLine("GETHTTP/1.1");
try std.testing.expect(result == error.MalformedRequest);
}
test "reject malformed request line - no version" {
const result = parseRequestLine("GET /path");
try std.testing.expect(result == error.MalformedRequest);
}
test "reject unknown method" {
const result = parseRequestLine("FOOBAR /path HTTP/1.1");
try std.testing.expect(result == error.UnsupportedMethod);
}
test "parse headers" {
const raw =
"Host: example.com\r\n" ++
"Content-Type: text/html\r\n" ++
"X-Custom: spaces \r\n";
const headers = try parseHeaders(std.testing.allocator, raw);
defer std.testing.allocator.free(headers);
try std.testing.expect(headers.len == 3);
try std.testing.expectEqualStrings("Host", headers[0].name);
try std.testing.expectEqualStrings("example.com", headers[0].value);
try std.testing.expectEqualStrings("Content-Type", headers[1].name);
try std.testing.expectEqualStrings("text/html", headers[1].value);
try std.testing.expectEqualStrings("X-Custom", headers[2].name);
try std.testing.expectEqualStrings("spaces", headers[2].value);
}
test "parse header with no value" {
const raw = "X-Empty:\r\n";
const headers = try parseHeaders(std.testing.allocator, raw);
defer std.testing.allocator.free(headers);
try std.testing.expect(headers.len == 1);
try std.testing.expectEqualStrings("X-Empty", headers[0].name);
try std.testing.expectEqualStrings("", headers[0].value);
}
test "reject header with no colon" {
const raw = "InvalidHeader\r\n";
const result = parseHeaders(std.testing.allocator, raw);
try std.testing.expect(result == error.MalformedRequest);
}
test "getHeader is case-insensitive" {
const headers = [_]Header{
.{ .name = "Content-Type", .value = "text/html" },
.{ .name = "X-Custom", .value = "test" },
};
const request = Request{
.method = .GET,
.path = "/",
.version = "HTTP/1.1",
.headers = &headers,
.body = null,
};
const ct = request.getHeader("content-type");
try std.testing.expect(ct != null);
try std.testing.expectEqualStrings("text/html", ct.?);
const missing = request.getHeader("Authorization");
try std.testing.expect(missing == null);
}
test "getContentLength parses correctly" {
const headers = [_]Header{
.{ .name = "Content-Length", .value = "42" },
};
const request = Request{
.method = .POST,
.path = "/data",
.version = "HTTP/1.1",
.headers = &headers,
.body = null,
};
const len = request.getContentLength();
try std.testing.expect(len != null);
try std.testing.expect(len.? == 42);
}
test "getContentLength returns null for missing header" {
const headers = [_]Header{};
const request = Request{
.method = .GET,
.path = "/",
.version = "HTTP/1.1",
.headers = &headers,
.body = null,
};
try std.testing.expect(request.getContentLength() == null);
}
test "ReadBuffer findHeaderEnd" {
var buf = ReadBuffer.init();
const data = "GET / HTTP/1.1\r\nHost: x\r\n\r\nextra";
@memcpy(buf.buf[0..data.len], data);
buf.len = data.len;
const pos = buf.findHeaderEnd();
try std.testing.expect(pos != null);
// Verify the remaining data after header end
const rest = buf.remaining(pos.?);
try std.testing.expectEqualStrings("extra", rest);
}
test "Method.fromString" {
try std.testing.expect(try Method.fromString("GET") == .GET);
try std.testing.expect(try Method.fromString("POST") == .POST);
try std.testing.expect(try Method.fromString("DELETE") == .DELETE);
try std.testing.expect(Method.fromString("INVALID") == error.UnsupportedMethod);
}
And for integration-style testing, you can fire up the server and hit it with curl:
$ zig build-exe http_server.zig && ./http_server &
Listening on port 8080...
$ curl -v http://localhost:8080/hello
> GET /hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 78
< Connection: close
<
Hello from Zig HTTP server!
Method: GET
Path: /hello
Version: HTTP/1.1
Headers: 3
$ curl -v -X POST -d '{"name":"scipio"}' http://localhost:8080/api/users
> POST /api/users HTTP/1.1
> Host: localhost:8080
> Content-Length: 17
> Content-Type: application/x-www-form-urlencoded
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 85
< Connection: close
<
Hello from Zig HTTP server!
Method: POST
Path: /api/users
Version: HTTP/1.1
Headers: 4
The parser correctly identifies the method, path, and headers for both GET and POST requests. The POST request's body (the {"name":"scipio"} part) gets read and stored in request.body -- we just don't echo it back in our placeholder response yet.
Try sending malformed requests too:
$ echo "GARBAGE" | nc localhost 8080
HTTP/1.1 400 Bad Request
Content-Type: text/plain
Content-Length: 12
Connection: close
Bad Request
The server correctly rejects the garbage input with a 400 response instead of crashing. That's exactly what we want.
What about chunked transfer encoding?
You might have noticed we're only handling Content-Length for body reading. The HTTP/1.1 spec also defines chunked transfer encoding, where the body is sent in chunks of varying size, each prefixed with its length in hex:
4\r\n
Wiki\r\n
6\r\n
pedia \r\n
0\r\n
\r\n
This is used when the sender doesn't know the total body size upfront (streaming responses, for example). It's important for production servers but adds quite some complexity to the parser -- you need to read chunk sizes, validate hex encoding, handle trailers, and deal with the terminating zero-length chunk.
We're skipping chunked encoding for this project. For a learning exercise, Content-Length covers the vast majority of real-world requests you'll encounter. Browsers and curl always send Content-Length for POST/PUT bodies. Chunked encoding is mostly a server-to-client feature (servers streaming responses), and when it IS used client-to-server, it's typically by programmatic HTTP clients that could just as easily send a Content-Length. If you wanted to add it later, the parsing logic would slot in right where we call readBody -- check for Transfer-Encoding: chunked instead of Content-Length, and call a different body-reading function.
Design decisions and what comes next
A few design choices worth reflecting on:
Single-threaded. We handle one connection at a time. This is the simplest possible model and it works fine for learning. A production server would use one of: thread-per-connection, a thread pool, or async I/O (epoll/kqueue). The parsing code doesn't change regardless -- only the accept loop needs to be restructured. We covered threading in episode 30, so you already know how to spawn threads per connection if you wanted to.
Stack buffers. Our read buffer, response buffer, and body buffer are all stack-allocated with fixed maximums. This means zero heap allocations for the common case (small requests, short responses). The downside is hard limits -- an 8 KB header limit, 1 MB body limit. Production servers make these configurable, but fixed limits are perfectly reasonable and actually desirable from a security standpoint. An attacker can't exhaust your memory by sending infinite headers.
No keep-alive. We close the connection after every response (Connection: close). HTTP/1.1 supports persistent connections where the client sends multiple requests on the same TCP connection. That's an optimization that reduces latency by avoiding TCP handshake overhead, but it complicates our code because we'd need to loop reading requests until the client disconnects. Something to add later if you want.
The parsing layer we built today is the foundation for everything that follows. Next time we'll build on top of it: a router that maps URL paths to handler functions, proper response generation with different content types and status codes, and the beginnings of a real API. The request parsing won't change -- we'll just do more interesting things with the parsed result.
Wat we geleerd hebben
- Building a TCP accept loop with
net.Serverand handling errors gracefully so one bad connection doesn't crash the whole server - Setting
SO_RCVTIMEOviaposix.setsockoptto prevent slow clients from blocking the server indefinitely - The
ReadBufferpattern: accumulating TCP data in a fixed buffer and scanning for the\r\n\r\nheader terminator - Parsing the HTTP request line by splitting on spaces to extract method, path, and version
- The
Methodenum withfromStringfor converting HTTP method strings into type-safe values - Parsing headers as colon-separated key-value pairs with proper whitespace trimming
- Building a
Requeststruct where path and header slices reference the underlying read buffer (zero-copy parsing) - Reading request bodies using
Content-Length, accounting for bytes already buffered during header reading - Sending proper HTTP error responses (400, 408, 413, 500) with status line, headers, and body
- Unit testing the parser by calling parse functions directly with string literals and checking error returns
Bedankt en tot de volgende keer!