Skip to content

Middleware

Middleware is code that runs around the handlers. It's a struct with a Config, an init and an execute method:

const Timing = struct {
    pub const Config = struct {};

    pub fn init(_: Config) !Timing {
        return .{};
    }

    pub fn execute(_: *const Timing, req: *http.Request, res: *http.Response, executor: anytype) !void {
        const start = std.Io.Timestamp.now(req.io, .awake);
        try executor.next();
        const took = start.durationTo(std.Io.Timestamp.now(req.io, .awake));
        const text = try std.fmt.allocPrint(req.arena, "{d}us", .{took.toMicroseconds()});
        if (!res.headers_written) try res.header("X-Response-Time", text);
    }
};

execute gets the request, the response, and the executor. executor.next() runs the next middleware, and in the end the handler. When it returns, the handler is done. The middleware can then look at the response and change it. This one adds an X-Response-Time header.

Attaching Middleware

server.middleware creates the middleware from its Config. The router's middleware applies to every route, and a group's middleware to the routes of the group:

const log = try server.middleware(AccessLog, .{});
const timing = try server.middleware(Timing, .{});
const session = try server.middleware(http.middleware.Session, .{
    .secret_key = "change me to 32 or more random bytes",
});
server.router.middlewares = &.{ log, timing, session };

server.router.get("/", index);
server.router.get("/visit", visit);

const auth = try server.middleware(Auth, .{ .token = "Bearer secret" });
const admin = server.router.group("/admin", &.{auth});
admin.get("/", dashboard);

Routes get the router's middleware when they're registered, so set server.router.middlewares first. Middleware runs in the order of the list, with the router's before the group's. See Routing for more about groups.

Answering Early

Middleware doesn't have to call executor.next(). If it answers the request itself, the handler doesn't run:

const Auth = struct {
    pub const Config = struct {
        token: []const u8,
    };

    const Sha256 = std.crypto.hash.sha2.Sha256;

    token_hash: [Sha256.digest_length]u8,

    pub fn init(config: Config) !Auth {
        var auth: Auth = undefined;
        Sha256.hash(config.token, &auth.token_hash, .{});
        return auth;
    }

    pub fn execute(self: *const Auth, req: *http.Request, res: *http.Response, executor: anytype) !void {
        var header_hash: [Sha256.digest_length]u8 = undefined;
        Sha256.hash(req.headers.get("Authorization") orelse "", &header_hash, .{});
        if (!std.crypto.timing_safe.eql([Sha256.digest_length]u8, header_hash, self.token_hash)) {
            res.status = .unauthorized;
            return res.print(.text, "Unauthorized\n", .{});
        }
        executor.ctx.user = "admin";
        return executor.next();
    }
};

A request to /admin/ without the right Authorization header gets a 401. Auth compares hashes with std.crypto.timing_safe.eql, so the time the check takes doesn't give the token away.

Passing Values to Handlers

Auth in the snippet above sets executor.ctx.user for the handler. executor.ctx is the app's RequestContext, a struct you declare on the app type. Every field needs a default, because each request starts with a new one:

const App = struct {
    pub const RequestContext = struct {
        user: []const u8 = "guest",
        session: http.SessionData = .{},
    };
};

A handler gets it by taking *App.RequestContext:

fn dashboard(ctx: *App.RequestContext, res: *http.Response) !void {
    try res.print(.text, "Hello, {s}\n", .{ctx.user});
}

Hooks

Code after executor.next() runs when the handler returns. For a response the handler streams, like a file download or Server-Sent Events, the headers are already sent by then, and the body may be too. For those responses, middleware can declare onHeaders and onComplete. They run for every middleware whose execute was called:

  • onHeaders(self, req, res, ctx) runs right before the response headers are sent, so it can still add a header. It can return an error, which on a buffered response becomes the error response.
  • onComplete(self, req, res, ctx, err) runs after the response has been sent, or failed to. err is the error, if there was one. It can't return an error.

This access log uses onComplete, so it also logs errors and responses that didn't finish:

const AccessLog = struct {
    pub const Config = struct {};

    pub fn init(_: Config) !AccessLog {
        return .{};
    }

    pub fn execute(_: *const AccessLog, _: *http.Request, _: *http.Response, executor: anytype) !void {
        return executor.next();
    }

    pub fn onComplete(_: *const AccessLog, req: *http.Request, res: *http.Response, _: anytype, err: ?anyerror) void {
        if (err) |e| {
            std.log.info("{f} {s} {d} ({t})", .{ req.method, req.url, @intFromEnum(res.status), e });
        } else {
            std.log.info("{f} {s} {d}", .{ req.method, req.url, @intFromEnum(res.status) });
        }
    }
};

Init and Deinit

init takes the Config. Middleware that needs to allocate can take a second parameter, an http.MiddlewareConfig. It has the router's arena and the server's allocator. If the middleware has a deinit method, server.deinit calls it.

One instance of the middleware serves all requests at the same time. Put anything that changes per request in the RequestContext. Any other state that changes has to be safe to use from several threads.

CORS

http.middleware.Cors adds the CORS headers, and answers preflight requests:

const cors = try server.middleware(http.middleware.Cors, .{
    .origin = "https://example.com",
    .methods = "GET, POST",
    .headers = "Content-Type",
    .max_age = "86400",
});

Sessions

http.middleware.Session keeps a session in a signed cookie. The app's RequestContext needs a session field:

const App = struct {
    pub const RequestContext = struct {
        session: http.SessionData = .{},
    };
};

The secret_key in its config, set in the example above, signs the cookie. Keep it secret and make it at least 32 random bytes. The handler reads and writes the session through the context:

fn visit(req: *http.Request, ctx: *App.RequestContext, res: *http.Response) !void {
    const before = ctx.session.get("visits") orelse "0";
    const visits = (std.fmt.parseInt(u32, before, 10) catch 0) + 1;
    try ctx.session.set("visits", try std.fmt.allocPrint(req.arena, "{d}", .{visits}));
    try res.print(.text, "Visit {d}\n", .{visits});
}

ctx.session.remove(key) removes one value, and ctx.session.clear() removes them all, for example when the user logs out. A session with nothing in it deletes the cookie.

The session doesn't copy the values you give it. They have to live until the response is sent, so allocate them with req.arena. The cookie is sent with Secure by default, so browsers only send it back over HTTPS. cookie_opts in the config changes that.

Next Steps