From d6564526673fe1aa9ae915976236b43feceb7e33 Mon Sep 17 00:00:00 2001 From: Rene Schallner Date: Fri, 19 May 2023 02:47:12 +0200 Subject: [PATCH] MIDDLEWARE support --- README.md | 4 + build.zig | 1 + build.zig.zon | 2 +- doc/zig-ception.md | 93 ++++++++++++ examples/middleware/middleware.zig | 228 +++++++++++++++++++++++++++++ src/middleware.zig | 201 +++++++++++++++++++++++++ src/zap.zig | 11 +- targets.txt | 2 +- 8 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 doc/zig-ception.md create mode 100644 examples/middleware/middleware.zig create mode 100644 src/middleware.zig diff --git a/README.md b/README.md index 1954c42..0b853d0 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ Here's what works: A convenience authenticator that redirects un-authenticated requests to a login page and sends cookies containing session tokens based on username/password pairs transmitted via POST request. +- **[MIDDLEWARE support](examples/middleware/middleware.zig)**: chain together + request handlers in middleware style. Provide custom context structs, totally + type-safe, using **[ZIG-CEPTION](doc/zig-ception.md)**. If you come from GO + this might appeal to you. I'll continue wrapping more of facil.io's functionality and adding stuff to zap diff --git a/build.zig b/build.zig index fb7f6be..311d287 100644 --- a/build.zig +++ b/build.zig @@ -55,6 +55,7 @@ pub fn build(b: *std.build.Builder) !void { .{ .name = "websockets", .src = "examples/websockets/websockets.zig" }, .{ .name = "userpass_session", .src = "examples/userpass_session_auth/userpass_session_auth.zig" }, .{ .name = "sendfile", .src = "examples/sendfile/sendfile.zig" }, + .{ .name = "middleware", .src = "examples/middleware/middleware.zig" }, }) |excfg| { const ex_name = excfg.name; const ex_src = excfg.src; diff --git a/build.zig.zon b/build.zig.zon index 85033e0..9731bff 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = "zap", - .version = "0.0.20", + .version = "0.0.21", .dependencies = .{ .@"facil.io" = .{ diff --git a/doc/zig-ception.md b/doc/zig-ception.md new file mode 100644 index 0000000..babe587 --- /dev/null +++ b/doc/zig-ception.md @@ -0,0 +1,93 @@ +# ZIG-CEPTION! + +In ZAP, we have great zig-ception moment in the [middleware +example](../examples/middleware/middleware.zig). But first we need to introduce +one key function of `zap.Middleware`: **combining structs at comptime!** + +## Combining structs at runtime + +Here is how it is used in user-code: + +```zig +// create a combined context struct +const Context = zap.Middleware.MixContexts(.{ + .{ .name = "?user", .type = UserMiddleWare.User }, + .{ .name = "?session", .type = SessionMiddleWare.Session }, +}); +``` + +The result of this function call is a struct that has a `user` field of type +`?UserMiddleWare.User`, which is the `User` struct inside of its containing +struct - and a `session` field of type `?SessionMiddleWare.Session`. + +So `MixContexts` accepts a **tuple** of structs that each contain a +`name` field and a `type` field. As a hack, we support the `?` in the name to +indicate we want to the resulting struct field to be an optional. + +A **tuple** means that we can "mix" as many structs as we like. Not just two +like in above example. + +`MixContexts` inspects the passed-in `type` fields and **composes a new struct +type at comptime**! Have a look at its [source code](../src/middleware.zig). +You'll be blown away if this kind of metaprogramming stuff isn't what you do +everyday. I was totally blown away by trying it out and seeing it that it +_actually_ worked. + +Why do we create combined structs? Because all our Middleware handler functions +need to receive a per-request context. But each wants their own data: the User +middleware might want to access a User struct, the Session middleware might want +a Session struct, and so on. So, which struct should we use in the prototype of +the "on_request" callback function? We could just use an `anyopaque` pointer. +That would solve the generic function prototype problem. But then everyone +implementing such a handler would need to cast this pointer back into - what? +Into the same type that the caller of the handler used. It gets really messy +when we continue this train of thought. + +So, in ZAP, I opted for one Context type for all request handlers. Since ZAP is +a library, it cannot know what your preferred Context struct is. What it should +consist of. Therefore, it lets you combine all the structs your and maybe your +3rd parties's middleware components require - at comptime! And derive the +callback function prototype from that. If you look at the [middleware +example](../examples/middleware/middleware.zig), you'll notice, it's really +smooth to use. + +**NOTE:** In your contexts, please also use OPTIONALS. They are set null at +context creation time. And will aid you in not shooting yourself in the foot +when accessing context fields that haven't been initialized - which may happen +when the order of your chain of components isn't perfect yet. 😉 + +## The zig-ception moment + +Have a look at an excerpt of the example: + +```zig + +// we create a Handler type based on our Context +const Handler = zap.Middleware.Handler(Context); + +// +// ZIG-CEPTION!!! +// +// Note how amazing zig is: +// - we create the "mixed" context based on the both middleware structs +// - we create the handler based on this context +// - we create the middleware structs based on the handler +// - which needs the context +// - which needs the middleware structs +// - ZIG-CEPTION! + +// Example user middleware: puts user info into the context +const UserMiddleWare = struct { + handler: Handler, + + // .. the UserMiddleWare depends on the handler + // which depends on the Context + // which depends on this UserMiddleWare struct + // ZIG-CEPTION!!! +``` + +## 🤯 + +The comments in the code say it all. + +**Isn't ZIG AMAZING?** diff --git a/examples/middleware/middleware.zig b/examples/middleware/middleware.zig new file mode 100644 index 0000000..24582fc --- /dev/null +++ b/examples/middleware/middleware.zig @@ -0,0 +1,228 @@ +const std = @import("std"); +const zap = @import("zap"); + +// just a way to share our allocator via callback +const SharedAllocator = struct { + // static + var allocator: std.mem.Allocator = undefined; + + const Self = @This(); + + // just a convenience function + pub fn init(a: std.mem.Allocator) void { + allocator = a; + } + + // static function we can pass to the listener later + pub fn getAllocator() std.mem.Allocator { + return allocator; + } +}; + +// create a combined context struct +const Context = zap.Middleware.MixContexts(.{ + .{ .name = "?user", .type = UserMiddleWare.User }, + .{ .name = "?session", .type = SessionMiddleWare.Session }, +}); + +// we create a Handler type based on our Context +const Handler = zap.Middleware.Handler(Context); + +// +// ZIG-CEPTION!!! +// +// Note how amazing zig is: +// - we create the "mixed" context based on the both middleware structs +// - we create the handler based on this context +// - we create the middleware structs based on the handler +// - which needs the context +// - which needs the middleware structs +// - ZIG-CEPTION! + +// Example user middleware: puts user info into the context +const UserMiddleWare = struct { + handler: Handler, + + const Self = @This(); + + // Just some arbitrary struct we want in the per-request context + // note: it MUST have all default values!!! + // note: it MUST have all default values!!! + // note: it MUST have all default values!!! + // note: it MUST have all default values!!! + // This is so that it can be constructed via .{} + // as we can't expect the listener to know how to initialize our context structs + const User = struct { + name: []const u8 = undefined, + email: []const u8 = undefined, + }; + + pub fn init(other: ?*Handler) Self { + return .{ + .handler = Handler.init(onRequest, other), + }; + } + + // we need the handler as a common interface to chain stuff + pub fn getHandler(self: *Self) *Handler { + return &self.handler; + } + + // note that the first parameter is of type *Handler, not *Self !!! + pub fn onRequest(handler: *Handler, r: zap.SimpleRequest, context: *Context) bool { + + // this is how we would get our self pointer + var self = @fieldParentPtr(Self, "handler", handler); + _ = self; + + // do our work: fill in the user field of the context + context.user = User{ + .name = "renerocksai", + .email = "supa@secret.org", + }; + + std.debug.print("\n\nUser Middleware: set user in context {any}\n\n", .{context.user}); + + // continue in the chain + return handler.handleOther(r, context); + } +}; + +// Example session middleware: puts user info into the context +const SessionMiddleWare = struct { + handler: Handler, + + const Self = @This(); + + // Just some arbitrary struct we want in the per-request context + // note: it MUST have all default values!!! + const Session = struct { + info: []const u8 = undefined, + token: []const u8 = undefined, + }; + + pub fn init(other: ?*Handler) Self { + return .{ + .handler = Handler.init(onRequest, other), + }; + } + + // we need the handler as a common interface to chain stuff + pub fn getHandler(self: *Self) *Handler { + return &self.handler; + } + + // note that the first parameter is of type *Handler, not *Self !!! + pub fn onRequest(handler: *Handler, r: zap.SimpleRequest, context: *Context) bool { + // this is how we would get our self pointer + var self = @fieldParentPtr(Self, "handler", handler); + _ = self; + + context.session = Session{ + .info = "secret session", + .token = "rot47-asdlkfjsaklfdj", + }; + + std.debug.print("\n\nSessionMiddleware: set session in context {any}\n\n", .{context.session}); + + // continue in the chain + return handler.handleOther(r, context); + } +}; + +// Example html middleware: handles the request +const HtmlMiddleWare = struct { + handler: Handler, + + const Self = @This(); + + pub fn init(other: ?*Handler) Self { + return .{ + .handler = Handler.init(onRequest, other), + }; + } + + // we need the handler as a common interface to chain stuff + pub fn getHandler(self: *Self) *Handler { + return &self.handler; + } + + // note that the first parameter is of type *Handler, not *Self !!! + pub fn onRequest(handler: *Handler, r: zap.SimpleRequest, context: *Context) bool { + + // this is how we would get our self pointer + var self = @fieldParentPtr(Self, "handler", handler); + _ = self; + + std.debug.print("\n\nHtmlMiddleware: handling request with context: {any}\n\n", .{context}); + + var buf: [1024]u8 = undefined; + var userFound: bool = false; + var sessionFound: bool = false; + if (context.user) |user| { + userFound = true; + if (context.session) |session| { + sessionFound = true; + + const message = std.fmt.bufPrint(&buf, "User: {s} / {s}, Session: {s} / {s}", .{ + user.name, + user.email, + session.info, + session.token, + }) catch unreachable; + r.setContentType(.TEXT) catch unreachable; + r.sendBody(message) catch unreachable; + return true; + } + } + + const message = std.fmt.bufPrint(&buf, "User info found: {}, session info found: {}", .{ userFound, sessionFound }) catch unreachable; + + r.setContentType(.TEXT) catch unreachable; + r.sendBody(message) catch unreachable; + return true; + } +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{ + .thread_safe = true, + }){}; + var allocator = gpa.allocator(); + SharedAllocator.init(allocator); + + // we create our HTML middleware component that handles the request + var htmlHandler = HtmlMiddleWare.init(null); + + // we wrap it in the session Middleware component + var sessionHandler = SessionMiddleWare.init(htmlHandler.getHandler()); + + // we wrap that in the user Middleware component + var userHandler = UserMiddleWare.init(sessionHandler.getHandler()); + + // we create a listener with our combined context + // and pass it the initial handler: the user handler + var listener = try zap.Middleware.Listener(Context).init( + .{ + .on_request = null, // must be null + .port = 3000, + .log = true, + .max_clients = 100000, + }, + userHandler.getHandler(), + SharedAllocator.getAllocator, + ); + zap.enableDebugLog(); + listener.listen() catch |err| { + std.debug.print("\nLISTEN ERROR: {any}\n", .{err}); + return; + }; + + std.debug.print("Visit me on http://127.0.0.1:3000\n", .{}); + + // start worker threads + zap.start(.{ + .threads = 2, + .workers = 1, + }); +} diff --git a/src/middleware.zig b/src/middleware.zig new file mode 100644 index 0000000..dd1bc0d --- /dev/null +++ b/src/middleware.zig @@ -0,0 +1,201 @@ +const std = @import("std"); +const zap = @import("zap.zig"); + +pub const ContextDescriptor = struct { + name: []const u8, + type: type, +}; + +/// Provide a tuple of structs of type like ContextDescriptor +/// a name starting with '?', such as "?user" will be treated as Optional with default `null`. +pub fn MixContexts(comptime context_tuple: anytype) type { + var fields: [context_tuple.len]std.builtin.Type.StructField = undefined; + for (context_tuple, 0..) |t, i| { + var fieldType: type = t.type; + var fieldName: []const u8 = t.name[0..]; + var isOptional: bool = false; + if (fieldName[0] == '?') { + fieldType = @Type(.{ .Optional = .{ .child = fieldType } }); + fieldName = fieldName[1..]; + isOptional = true; + } + fields[i] = .{ + .name = fieldName, + .type = fieldType, + .default_value = if (isOptional) &null else null, + .is_comptime = false, + .alignment = 0, + }; + } + return @Type(.{ + .Struct = .{ + .layout = .Auto, + .fields = fields[0..], + .decls = &[_]std.builtin.Type.Declaration{}, + .is_tuple = false, + }, + }); +} + +/// ContextType must implement .init(zap.SimpleRequest) +pub fn Handler(comptime ContextType: anytype) type { + return struct { + other_handler: ?*Self = null, + on_request: ?RequestFn = null, + + // will be set + allocator: ?std.mem.Allocator = null, + + pub const RequestFn = *const fn (*Self, zap.SimpleRequest, *ContextType) bool; + const Self = @This(); + + pub fn init(on_request: RequestFn, other: ?*Self) Self { + return .{ + .other_handler = other, + .on_request = on_request, + }; + } + + // example for handling request + pub fn handleOther(self: *Self, r: zap.SimpleRequest, context: *ContextType) bool { + // in structs embedding a handler, we'd @fieldParentPtr the first + // param to get to the real self + + // First, do our pre-other stuff + // .. + + // then call the wrapped thing + var other_handler_finished = false; + if (self.other_handler) |other_handler| { + if (other_handler.on_request) |on_request| { + other_handler_finished = on_request(other_handler, r, context); + } + } + + // now do our post stuff + return other_handler_finished; + } + }; +} + +pub const Error = error{ + InitOnRequestIsNotNull, +}; + +pub const RequestAllocatorFn = *const fn () std.mem.Allocator; + +pub fn Listener(comptime ContextType: anytype) type { + return struct { + listener: zap.SimpleHttpListener = undefined, + settings: zap.SimpleHttpListenerSettings, + + // static initial handler + var handler: ?*Handler(ContextType) = undefined; + // static allocator getter + var requestAllocator: ?RequestAllocatorFn = null; + + const Self = @This(); + + /// initialize the middleware handler + /// the passed in settings must have on_request set to null + /// + pub fn init(settings: zap.SimpleHttpListenerSettings, initial_handler: *Handler(ContextType), request_alloc: ?RequestAllocatorFn) Error!Self { + // override on_request with ourselves + if (settings.on_request != null) { + return Error.InitOnRequestIsNotNull; + } + requestAllocator = request_alloc; + std.debug.assert(requestAllocator != null); + + var ret: Self = .{ + .settings = settings, + }; + ret.settings.on_request = onRequest; + ret.listener = zap.SimpleHttpListener.init(ret.settings); + handler = initial_handler; + return ret; + } + + pub fn listen(self: *Self) !void { + try self.listener.listen(); + } + + // this is just a reference implementation + pub fn onRequest(r: zap.SimpleRequest) void { + // we are the 1st handler in the chain, so we create a context + var context: ContextType = .{}; + + // handlers might need an allocator + // we CAN provide an allocator getter + var allocator: ?std.mem.Allocator = null; + if (requestAllocator) |foo| { + allocator = foo(); + } + + if (handler) |initial_handler| { + initial_handler.allocator = allocator; + if (initial_handler.on_request) |on_request| { + // we don't care about the return value at the top level + _ = on_request(initial_handler, r, &context); + } + } + } + }; +} + +test "it" { + + // just some made-up struct + const User = struct { + name: []const u8, + email: []const u8, + }; + + // just some made-up struct + const Session = struct { + sessionType: []const u8, + token: []const u8, + valid: bool, + }; + + const Mixed = MixContexts( + .{ + .{ .name = "?user", .type = *User }, + .{ .name = "?session", .type = *Session }, + }, + ); + + std.debug.print("{any}\n", .{Mixed}); + inline for (@typeInfo(Mixed).Struct.fields, 0..) |f, i| { + std.debug.print("field {} : name = {s} : type = {any}\n", .{ i, f.name, f.type }); + } + + var mixed: Mixed = .{ + // it's all optionals which we made default to null in MixContexts + }; + std.debug.print("mixed = {any}\n", .{mixed}); + + const NonOpts = MixContexts( + .{ + .{ .name = "user", .type = *User }, + .{ .name = "session", .type = *Session }, + }, + ); + + var user: User = .{ + .name = "renerocksai", + .email = "secret", + }; + var session: Session = .{ + .sessionType = "bearerToken", + .token = "ABCDEFG", + .valid = false, + }; + + // this will fail if we don't specify + var nonOpts: NonOpts = .{ + .user = &user, + .session = &session, + }; + std.debug.print("nonOpts = {any}\n", .{nonOpts}); +} diff --git a/src/zap.zig b/src/zap.zig index 6bea777..4d33107 100644 --- a/src/zap.zig +++ b/src/zap.zig @@ -10,6 +10,7 @@ pub usingnamespace @import("util.zig"); pub usingnamespace @import("http.zig"); pub usingnamespace @import("mustache.zig"); pub usingnamespace @import("http_auth.zig"); +pub const Middleware = @import("middleware.zig"); pub const WebSockets = @import("websockets.zig"); pub const Log = @import("log.zig"); @@ -556,6 +557,7 @@ pub const SimpleHttpListener = struct { var the_one_and_only_listener: ?*SimpleHttpListener = null; pub fn init(settings: SimpleHttpListenerSettings) Self { + std.debug.assert(settings.on_request != null); return .{ .settings = settings, }; @@ -574,7 +576,11 @@ pub const SimpleHttpListener = struct { .method = util.fio2str(r.*.method), .h = r, }; - l.settings.on_request.?(req); + std.debug.assert(l.settings.on_request != null); + if (l.settings.on_request) |on_request| { + // l.settings.on_request.?(req); + on_request(req); + } } } @@ -655,7 +661,8 @@ pub const SimpleHttpListener = struct { // const result = try bufPrint(buf, fmt ++ "\x00", args); // return result[0 .. result.len - 1 :0]; // } - if (fio.http_listen(printed_port.ptr, self.settings.interface, x) == -1) { + var ret = fio.http_listen(printed_port.ptr, self.settings.interface, x); + if (ret == -1) { return error.ListenError; } diff --git a/targets.txt b/targets.txt index 56c61f8..a55c08e 100644 --- a/targets.txt +++ b/targets.txt @@ -13,4 +13,4 @@ cookies websockets userpass_session sendfile - +middleware