Learn Zig Series (#52) - HTTP Server: Router and Responses
Project E: HTTP Server from Scratch (2/4)
What will I learn
- You will learn how to build a Response struct with status code, headers, and body;
- You will learn common HTTP status codes and when to use each one;
- You will learn how to construct well-formed HTTP responses:
200 OK,404 Not Found,500 Internal Server Error; - You will learn how to build a path-based router that matches URL patterns to handler functions;
- You will learn the handler function signature:
fn(Request, std.mem.Allocator) Response; - You will learn how to extract path parameters from URLs like
/users/:id; - You will learn how to build JSON responses with proper Content-Type headers;
- You will learn how to test your router by registering routes, sending requests, and verifying responses.
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
- Learn Zig Series (#52) - HTTP Server: Router and Responses (this post)
Learn Zig Series (#52) - HTTP Server: Router and Responses
Last time in episode 51 we built the foundation of our HTTP server -- the accept loop, a Request struct, buffered reading, header parsing, body reading, and basic error responses. We ended with a placeholder sendResponse function that just echoed the request info back. Functional, but not very useful. A real HTTP server needs two things on top of that: a router (matching URL paths to handler functions) and proper response generation (status codes, headers, content types, the whole deal).
That's exactly what we're building today. By the end of this episode you'll have a server where you can register routes like /users/:id with handler functions, and the router will match incoming requests, extract path parameters, call the right handler, and send back a well-formed HTTP response. If no route matches, you get a proper 404 Not Found. If the handler crashes, you get a 500 Internal Server Error. Here we go!
The Response struct: status code, headers, body
Before we can build a router, we need to define what a handler returns. In episode 51 our sendErrorResponse function manually formatted HTTP response strings. That works for one-off error pages but it's not scalable -- we need a proper Response struct that handlers can construct cleanly:
const std = @import("std");
const Response = struct {
status_code: u16,
reason: []const u8,
headers: std.ArrayList(Header),
body: []const u8,
allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator) Response {
return .{
.status_code = 200,
.reason = "OK",
.headers = std.ArrayList(Header).init(allocator),
.body = "",
.allocator = allocator,
};
}
fn deinit(self: *Response) void {
self.headers.deinit();
}
fn setStatus(self: *Response, code: u16, reason: []const u8) void {
self.status_code = code;
self.reason = reason;
}
fn addHeader(self: *Response, name: []const u8, value: []const u8) !void {
try self.headers.append(.{ .name = name, .value = value });
}
fn setBody(self: *Response, body: []const u8) !void {
self.body = body;
// Auto-set Content-Length when body is set
// We store the length as a formatted string
}
fn serialize(self: *const Response, buf: []u8) ![]const u8 {
var pos: usize = 0;
// Status line: HTTP/1.1 200 OK\r\n
const status_line = try std.fmt.bufPrint(
buf[pos..],
"HTTP/1.1 {d} {s}\r\n",
.{ self.status_code, self.reason },
);
pos += status_line.len;
// Headers
for (self.headers.items) |header| {
const hdr = try std.fmt.bufPrint(
buf[pos..],
"{s}: {s}\r\n",
.{ header.name, header.value },
);
pos += hdr.len;
}
// Content-Length (always add it -- clients depend on this)
const cl = try std.fmt.bufPrint(
buf[pos..],
"Content-Length: {d}\r\n",
.{self.body.len},
);
pos += cl.len;
// Connection: close (we're not doing keep-alive yet)
const conn = try std.fmt.bufPrint(
buf[pos..],
"Connection: close\r\n",
.{},
);
pos += conn.len;
// Blank line separating headers from body
buf[pos] = '\r';
buf[pos + 1] = '\n';
pos += 2;
// Body
if (self.body.len > 0) {
@memcpy(buf[pos..][0..self.body.len], self.body);
pos += self.body.len;
}
return buf[0..pos];
}
};
The design is straightforward -- a builder pattern where you create a response, set its status, add headers, set the body, and then serialize it into a byte buffer for writing to the socket. The serialize function always adds Content-Length and Connection: close automatically. Handlers don't need to remember those -- they just set the body and the framework handles the rest.
Having said that, I want to call attention to the serialize function's buffer approach. We're writing into a caller-provided buffer, not allocating. This is the same philosophy we used in episode 51 for the read buffer -- keep allocations off the heap where possible. For most HTTP responses (an API returning JSON, an error page, a redirect), the entire serialized response fits comfortably in a few kilobytes. If you needed to serve large files you'd want a streaming approach instead, but for an API server this is perfect.
Common HTTP status codes
Before we wire up the router, let's define the status codes we'll actually use. HTTP has dozens of status codes but in practice you encounter maybe 10-15 regularly. Here's a helper that gives us named constants and maps codes to their reason phrases:
const StatusCode = struct {
// 2xx Success
const ok = Status{ .code = 200, .reason = "OK" };
const created = Status{ .code = 201, .reason = "Created" };
const no_content = Status{ .code = 204, .reason = "No Content" };
// 3xx Redirection
const moved_permanently = Status{ .code = 301, .reason = "Moved Permanently" };
const found = Status{ .code = 302, .reason = "Found" };
const not_modified = Status{ .code = 304, .reason = "Not Modified" };
// 4xx Client Errors
const bad_request = Status{ .code = 400, .reason = "Bad Request" };
const unauthorized = Status{ .code = 401, .reason = "Unauthorized" };
const forbidden = Status{ .code = 403, .reason = "Forbidden" };
const not_found = Status{ .code = 404, .reason = "Not Found" };
const method_not_allowed = Status{ .code = 405, .reason = "Method Not Allowed" };
const conflict = Status{ .code = 409, .reason = "Conflict" };
const payload_too_large = Status{ .code = 413, .reason = "Payload Too Large" };
// 5xx Server Errors
const internal_error = Status{ .code = 500, .reason = "Internal Server Error" };
const not_implemented = Status{ .code = 501, .reason = "Not Implemented" };
const bad_gateway = Status{ .code = 502, .reason = "Bad Gateway" };
const service_unavailable = Status{ .code = 503, .reason = "Service Unavailable" };
};
const Status = struct {
code: u16,
reason: []const u8,
};
Using const declarations inside a struct as a namespace for related constants -- we've done this before in the KV store project (episode 40) and it's a clean pattern. Instead of writing response.setStatus(404, "Not Found") (error-prone -- what if you typo the reason phrase?), handlers write response.setStatus(StatusCode.not_found.code, StatusCode.not_found.reason). Or even better, we can add a convenience method:
// Add to Response struct:
fn setStatusFromCode(self: *Response, status: Status) void {
self.status_code = status.code;
self.reason = status.reason;
}
Now handlers just write response.setStatusFromCode(StatusCode.not_found) and can't accidentally mismatch a code with the wrong reason string.
Building responses: convenience constructors
Writing Response.init(), then setting status, then adding headers, then setting body -- that's verbose for the common cases. Let's add convenience functions that build complete responses in one call:
// Add these to the Response struct as well:
fn text(allocator: std.mem.Allocator, status: Status, body: []const u8) !Response {
var resp = Response.init(allocator);
resp.setStatusFromCode(status);
try resp.addHeader("Content-Type", "text/plain; charset=utf-8");
resp.body = body;
return resp;
}
fn json(allocator: std.mem.Allocator, status: Status, body: []const u8) !Response {
var resp = Response.init(allocator);
resp.setStatusFromCode(status);
try resp.addHeader("Content-Type", "application/json");
resp.body = body;
return resp;
}
fn html(allocator: std.mem.Allocator, status: Status, body: []const u8) !Response {
var resp = Response.init(allocator);
resp.setStatusFromCode(status);
try resp.addHeader("Content-Type", "text/html; charset=utf-8");
resp.body = body;
return resp;
}
fn notFound(allocator: std.mem.Allocator) !Response {
return text(allocator, StatusCode.not_found, "404 Not Found\n");
}
fn internalError(allocator: std.mem.Allocator) !Response {
return text(allocator, StatusCode.internal_error, "500 Internal Server Error\n");
}
fn methodNotAllowed(allocator: std.mem.Allocator) !Response {
return text(allocator, StatusCode.method_not_allowed, "405 Method Not Allowed\n");
}
Now a handler that returns JSON can do return Response.json(allocator, StatusCode.ok, json_bytes) -- one line, no forgetting Content-Type, no typos. The convenience constructors also make the 404/500 error paths trivially short, which matters because you'll be writing those a LOT in any real API.
The router: matching paths to handlers
This is the core of today's episode. A router takes a request (specifically its method and path) and finds the right handler to call. Our router supports both exact matches (/health, /api/users) and parameterized paths (/api/users/:id, /posts/:year/:month).
The handler signature is a function that takes a *const Request, a *const RouteParams, and an std.mem.Allocator, and returns a Response or an error:
const HandlerFn = *const fn (*const Request, *const RouteParams, std.mem.Allocator) anyerror!Response;
const RouteParams = struct {
params: [8]Param, // max 8 path parameters per route
len: usize,
const Param = struct {
name: []const u8,
value: []const u8,
};
fn init() RouteParams {
return .{
.params = undefined,
.len = 0,
};
}
fn add(self: *RouteParams, name: []const u8, value: []const u8) void {
if (self.len >= 8) return; // silent cap
self.params[self.len] = .{ .name = name, .value = value };
self.len += 1;
}
fn get(self: *const RouteParams, name: []const u8) ?[]const u8 {
for (self.params[0..self.len]) |p| {
if (std.mem.eql(u8, p.name, name)) return p.value;
}
return null;
}
};
const Route = struct {
method: Method,
pattern: []const u8, // e.g. "/users/:id"
segments: []const Segment,
handler: HandlerFn,
const Segment = union(enum) {
literal: []const u8, // "users"
param: []const u8, // ":id" -> "id"
};
};
const Router = struct {
routes: std.ArrayList(Route),
allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator) Router {
return .{
.routes = std.ArrayList(Route).init(allocator),
.allocator = allocator,
};
}
fn deinit(self: *Router) void {
for (self.routes.items) |route| {
self.allocator.free(route.segments);
}
self.routes.deinit();
}
fn addRoute(
self: *Router,
method: Method,
pattern: []const u8,
handler: HandlerFn,
) !void {
// Parse pattern into segments
const segments = try self.parsePattern(pattern);
try self.routes.append(.{
.method = method,
.pattern = pattern,
.segments = segments,
.handler = handler,
});
}
fn parsePattern(self: *Router, pattern: []const u8) ![]const Route.Segment {
var segments = std.ArrayList(Route.Segment).init(self.allocator);
errdefer segments.deinit();
// Split pattern by '/' and skip empty parts
var iter = std.mem.splitScalar(u8, pattern, '/');
while (iter.next()) |part| {
if (part.len == 0) continue;
if (part[0] == ':') {
// Parameter segment: ":id" -> param("id")
try segments.append(.{ .param = part[1..] });
} else {
// Literal segment: "users" -> literal("users")
try segments.append(.{ .literal = part });
}
}
return try segments.toOwnedSlice();
}
fn match(self: *const Router, method: Method, path: []const u8) ?struct {
handler: HandlerFn,
params: RouteParams,
} {
for (self.routes.items) |route| {
// Method must match
if (route.method != method) continue;
// Try to match segments
if (self.matchSegments(route.segments, path)) |params| {
return .{
.handler = route.handler,
.params = params,
};
}
}
return null;
}
fn matchSegments(
self: *const Router,
segments: []const Route.Segment,
path: []const u8,
) ?RouteParams {
_ = self;
var params = RouteParams.init();
var seg_idx: usize = 0;
var path_iter = std.mem.splitScalar(u8, path, '/');
while (path_iter.next()) |part| {
if (part.len == 0) continue;
if (seg_idx >= segments.len) return null; // more path parts than route segments
switch (segments[seg_idx]) {
.literal => |expected| {
if (!std.mem.eql(u8, part, expected)) return null;
},
.param => |name| {
params.add(name, part);
},
}
seg_idx += 1;
}
// All segments must be consumed
if (seg_idx != segments.len) return null;
return params;
}
};
Let me walk through the design decisions here because there are a few interesting ones.
Pattern parsing happens once when you register a route, not on every request. The parsePattern function splits /users/:id/posts into segments: [literal("users"), param("id"), literal("posts")]. This pre-parsed representation makes matching fast -- we're just walking two lists in parallel, not doing string operations per request.
Path parameters use the :name convention that Express.js popularized. When matching /users/42 against the pattern /users/:id, the router extracts id=42 and stores it in RouteParams. Handlers access it via params.get("id"). The fixed-size array of 8 parameters avoids heap allocation for the common case (most routes have 0-3 params). If you somehow need more than 8 path parameters in one URL... you probably have a different problem ;-)
Matching is linear -- we just walk through all registered routes and try each one. For a small to medium API (say, under 100 routes) this is perfectly fine. If you had thousands of routes you'd want a radix tree (a trie structure optimized for URL paths), but that's massive overkill for learning. We used std.ArrayList for the route list -- same approach as the header parsing in episode 51.
The matchSegments function is where the actual matching happens. It walks through the path and the route's segments in parallel. For literal segments, the path part must match exactly. For param segments, any non-empty value matches and gets captured. If the path has more or fewer segments than the pattern, it's a mismatch. Simple, correct, fast enough.
Handler functions: the application layer
With the router in place, writing handlers is straightforward. Each handler receives the parsed request, extracted path params, and an allocator, and returns a Response:
fn handleHealth(
request: *const Request,
params: *const RouteParams,
allocator: std.mem.Allocator,
) anyerror!Response {
_ = request;
_ = params;
return Response.json(allocator, StatusCode.ok,
\\{"status": "healthy"}
);
}
fn handleGetUser(
request: *const Request,
params: *const RouteParams,
allocator: std.mem.Allocator,
) anyerror!Response {
_ = request;
const id = params.get("id") orelse
return Response.text(allocator, StatusCode.bad_request, "Missing user ID\n");
// In a real app you'd look up the user in a database.
// For now, just echo back the ID as JSON.
var buf: [256]u8 = undefined;
const body = std.fmt.bufPrint(&buf,
\\{{"id": "{s}", "name": "User {s}"}}
, .{ id, id }) catch
return Response.internalError(allocator);
return Response.json(allocator, StatusCode.ok, body);
}
fn handleCreateUser(
request: *const Request,
params: *const RouteParams,
allocator: std.mem.Allocator,
) anyerror!Response {
_ = params;
// Verify we have a body
const body = request.body orelse
return Response.text(allocator, StatusCode.bad_request, "Request body required\n");
// Verify Content-Type is JSON
const content_type = request.getHeader("Content-Type") orelse "text/plain";
if (!std.mem.startsWith(u8, content_type, "application/json")) {
return Response.text(
allocator,
StatusCode.bad_request,
"Content-Type must be application/json\n",
);
}
// In a real app, parse the JSON body, validate it, create the user.
// For now, echo it back with a 201 Created status.
var resp_buf: [512]u8 = undefined;
const resp_body = std.fmt.bufPrint(&resp_buf,
\\{{"message": "User created", "received_bytes": {d}}}
, .{body.len}) catch
return Response.internalError(allocator);
return Response.json(allocator, StatusCode.created, resp_body);
}
fn handleListUsers(
request: *const Request,
params: *const RouteParams,
allocator: std.mem.Allocator,
) anyerror!Response {
_ = request;
_ = params;
return Response.json(allocator, StatusCode.ok,
\\[{"id": "1", "name": "Alice"}, {"id": "2", "name": "Bob"}]
);
}
A few things worth noting. The \\ multiline string literal syntax is Zig's raw string -- it preserves everything literally, no escaping needed. Perfect for embedding JSON in source code. We first encountered this way back in episode 5 and it continues to be one of my favorite Zig features.
The handlers use params.get("id") to extract path parameters. If a required parameter is somehow missing (shouldn't happen if the route pattern is correct, but defensive programming never hurts), we return a 400. The getHeader function from our Request struct (episode 51) gives us case-insensitive header lookup -- exactly what we need for checking Content-Type.
Wiring the router into the server
Now we connect the router to the server's request processing. Remember the processRequest function from episode 51? We replace the placeholder sendResponse call with router dispatch:
fn processRequest(self: *Server, stream: net.Stream) !void {
var read_buf = ReadBuffer.init();
const header_end = try read_buf.readUntilHeaders(stream);
const raw = read_buf.data()[0..header_end];
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 ..];
const req_info = try parseRequestLine(request_line);
const headers = try parseHeaders(self.allocator, header_block);
defer self.allocator.free(headers);
var request = Request{
.method = req_info.method,
.path = req_info.path,
.version = req_info.version,
.headers = headers,
.body = null,
};
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);
// --- Router dispatch (NEW) ---
var response = if (self.router.match(request.method, request.path)) |route_match| blk: {
// Route matched -- call the handler
break :blk route_match.handler(
&request,
&route_match.params,
self.allocator,
) catch {
// Handler errored -- return 500
break :blk Response.internalError(self.allocator) catch
return error.OutOfMemory;
};
} else blk: {
// No route matched -- 404
break :blk Response.notFound(self.allocator) catch
return error.OutOfMemory;
};
defer response.deinit();
// Serialize and send
var resp_buf: [65536]u8 = undefined;
const serialized = response.serialize(&resp_buf) catch
return error.MalformedRequest;
_ = try stream.write(serialized);
}
The dispatch logic is clean: try to match a route, call the handler if found, fall back to 404 if not. If the handler itself returns an error (panics, runs out of memory, whatever), we catch that and turn it into a 500. The labeled blocks (blk:) with break :blk are the pattern we've been using throughout the series when we need if-else to produce a value -- Zig doesn't have ternary operators, but labeled blocks fill that role nicely.
The 64 KB response buffer should handle any reasonable API response. If your JSON payloads are bigger than 64 KB you'd want to stream the response instead of buffering it, but for a learning project (and honestly for quit some real APIs) this is fine.
Registration: setting up routes in main
Putting it all together, here's how you register routes and start the server:
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", .{});
}
const allocator = gpa.allocator();
var router = Router.init(allocator);
defer router.deinit();
// Register routes
try router.addRoute(.GET, "/health", handleHealth);
try router.addRoute(.GET, "/api/users", handleListUsers);
try router.addRoute(.GET, "/api/users/:id", handleGetUser);
try router.addRoute(.POST, "/api/users", handleCreateUser);
var server = try Server.init(allocator, 8080, &router);
defer server.deinit();
server.run() catch |err| {
std.log.err("Server failed: {}", .{err});
return err;
};
}
That's a complete, working REST API. Four routes, proper method-based routing (GET /api/users lists users, POST /api/users creates one), path parameters, JSON responses, error handling. And zero external dependencies -- just Zig's standard library.
The Server struct now takes a pointer to the Router in its init function, storing it as a field. This way the server doesn't own the router (the router lives in main and gets cleaned up there), but can use it to dispatch requests. Clean separation of concerns.
Testing the router
We can test the router's matching logic without running a real server. The path matching is pure logic -- give it a method and path, check if it returns the right handler and parameters:
test "exact route match" {
const allocator = std.testing.allocator;
var router = Router.init(allocator);
defer router.deinit();
try router.addRoute(.GET, "/health", handleHealth);
try router.addRoute(.GET, "/api/users", handleListUsers);
const result = router.match(.GET, "/health");
try std.testing.expect(result != null);
const result2 = router.match(.GET, "/api/users");
try std.testing.expect(result2 != null);
// No match for unregistered path
const result3 = router.match(.GET, "/nonexistent");
try std.testing.expect(result3 == null);
// No match for wrong method
const result4 = router.match(.POST, "/health");
try std.testing.expect(result4 == null);
}
test "parameterized route match" {
const allocator = std.testing.allocator;
var router = Router.init(allocator);
defer router.deinit();
try router.addRoute(.GET, "/api/users/:id", handleGetUser);
const result = router.match(.GET, "/api/users/42");
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("42", result.?.params.get("id").?);
// Different ID
const result2 = router.match(.GET, "/api/users/abc");
try std.testing.expect(result2 != null);
try std.testing.expectEqualStrings("abc", result2.?.params.get("id").?);
// Too many segments -- no match
const result3 = router.match(.GET, "/api/users/42/extra");
try std.testing.expect(result3 == null);
// Too few segments -- no match
const result4 = router.match(.GET, "/api/users");
try std.testing.expect(result4 == null);
}
test "multiple parameters" {
const allocator = std.testing.allocator;
var router = Router.init(allocator);
defer router.deinit();
try router.addRoute(.GET, "/posts/:year/:month", handleHealth);
const result = router.match(.GET, "/posts/2026/05");
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("2026", result.?.params.get("year").?);
try std.testing.expectEqualStrings("05", result.?.params.get("month").?);
}
test "method differentiation" {
const allocator = std.testing.allocator;
var router = Router.init(allocator);
defer router.deinit();
try router.addRoute(.GET, "/api/users", handleListUsers);
try router.addRoute(.POST, "/api/users", handleCreateUser);
const get_result = router.match(.GET, "/api/users");
try std.testing.expect(get_result != null);
const post_result = router.match(.POST, "/api/users");
try std.testing.expect(post_result != null);
// Same path, different handler for each method
const del_result = router.match(.DELETE, "/api/users");
try std.testing.expect(del_result == null);
}
test "Response serialization" {
const allocator = std.testing.allocator;
var resp = try Response.json(allocator, StatusCode.ok,
\\{"status": "ok"}
);
defer resp.deinit();
var buf: [4096]u8 = undefined;
const serialized = try resp.serialize(&buf);
// Verify it starts with the right status line
try std.testing.expect(
std.mem.startsWith(u8, serialized, "HTTP/1.1 200 OK\r\n"),
);
// Verify Content-Type header present
try std.testing.expect(
std.mem.indexOf(u8, serialized, "Content-Type: application/json") != null,
);
// Verify body is included
try std.testing.expect(
std.mem.indexOf(u8, serialized, "{\"status\": \"ok\"}") != null,
);
}
test "RouteParams get" {
var params = RouteParams.init();
params.add("id", "42");
params.add("name", "scipio");
try std.testing.expectEqualStrings("42", params.get("id").?);
try std.testing.expectEqualStrings("scipio", params.get("name").?);
try std.testing.expect(params.get("missing") == null);
}
And for the integration test, fire it up and hit it with curl:
$ zig build-exe http_server.zig && ./http_server &
Listening on port 8080...
$ curl -s http://localhost:8080/health | python3 -m json.tool
{
"status": "healthy"
}
$ curl -s http://localhost:8080/api/users | python3 -m json.tool
[
{
"id": "1",
"name": "Alice"
},
{
"id": "2",
"name": "Bob"
}
]
$ curl -s http://localhost:8080/api/users/42 | python3 -m json.tool
{
"id": "42",
"name": "User 42"
}
$ curl -s -X POST -H "Content-Type: application/json" \
-d '{"name":"Charlie"}' http://localhost:8080/api/users
{"message": "User created", "received_bytes": 18}
$ curl -s http://localhost:8080/nonexistent
404 Not Found
$ curl -s -X DELETE http://localhost:8080/health
405 Method Not Allowed
Everything works. GET routes return JSON, parameterized routes extract the ID correctly, POST receives and acknowledges the body, and unknown routes get a clean 404. The DELETE request to /health returns 405 because we only registered a GET handler for that path -- the router checks the method and rejects mismatches. (Well actually, our current implementation returns 404 for method mismatches since we just check "does any route match?". To get a proper 405 you'd check if the path matches but the method doesn't -- something to improve if you want to be strictly compliant with RFC 9110.)
Query string handling: a practical addition
One thing you'll notice immediately when building a real API is that path parameters aren't enough. Clients send query strings too: /api/users?page=2&limit=20. Our current router doesn't handle those because request.path contains the full path including the query string, and matching /api/users?page=2 against the pattern /api/users fails (the ?page=2 part gets included in the last segment comparison).
The fix is simple -- strip the query string before routing, and parse it into a separate structure:
fn splitPathAndQuery(full_path: []const u8) struct {
path: []const u8,
query: ?[]const u8,
} {
if (std.mem.indexOf(u8, full_path, "?")) |qmark| {
return .{
.path = full_path[0..qmark],
.query = if (qmark + 1 < full_path.len)
full_path[qmark + 1 ..]
else
null,
};
}
return .{ .path = full_path, .query = null };
}
const QueryParams = struct {
pairs: [16]Pair,
len: usize,
const Pair = struct {
key: []const u8,
value: []const u8,
};
fn parse(query: []const u8) QueryParams {
var result = QueryParams{
.pairs = undefined,
.len = 0,
};
var iter = std.mem.splitScalar(u8, query, '&');
while (iter.next()) |pair| {
if (result.len >= 16) break;
if (std.mem.indexOf(u8, pair, "=")) |eq| {
result.pairs[result.len] = .{
.key = pair[0..eq],
.value = pair[eq + 1 ..],
};
result.len += 1;
}
}
return result;
}
fn get(self: *const QueryParams, key: []const u8) ?[]const u8 {
for (self.pairs[0..self.len]) |pair| {
if (std.mem.eql(u8, pair.key, key)) return pair.value;
}
return null;
}
};
Same fixed-size array approach as RouteParams -- 16 query parameters with zero heap allocation. In the processRequest function, you'd call splitPathAndQuery(request.path) before routing, use .path for the router match, and pass the parsed QueryParams to the handler alongside the RouteParams. I'm leaving that wiring as an exercise -- the concept is straightforward and you've seen enough of the pattern by now.
Note that we're NOT doing URL decoding here (converting %20 to spaces, + to spaces, etc.). A production URL parser needs that, but for a learning project, raw query string values work fine for most testing scenarios. URL decoding would be a good addition in the static files episode coming up next.
Design decisions and what's coming
A few things worth reflecting on regarding our router design:
Linear route matching. We walk through all registered routes sequentially. This is O(n) in the number of routes. Production routers like those in Go's net/http or Rust's actix-web use radix trees for O(log n) matching. For us, with a handful of routes, linear is actually faster because there's no tree overhead. If you wanted to optimize, the algorithm from episode 22 (hash maps) could help -- hash the exact-match routes and only fall back to linear scan for parameterized ones.
Fixed-size parameter arrays. Both RouteParams and QueryParams use stack arrays with hard limits (8 and 16). This means zero allocations for the common case, which is great for latency. The trade-off is that you can't have 20 path paramters in one URL. I argue that if you hit that limit, your API design needs help more than your router does.
No regex. Many routers support regex patterns in routes (e.g. /users/:id([0-9]+) to constrain the parameter to digits). We skip this entirely. Pattern matching with constraints adds quite some complexity and for most APIs you validate parameters in the handler anyway. Keep it simple.
No middleware yet. A real framework would let you attach middleware (logging, authentication, CORS headers) that runs before/after handlers. That's coming in a future episode where we add request logging and static file serving. The handler signature already returns a Response, so wrapping handlers with middleware is a matter of creating functions that take a handler and return a new handler -- the same composability pattern we explored with function pointers in episode 13.
Next time we'll tackle static file serving (reading files from disk, MIME type detection, directory listing) and request logging middleware. Those two features turn our API server into something that can serve actual web pages alongside API endpoints -- a complete web server.
Wat we geleerd hebben
- The
Responsestruct as a builder: set status, add headers, set body, then serialize everything into a byte buffer for the socket - Named status code constants using struct namespacing (
StatusCode.ok,StatusCode.not_found) to prevent code/reason mismatches - Convenience constructors (
Response.json,Response.text,Response.notFound) that set Content-Type automatically - Pre-parsing route patterns into literal and parameter segments at registration time so matching is fast per-request
- The
RouteParamsstruct with a fixed-size stack array to capture:namevalues from URLs without heap allocation - Linear route matching: walk through all registered routes, compare segments in parallel with the request path
- Method-based routing: same path can map to different handlers for GET vs POST vs DELETE
- Handler function signature: receives
*const Request,*const RouteParams, and an allocator, returns aResponse - Wiring the router into the server's
processRequestwith automatic 404 for unmatched paths and 500 for handler errors - Query string handling: splitting path from query at the
?character and parsing key-value pairs from&-separated segments - Testing routers with pure logic tests (no network needed) by calling
router.match()directly and checking the result
De groeten!