Middleware design seem very hard to do correct

I’m creating an http micro-framework and the thing I can’t fully understand is middlewares.
I’ve looked up zap to see how they do this, but they only do global chain of middlewares, which I don’t personally like. I would like to have global chains and per-route middlewares. But there’s another problem.

Approach 1. Let’s say I create a logger:

pub const Handler = struct {
    userdata: ?*anyopaque,
    call_fn: *const fn (?*anyopaque, Context) anyerror!void,
};

pub const Logger = struct {
    next: Handler,
    pub fn handler(self: *Logger) Handler {
        return .{ .userdata = self, .call_fn = Logger.callFn };
    }
    pub fn callFn(erased: ?*anyopaque, context: Context) !void {
        const logger: *Logger = @ptrCast(@alignCast(erased));
        // Imagine some useful work here.
        retrun try logger.callFn(context);
    }
};

This approach works, but if I want to create per-route middleware I’ll have to create as many instances as there are routes, which becomes verbose really fast.

I considered to create a helper method which will allocate middleware on heap, but then I’ll have to put deinit in Handler interface to prevent memory leaks. And also creating allocation for every middleware on every route seem a bit too much, I might be wrong though. This pushed me towards next approach.

Approach 2:

pub const Handler = struct {
    userdata: ?*anyopaque,
    call_fn: ErasedHandler,
    const ErasedHandler = *const fn (userdata: ?*anyopaque, context: *Context, next: mw.Chain.Next) anyerror!Response;
};

pub const Logger = struct {
    pub fn handler(self: *Logger) Handler {
        return .{ .userdata = null, .call_fn = Logger.callFn };
    }
    pub fn callFn(erased: ?*anyopaque, context: Context, next: Chain.Next) !void {
        const logger: *Logger = @ptrCast(@alignCast(erased));
        // Imagine some useful work here.
        retrun try next.call(context);
    }
};

pub const Chain = struct {
    next: Next,

    pub const Next = struct {
        handlers: []const Handler,

        pub const empty: Next = .{ .handlers = &.{} };

        pub fn call(self: Next, context: *Context) anyerror!Response {
            std.debug.assert(self.handlers.len > 0);
            const h = self.handlers[0];
            const next: Next = .{ .handlers = self.handlers[1..] };
            return h.call_fn(h.userdata, context, next);
        }
    };

    pub fn new(handlers: []const Handler) Chain {
        return .{ .next = .{ .handlers = handlers } };
    }

    pub fn handler(self: *Chain) Handler {
        return .{ .userdata = self, .call_fn = Chain.callFn };
    }

    fn callFn(erased: ?*anyopaque, context: *Context, _: Next) anyerror!Response {
        const chain: *Chain = @ptrCast(@alignCast(erased));
        return try chain.next.call(context);
    }
};

This approach does lead to a better dx, but leaves ErasedHandler with next even if it’s never used, like in endpoint. Though i erase a type in such a way that Next cannot be an argument of an endpoint, but if created manually without type erasure it’s still possible.

Now if I want to have a chain of middlewares I’ll have to create Chain instance for every of them,
but I don’t have to create instances of middlewares, because they are they don’t have a state about next. It leads to this code:

var logger_instance = mw.Logger.new();
const logger = logger_instance.handler();

var chain = mw.Chain.new(&.{ logger, .new(ping) });
try router.insert(gpa, "/ping", chain.handler());

It looks cleaner, but I still have to create chain instance for every route, because handler needs mutable pointer.

I have these 2 approaches in mind but both of them feels like I’m missing something important.
I’m also aware of rust/tower like approach, but it doesn’t feel feasible to do in zig comfortably and go/http approach. It was simple, but we don’t have closures and if we do it by hand we’ll have problems from approach 1 again.

What am I missing here?

I dont think you are missing much here, except maybe sprinkle a bit of comptime to get over needing type erased values ?

Or maybe change things around so that the handler doesn’t need a mutable pointer to the chain ? You can for example just use the context itself to accumulate extra info as it passes through the chain.

At least you know where you want your code to go, and have the DX you are looking for, so that’s more than half the battle. Just keep iterating and let it take shape.

Feel free to pick through the middleware layer here < GitHub - zigster64/datastar.zig: Datastar SDK for zig 0.16 · GitHub> .. which is modelled after the “plug” design in Erlang/elixir. Might be worth getting ideas from ?

I glanced through repo and it made me think that i might be overegineering.

I tried to make onion middleware chain, but do i really need to? If i ditch this approach in favor of using independent middlewares with no state about next middleware it would be A LOT easier to implement.

Though some things like logging elapsed time will require 2 middlewares.

Thank you for the repo and for your support! I will keep this thread open for a while, maybe somebody have to add something to this.

Good point .. I deliberately kept logging separate from middleware there, so the time reporting remains consistent and independent. Makes sense for logging to be intrinsic and built in with the router part.

And yep, it’s easier if each middleware chain knows nothing about who is next .. let the framework do all that in 1 spot using an arraylist of funcs to call, and use a convention like return true/false (or set a flag) to say whether to keep going or not.

Last bit was adding that kv store of “assigns” to the context to let middlewares add their bit of state .. like auth and add a user id to the context or whatever.

Good luck with it .. interested to follow along and see what you discover. It’s super useful rolling your own everything from scratch, doesn’t have to be perfect.