Learn Zig Series (#54) - HTTP Server: Middleware and Logging
Learn Zig Series (#54) - HTTP Server: Middleware and Logging

Project E: HTTP Server from Scratch (4/4)
What will I learn
- You will learn the middleware pattern: wrapping handlers with additional behavior using function composition;
- You will learn how to build request logging middleware that records method, path, status code, and response time;
- You will learn how to implement CORS middleware that adds cross-origin headers to every response;
- You will learn how to build authentication middleware that checks API keys in request headers;
- You will learn how to compose multiple middleware layers into a chain with a generic
chainfunction; - You will learn how to build error-handling middleware that catches handler errors and returns clean 500 responses;
- You will learn how to measure request timing and collect basic performance metrics;
- You will learn a project retrospective: what we built across four episodes and how it compares to real HTTP frameworks.
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
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging (this post)
Learn Zig Series (#54) - HTTP Server: Middleware and Logging
This is it -- the final episode of our HTTP server project. Over the last three episodes we built the accept loop and request parser (episode 51), a path-based router with response generation (episode 52), and static file serving with MIME detection, caching, and range requests (episode 53). What's missing is the glue that makes a server production-ready: middleware. Logging every request so you can debug problems. Adding CORS headers so browsers don't block your API. Checking API keys before letting requests through. Measuring how long things take so you know what's slow.
The middleware pattern is one of those ideas that's simple in concept but powerful in practice. You wrap a handler with another function that does something before and/or after the real work. Stack enough of these wrappers and you've got a pipeline that handles cross-cutting concerns without polluting your application logic. If you've ever used Express.js, Django, or any web framework really -- you've used middleware. Today we build it from scratch in Zig. Here we go!
The middleware pattern: function composition
The core idea is dead simple. A middleware is a function that takes a handler and returns a new handler. The new handler does some extra work (logging, auth checking, header injection) and then calls the original handler. In code, the type signature looks like this:
const std = @import("std");
const HandlerFn = *const fn (
*const Request,
*const RouteParams,
std.mem.Allocator,
) anyerror!Response;
// A middleware takes a handler and returns a new handler.
// Since Zig doesn't have closures that capture arbitrary state,
// we use a struct with a function pointer instead.
const Middleware = struct {
inner: HandlerFn,
wrapFn: *const fn (*const Middleware, *const Request, *const RouteParams, std.mem.Allocator) anyerror!Response,
fn call(
self: *const Middleware,
req: *const Request,
params: *const RouteParams,
allocator: std.mem.Allocator,
) anyerror!Response {
return self.wrapFn(self, req, params, allocator);
}
};
Now here's the thing -- Zig doesn't have closures. In JavaScript you'd just write (req) => { log(req); return handler(req); } and the inner handler reference gets captured automatically. In Zig we need to be explicit about what state the middleware carries. The Middleware struct holds the inner handler and a function pointer for its wrapping logic. This is the same pattern we used for type erasure back in episode 13 -- a struct that carries both data and behavior.
But there's a more practical approach for our server. Instead of building a generic middleware type, we can use a simpler pattern: each middleware is a function that receives the request, does its pre-processing, calls the next handler directly, does its post-processing, and returns the result. We compose them by nesting calls. Let me show you what I mean with actual middleware implementations.
Request logging middleware
The first middleware every server needs is logging. When something goes wrong in production (and it will), the request log is your first diagnostic tool. We want to record: which HTTP method was used, what path was requested, what status code we returned, and how long the whole thing took.
const Timer = struct {
start: i128,
fn begin() Timer {
return .{ .start = std.time.nanoTimestamp() };
}
fn elapsedMs(self: Timer) f64 {
const now = std.time.nanoTimestamp();
const diff = now - self.start;
return @as(f64, @floatFromInt(diff)) / 1_000_000.0;
}
};
fn loggingMiddleware(
request: *const Request,
params: *const RouteParams,
allocator: std.mem.Allocator,
next: HandlerFn,
) anyerror!Response {
const timer = Timer.begin();
// Call the actual handler
const response = try next(request, params, allocator);
const elapsed = timer.elapsedMs();
// Log the request
const stdout = std.io.getStdOut().writer();
stdout.print("{s} {s} -> {d} ({d:.2}ms)\n", .{
@tagName(request.method),
request.path,
response.status_code,
elapsed,
}) catch {};
return response;
}
The Timer struct uses std.time.nanoTimestamp() for high-resolution timing. We capture the start time before calling next (the actual handler), then compute the elapsed milliseconds after it returns. The log output looks something like GET /api/users -> 200 (0.43ms) -- compact, useful, and similar to what you'd see in nginx access logs.
Notice that stdout.print uses catch {} to silently discard write errors. If logging itself fails (stdout closed, disk full, whatever), we don't want to crash the server or affect the actual response. This is a deliberate design choice -- logging is observational, not functional. The request was already handled; the response is already built. If the log line doesn't make it, that's unfortunate but not catastrophic.
Having said that, the @tagName(request.method) call converts the method enum to a string at comptime. We defined our Method enum in episode 51 and Zig's @tagName builtin gives us "GET", "POST", etc. directly -- no manual string mapping needed.
CORS middleware
If your API is called from a browser running on a different domain (which is basically every single-page app talking to a backend), the browser enforces Cross-Origin Resource Sharing (CORS). Without the right headers, the browser blocks the response entirely. This is one of those things that drives developers absolutely crazy the first time they hit it, because the server responds fine -- curl works perfectly -- but the browser just refuses to use the response.
fn corsMiddleware(
request: *const Request,
params: *const RouteParams,
allocator: std.mem.Allocator,
next: HandlerFn,
) anyerror!Response {
// Handle preflight requests
if (request.method == .OPTIONS) {
var resp = Response.init(allocator);
resp.setStatus(204, "No Content");
try resp.addHeader("Access-Control-Allow-Origin", "*");
try resp.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
try resp.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key");
try resp.addHeader("Access-Control-Max-Age", "86400");
return resp;
}
// Call the actual handler
var response = try next(request, params, allocator);
// Add CORS headers to every response
try response.addHeader("Access-Control-Allow-Origin", "*");
try response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
try response.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key");
return response;
}
Two things happen here. First, preflight requests: before sending a POST or PUT with custom headers, the browser sends an OPTIONS request asking "are you cool with this?". Our middleware catches OPTIONS requests and replies immediately with a 204 (no body, just headers saying "yes, go ahead"). The Access-Control-Max-Age: 86400 tells the browser to cache this answer for 24 hours so it doesn't have to ask again for every single request.
Second, actual responses: after the handler runs, we add CORS headers to the response. The Access-Control-Allow-Origin: * is the permissive setting -- it allows ANY domain to call your API. For production you'd want to restrict this to specific origins (your frontend's domain), but * is fine for development and for public APIs.
The list of allowed headers (Content-Type, Authorization, X-API-Key) matches what our authentication middleware will check. Browsers require explicit permission for custom headers -- if you add a custom header to a fetch() call and it's not in this list, the browser blocks the request before it even reaches your server.
Authentication middleware
Most APIs need some form of authentication. We'll implement the simplest useful pattern: API key checking. The client sends an X-API-Key header, the middleware checks it against a known set of valid keys, and either lets the request through or returns 401 Unauthorized.
const AuthMiddleware = struct {
valid_keys: []const []const u8,
public_paths: []const []const u8,
fn init(
valid_keys: []const []const u8,
public_paths: []const []const u8,
) AuthMiddleware {
return .{
.valid_keys = valid_keys,
.public_paths = public_paths,
};
}
fn isPublicPath(self: *const AuthMiddleware, path: []const u8) bool {
for (self.public_paths) |public| {
if (std.mem.eql(u8, path, public)) return true;
// Also check prefix match for paths ending with *
if (public.len > 0 and public[public.len - 1] == '*') {
if (std.mem.startsWith(u8, path, public[0 .. public.len - 1])) {
return true;
}
}
}
return false;
}
fn authenticate(
self: *const AuthMiddleware,
request: *const Request,
params: *const RouteParams,
allocator: std.mem.Allocator,
next: HandlerFn,
) anyerror!Response {
// Skip auth for public paths
if (self.isPublicPath(request.path)) {
return next(request, params, allocator);
}
// Check for API key
const api_key = request.getHeader("X-API-Key") orelse {
return Response.text(
allocator,
StatusCode.unauthorized,
"401 Unauthorized: missing X-API-Key header\n",
);
};
// Validate the key
var valid = false;
for (self.valid_keys) |key| {
if (std.mem.eql(u8, api_key, key)) {
valid = true;
break;
}
}
if (!valid) {
return Response.text(
allocator,
StatusCode.unauthorized,
"401 Unauthorized: invalid API key\n",
);
}
// Key is valid -- proceed to handler
return next(request, params, allocator);
}
};
The AuthMiddleware is a struct because it needs state: the list of valid API keys and the list of public paths that don't require authentication. The health check endpoint, for example, should always be accessible (monitoring tools need it). Static files might also be public depending on your use case.
The isPublicPath function supports both exact matches and prefix matches with a trailing * wildcard. So /health matches exactly, and /static/* matches anything starting with /static/. This is a simple but practical approach -- enough for most APIs without building a full pattern matching engine.
One thing to note about the key comparison: we're using std.mem.eql which does a byte-by-byte comparison. This is technically vulnerable to timing attacks (an attacker could theoretically measure response times to figure out how many characters of the key are correct). For a learning project this is fine, but in production you'd want a constant-time comparison function that always checks every byte regardless of where the mismatch is. Zig's standard library doesn't have one built-in, but it's trivial to write -- loop through all bytes, XOR each pair, OR the results together, check if the final value is zero.
Composing middleware: the chain function
Now we have three middleware functions. How do we compose them? We need a way to say "first log the request, then check CORS, then authenticate, then call the handler." The composition needs to work from outside in -- logging wraps CORS which wraps auth which wraps the handler.
const MiddlewareStack = struct {
auth: *const AuthMiddleware,
fn wrapHandler(
self: *const MiddlewareStack,
handler: HandlerFn,
request: *const Request,
params: *const RouteParams,
allocator: std.mem.Allocator,
) anyerror!Response {
// Layer 1 (outermost): Logging
// Layer 2: CORS
// Layer 3: Auth
// Layer 4 (innermost): actual handler
const timer = Timer.begin();
// CORS: handle preflight
if (request.method == .OPTIONS) {
var resp = Response.init(allocator);
resp.setStatus(204, "No Content");
try resp.addHeader("Access-Control-Allow-Origin", "*");
try resp.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
try resp.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key");
try resp.addHeader("Access-Control-Max-Age", "86400");
logRequest(request, &resp, timer);
return resp;
}
// Auth: check API key
if (!self.auth.isPublicPath(request.path)) {
const api_key = request.getHeader("X-API-Key") orelse {
var resp = try Response.text(
allocator,
StatusCode.unauthorized,
"401 Unauthorized: missing X-API-Key header\n",
);
try resp.addHeader("Access-Control-Allow-Origin", "*");
logRequest(request, &resp, timer);
return resp;
};
var valid = false;
for (self.auth.valid_keys) |key| {
if (std.mem.eql(u8, api_key, key)) {
valid = true;
break;
}
}
if (!valid) {
var resp = try Response.text(
allocator,
StatusCode.unauthorized,
"401 Unauthorized: invalid API key\n",
);
try resp.addHeader("Access-Control-Allow-Origin", "*");
logRequest(request, &resp, timer);
return resp;
}
}
// Call the actual handler
var response = handler(request, params, allocator) catch |err| {
// Error-handling middleware: catch handler errors
std.log.err("Handler error: {}", .{err});
var resp = Response.internalError(allocator) catch
return error.OutOfMemory;
try resp.addHeader("Access-Control-Allow-Origin", "*");
logRequest(request, &resp, timer);
return resp;
};
// Add CORS headers to response
try response.addHeader("Access-Control-Allow-Origin", "*");
logRequest(request, &response, timer);
return response;
}
fn logRequest(request: *const Request, response: *const Response, timer: Timer) void {
const elapsed = timer.elapsedMs();
const stdout = std.io.getStdOut().writer();
stdout.print("{s} {s} -> {d} ({d:.2}ms)\n", .{
@tagName(request.method),
request.path,
response.status_code,
elapsed,
}) catch {};
}
};
I went back and forth on the architecture here. The "pure" approach would be composable individual middleware functions chained together like log(cors(auth(handler))). That works beautifully in languages with closures, but in Zig it gets awkward fast because each layer needs to return a HandlerFn and Zig functions can't capture variables from their enclosing scope.
The pragmatic approach (what I actually built above) is a MiddlewareStack struct that applies all layers in a single function. It's less elegant but more readable and more Zig-idiomatic. The execution order is explicit: CORS preflight check first, then auth, then the handler, then CORS headers on the response, with logging wrapping the entire thing. If any layer rejects the request (auth fails), we still add CORS headers (so the browser can read the error) and still log the request (so we can see failed auth attempts).
The error-handling middleware is baked into the handler call: the catch |err| block catches ANY error from the handler and converts it to a clean 500 response. This means handler authors don't need to worry about catching every possible error -- unhandled errors become 500s automatically. We log the actual error to stderr via std.log.err for debugging, but the client only sees the generic error message.
Request timing and performance metrics
Beyond per-request logging, it's useful to track aggregate metrics: how many requests per second, average response time, the distribution of status codes. Here's a simple metrics collector:
const Metrics = struct {
total_requests: u64,
total_errors: u64,
status_counts: [6]u64, // index 0=1xx, 1=2xx, 2=3xx, 3=4xx, 4=5xx, 5=other
total_duration_ns: i128,
slowest_ns: i128,
fastest_ns: i128,
mutex: std.Thread.Mutex,
fn init() Metrics {
return .{
.total_requests = 0,
.total_errors = 0,
.status_counts = [_]u64{0} ** 6,
.total_duration_ns = 0,
.slowest_ns = 0,
.fastest_ns = std.math.maxInt(i128),
.mutex = .{},
};
}
fn record(self: *Metrics, status_code: u16, duration_ns: i128) void {
self.mutex.lock();
defer self.mutex.unlock();
self.total_requests += 1;
self.total_duration_ns += duration_ns;
if (duration_ns > self.slowest_ns) self.slowest_ns = duration_ns;
if (duration_ns < self.fastest_ns) self.fastest_ns = duration_ns;
// Bucket by status code class
const bucket: usize = switch (status_code) {
100...199 => 0,
200...299 => 1,
300...399 => 2,
400...499 => 3,
500...599 => 4,
else => 5,
};
self.status_counts[bucket] += 1;
if (status_code >= 500) self.total_errors += 1;
}
fn averageMs(self: *const Metrics) f64 {
if (self.total_requests == 0) return 0;
const total_f: f64 = @floatFromInt(self.total_duration_ns);
const count_f: f64 = @floatFromInt(self.total_requests);
return (total_f / count_f) / 1_000_000.0;
}
fn printSummary(self: *const Metrics) void {
const stdout = std.io.getStdOut().writer();
stdout.print(
\\--- Server Metrics ---
\\Total requests: {d}
\\ 2xx: {d} 3xx: {d} 4xx: {d} 5xx: {d}
\\Avg response: {d:.2}ms
\\Fastest: {d:.2}ms
\\Slowest: {d:.2}ms
\\Error rate: {d:.1}%
\\
, .{
self.total_requests,
self.status_counts[1],
self.status_counts[2],
self.status_counts[3],
self.status_counts[4],
self.averageMs(),
@as(f64, @floatFromInt(self.fastest_ns)) / 1_000_000.0,
@as(f64, @floatFromInt(self.slowest_ns)) / 1_000_000.0,
if (self.total_requests > 0)
@as(f64, @floatFromInt(self.total_errors)) / @as(f64, @floatFromInt(self.total_requests)) * 100.0
else
0.0,
}) catch {};
}
};
The Metrics struct tracks everything with a mutex for thread safety -- even though our current server is single-threaded (we handle one request at a time in the accept loop), the mutex costs nearly nothing when uncontended and makes the code ready for a multi-threaded version later. We covered mutexes in episode 30 and the pattern is the same: lock, update, unlock.
The status code bucketing uses Zig's range pattern matching (100...199 => 0) which is both clean and fast -- the compiler turns it into a simple comparison chain. We track 2xx (success), 3xx (redirect), 4xx (client error), and 5xx (server error) separately because the distribution tells you different things about your system. Lots of 4xx might mean broken client code or someone poking your API. Lots of 5xx means your handlers are crashing.
The averageMs function does integer-to-float conversion with @floatFromInt, which we've used before but is worth noting here: Zig requires explicit casts between numeric types. There's no implicit widening or float conversion -- you always know exactly when a type conversion happens. This is the kind of thing that prevents an entire category of bugs in C (where mixing int and double in arithmetic can produce surprising results).
Wiring everything into the server
Here's the final processRequest function that brings it all together -- middleware stack, metrics, and the router:
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);
const pq = splitPathAndQuery(request.path);
const clean_path = pq.path;
// Find the handler via router, or use static file fallback
const handler: HandlerFn = if (self.router.match(request.method, clean_path)) |route_match|
route_match.handler
else
null; // will fall through to static/404
// Apply middleware stack and dispatch
var response = self.middleware_stack.wrapHandler(
handler orelse staticOrNotFound,
&request,
if (self.router.match(request.method, clean_path)) |rm| &rm.params else &RouteParams.init(),
self.allocator,
);
defer response.deinit();
// Record metrics
// (timer is inside wrapHandler, but we can also track here)
self.metrics.record(response.status_code, 0);
// Serialize and send
var resp_buf: [65536]u8 = undefined;
const serialized = response.serialize(&resp_buf) catch
return error.MalformedRequest;
_ = try stream.write(serialized);
}
And a metrics endpoint so you can check how the server is doing:
fn handleMetrics(
request: *const Request,
params: *const RouteParams,
allocator: std.mem.Allocator,
) anyerror!Response {
_ = request;
_ = params;
// Get metrics from the global/server instance
var buf: [512]u8 = undefined;
const body = std.fmt.bufPrint(&buf,
\\{{"total_requests": {d}, "2xx": {d}, "4xx": {d}, "5xx": {d}, "avg_ms": {d:.2}}}
, .{
server_metrics.total_requests,
server_metrics.status_counts[1],
server_metrics.status_counts[3],
server_metrics.status_counts[4],
server_metrics.averageMs(),
}) catch return Response.internalError(allocator);
return Response.json(allocator, StatusCode.ok, body);
}
The metrics endpoint itself is just another route -- /metrics or /api/metrics depending on your preference. It returns JSON so monitoring tools can scrape it. If you've worked with Prometheus or Grafana, this is the same concept: the application exposes its own internal stats via an HTTP endpoint.
Testing the middleware stack
Let's verify everything works with some targeted tests:
test "CORS preflight returns 204 with correct headers" {
const allocator = std.testing.allocator;
const auth = AuthMiddleware.init(
&[_][]const u8{"test-key-123"},
&[_][]const u8{"/health"},
);
const stack = MiddlewareStack{ .auth = &auth };
var request = Request{
.method = .OPTIONS,
.path = "/api/users",
.version = "HTTP/1.1",
.headers = &[_]Header{},
.body = null,
};
var params = RouteParams.init();
var resp = try stack.wrapHandler(handleHealth, &request, ¶ms, allocator);
defer resp.deinit();
try std.testing.expect(resp.status_code == 204);
// Verify CORS headers are present
var found_origin = false;
for (resp.headers.items) |hdr| {
if (std.mem.eql(u8, hdr.name, "Access-Control-Allow-Origin")) {
found_origin = true;
try std.testing.expectEqualStrings("*", hdr.value);
}
}
try std.testing.expect(found_origin);
}
test "auth middleware rejects missing key" {
const allocator = std.testing.allocator;
const auth = AuthMiddleware.init(
&[_][]const u8{"secret-key"},
&[_][]const u8{"/health"},
);
const stack = MiddlewareStack{ .auth = &auth };
var request = Request{
.method = .GET,
.path = "/api/users",
.version = "HTTP/1.1",
.headers = &[_]Header{},
.body = null,
};
var params = RouteParams.init();
var resp = try stack.wrapHandler(handleListUsers, &request, ¶ms, allocator);
defer resp.deinit();
try std.testing.expect(resp.status_code == 401);
}
test "auth middleware allows public paths" {
const allocator = std.testing.allocator;
const auth = AuthMiddleware.init(
&[_][]const u8{"secret-key"},
&[_][]const u8{"/health"},
);
const stack = MiddlewareStack{ .auth = &auth };
var request = Request{
.method = .GET,
.path = "/health",
.version = "HTTP/1.1",
.headers = &[_]Header{},
.body = null,
};
var params = RouteParams.init();
var resp = try stack.wrapHandler(handleHealth, &request, ¶ms, allocator);
defer resp.deinit();
try std.testing.expect(resp.status_code == 200);
}
test "metrics tracking" {
var metrics = Metrics.init();
metrics.record(200, 1_000_000); // 1ms
metrics.record(200, 2_000_000); // 2ms
metrics.record(404, 500_000); // 0.5ms
metrics.record(500, 5_000_000); // 5ms
try std.testing.expect(metrics.total_requests == 4);
try std.testing.expect(metrics.status_counts[1] == 2); // 2xx
try std.testing.expect(metrics.status_counts[3] == 1); // 4xx
try std.testing.expect(metrics.status_counts[4] == 1); // 5xx
try std.testing.expect(metrics.total_errors == 1);
}
And the curl integration test to see it all in action:
$ zig build-exe http_server.zig && ./http_server &
Serving on port 8080 with middleware stack enabled
$ curl -s http://localhost:8080/health
{"status": "healthy"}
# Server logs: GET /health -> 200 (0.12ms)
$ curl -s http://localhost:8080/api/users
401 Unauthorized: missing X-API-Key header
# Server logs: GET /api/users -> 401 (0.03ms)
$ curl -s -H "X-API-Key: my-secret-key" http://localhost:8080/api/users
[{"id": "1", "name": "Alice"}, {"id": "2", "name": "Bob"}]
# Server logs: GET /api/users -> 200 (0.31ms)
$ curl -sI -X OPTIONS http://localhost:8080/api/users
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key
Access-Control-Max-Age: 86400
$ curl -s http://localhost:8080/style.css
body { color: red; }
# Server logs: GET /style.css -> 200 (0.55ms)
$ curl -s http://localhost:8080/metrics
{"total_requests": 5, "2xx": 3, "4xx": 1, "5xx": 0, "avg_ms": 0.26}
Logging, CORS, auth, static files, metrics -- all working together, all composable, all from scratch.
Project retrospective: what we built and what's missing
Over four episodes we built a fully functional HTTP server in Zig with zero external dependencies:
- Episode 51: TCP accept loop, HTTP request parsing (method, path, headers, body), buffered reading, error responses
- Episode 52: Response struct with builder pattern, path-based router with parameter extraction, JSON/HTML/text convenience constructors, query string parsing
- Episode 53: Static file serving, MIME type detection, directory traversal prevention, caching headers (Last-Modified), range requests (206 Partial Content), directory listing
- Episode 54: Middleware pattern (logging, CORS, auth), request timing, performance metrics, error-handling middleware
That's a legitimate web server. It can serve a React or Vue frontend (static files), expose a REST API (router + handlers), protect endpoints (auth middleware), support cross-origin requests (CORS), and tell you how it's doing (logging + metrics). You could run this in production for a small personal project and it would work. Not every project needs nginx.
Having said that, let me be honest about what's missing compared to something like nginx, Go's net/http, or Rust's Actix:
Keep-alive connections. We close the connection after every response (Connection: close). HTTP/1.1 supports persistent connections where multiple requests reuse the same TCP socket. This is a big performance win because TCP connection setup involves a three-way handshake. Adding keep-alive means managing connection state and timeouts, which adds quite some complexity.
Chunked transfer encoding. For responses where we don't know the size up front (streaming data, server-sent events), HTTP supports Transfer-Encoding: chunked where the body is sent in pieces with length prefixes. Our current approach requires knowing the full body length before sending.
HTTPS / TLS. Our server is plaintext HTTP only. Adding TLS would require either linking against a C library (OpenSSL, BoringSSL) or implementing TLS in Zig. The C interop approach is totaly doable -- we covered it in episode 27 -- but it's a big chunk of work.
Concurrency. We handle one request at a time. A real server needs to handle thousands of concurrent connections. The standard approach is either threads (spawn a thread per connection) or an event loop (epoll/kqueue with non-blocking I/O). Zig's standard library has std.Thread (we used it in episode 30) and std.os.epoll for this. Multi-threaded HTTP serving is an entire project in itself.
HTTP/2 and HTTP/3. We implement HTTP/1.1 only. HTTP/2 adds multiplexing (multiple requests over one connection), header compression, and server push. HTTP/3 uses QUIC instead of TCP. These are separate protocol implementations, not incremental additions.
None of these missing features diminish what we built. The point was to understand how HTTP servers work at the socket level -- and we do now. Every time you use Express, Django, or actix-web in the future, you'll know what's happening underneath those abstractions: accept loops, request parsing, routing, response serialization, middleware chains, and static file serving. That understanding is worth more than the code itself ;-)
Wat we geleerd hebben
- The middleware pattern: wrapping handlers with pre-processing and post-processing to handle cross-cutting concerns without polluting application logic
- Request logging middleware: capturing method, path, status code, and elapsed time for every request using
std.time.nanoTimestamp()for high-resolution timing - CORS middleware: handling OPTIONS preflight requests with 204 and injecting
Access-Control-Allow-*headers into every response so browsers don't block cross-origin requests - Authentication middleware with a struct that carries state (valid keys, public paths) and supports both exact and wildcard path matching for bypassing auth on public routes
- Composing middleware into a
MiddlewareStackthat applies layers in order: CORS preflight, auth, handler call with error catching, CORS headers on response, logging - Error-handling middleware: catching handler errors with
catch |err|and converting them to clean 500 responses while logging the actual error for debugging - Performance metrics collection: counting requests by status code class, tracking min/max/average response times, with mutex protection for thread safety
- Exposing metrics via a JSON endpoint that monitoring tools can scrape
- The gap between our learning project and production HTTP frameworks: keep-alive, chunked encoding, TLS, concurrency, and HTTP/2 are the major missing pieces
Bedankt en tot de volgende keer!