Zig's HTTP frameworks will probably all have a Ctx like Go in the future

Previously, I had a toy HTTP routing library Mizu, with usage similar to:

var server = try mizu.Server.init(gpa);
defer server.deinit();

try server.use(logger);
try server.get("/", handler);
try server.listen(address);

fn handler(a: std.mem.allocator, req: *mizu.Req, res: *mizu.Res) anyerror!void {
  res.json(.{ name = "foo" });
}

Considering that allocator, request, and response are the most commonly used user interfaces, I expose them as parameters of the handler.
However, after the new Io was introduced, the parameters became four, so I merged them into:

fn usersHandler(ctx: *mizu.Context) anyerror!void {
  _ = try ctx.json(.{ .users = &.{} });
}

At this point, I realized that when using Zig to build somewhat higher-level applications (for example, HTTP CRUD services), the allocator and IO are likely to need to be passed down from top to bottom. In that case, the best approach is probably to create a unified Context.

Or could it be made into syntactic sugar? Similar to when this struct has fields named allocator and io, member methods don’t need to declare allocator or io parameters, and can directly access them in the method body via self.allocator and self.io?

On second thought, this might be too implicit. Anyway, I’d like to hear what everyone thinks.

By the way, I think this Io idea is really cool, because if I haven’t misunderstood, I can switch my async backend implementation from multi-threaded to stackful coroutines, or single-threaded sharded scheduling, just by replacing a simple parameter, right?

3 Likes

This would break readability, I think a userspace ctx : struct is fine, and easy to understand even for non zig reader, but syntax level support would prevent non zig user from reading the code as is.

5 Likes

Yes! it does require those implementations exist ofc, and unfortunately the only usable implementation currently is only std.Io.Threaded.


a context type is a common and encouraged pattern, especially as the data you commonly pass around grows.

As for how that should be integrated into a library, it depends on the library and how it will be used.

Giving the user as much control over it is a must, otherwise you force them to resort to global variables which can be problematic.
The easiest is to just have a library context and a user defined context that both get passed around.

If the libraries context is small and doesn’t change, then it could be integrated into the users context. Ideally your api would be structured in a way that library code doesn’t have to inject its context, but that is possible if necessary, though such cases should consider just seperating them.

1 Like

Implicit context system

I am aware of odins context system, and figured something like that is what they mean by syntax sugar.

zig will likely never have such a thing. Just different priorities.

1 Like

sure

but now we already have io and allocator, who knows what will be added in near future

at least in my lib main “interface” has getAllocator “method”

as result i need to use just one…

async and std improvements were planned a long long time ago. Them manifesting the way they have with std.Io is surprising, but not unexpected.

I can not think of anything planned that could result in zig getting a context system.

3 Likes

In old project of mine https://github.com/Cloudef/zig-router (zig 0.13 yay!)
I used dependency injection to pass things on demand to the routes.

It works by using comptime reflection to look at the types of the function arguments and passing things based on those types. If the type name ends up in *Body you get the deserialized body, if it ends in *Params you get deserialized path parameters, for *Query url query params. If it’s a unknown type, it looks equivalent value with the same type given as “user bindings”, and passes that to the argument. If no matching user binding was found then you get a compiler error.

This is very similar to how bevy or axum on rust side works, but the implementation is more understandable for sure.

If I’d do this now, I’d prob not rely on type names, especially because @typeName is frowned upon. I’d prob instead have type wrapper functions that append magic comptime decl that is then checked.

3 Likes

I am glad to hear that context has some official encouragement. This is the pattern I am adopting personally, and mine already includes alloc, io & env.

One of the idea I like about the new Io pattern is that if a function DOESN’T receive Io, you know that it doesn’t DO any I/O.

Yes. I have a similar pattern in one of my projects for handlers. The handlers decide wanting a context passed in or just plain parameters. During handler registration, I use comptime to figure out the handler’s parameters, return values, and expected errors. During runtime the correct forms of parameters are passed to the handlers, the return value is handled, and the error is captured.

A handler wants a context passed in when it needs the services I provide, which are allocator, io, logging, the original request, session data, user properties, etc.

Here’s an example handler with an ordinary struct parameter.

fn weighCat(cat: CatInfo) []const u8 {
    if (std.mem.eql(u8, cat.cat_name, "Garfield")) return "Fat Cat!";
    if (std.mem.eql(u8, cat.cat_name, "Odin")) return "Not a cat!";
    if (0 < cat.weight and cat.weight <= 2.0) return "Tiny cat";
    if (2.0 < cat.weight and cat.weight <= 10.0) return "Normal weight";
    if (10.0 < cat.weight ) return "Heavy cat";
    return "Something wrong";
}

Another example handler needing a context to get the allocator.

fn cloneCat(dc: *zigjr.DispatchCtx, cat: CatInfo) ![2]CatInfo {
    return .{
        cat,
        CatInfo {
            .cat_name = try std.fmt.allocPrint(dc.arena, "Clone of {s}", .{cat.cat_name}),
            .weight = cat.weight * 2,
            .eye_color = cat.eye_color,
        },
    };
}
1 Like