diff options
| author | Nathan Reiner <nathan@nathanreiner.xyz> | 2026-06-05 19:28:56 +0200 |
|---|---|---|
| committer | Nathan Reiner <nathan@nathanreiner.xyz> | 2026-06-05 19:28:56 +0200 |
| commit | 8001b02547ba6261682a6d6990157293b23e18d7 (patch) | |
| tree | ee0f7c130a0319e47a677ae24ac66765014507f1 | |
| parent | 44051e51f99fe7e94f37d567d621fa13399c17f1 (diff) | |
http add request and context
| -rw-r--r-- | src/api/root.zig | 2 | ||||
| -rw-r--r-- | src/api/sketch.zig | 29 | ||||
| -rw-r--r-- | src/http/Parameter.zig | 71 | ||||
| -rw-r--r-- | src/http/Response.zig | 28 | ||||
| -rw-r--r-- | src/http/Route.zig | 45 | ||||
| -rw-r--r-- | src/http/RouteSet.zig | 49 | ||||
| -rw-r--r-- | src/http/Url.zig | 39 | ||||
| -rw-r--r-- | src/http/handler.zig | 149 | ||||
| -rw-r--r-- | src/http/mime.zig | 0 | ||||
| -rw-r--r-- | src/http/root.zig | 5 |
10 files changed, 346 insertions, 71 deletions
diff --git a/src/api/root.zig b/src/api/root.zig index c28991c..5128aa1 100644 --- a/src/api/root.zig +++ b/src/api/root.zig @@ -3,8 +3,6 @@ const std = @import("std"); -pub const _ = @import("sketch.zig"); - test { _ = std.testing.refAllDecls(@This()); } diff --git a/src/api/sketch.zig b/src/api/sketch.zig deleted file mode 100644 index 409606f..0000000 --- a/src/api/sketch.zig +++ /dev/null @@ -1,29 +0,0 @@ -const std = @import("std"); -const http = @import("http"); - -pub const Url = http.Url.Template( - "/api/images/{id}/metadata/{name}", - .{ - .guard = Auth(.admin), - } -); - -const Query = struct { - condition: []const u8, - start: usize, - end: usize, -} - -pub fn post(request: http.Request(Url)) !http.Response { - const query = try request.body.json(Query, request.arena); - - return .string(request.params.id); -} - -pub fn get(request: http.Request(Url)) !http.Response { - return .file("./some/path.txt"); -} - -test { - _ = std.testing.refAllDecls(@This()); -} diff --git a/src/http/Parameter.zig b/src/http/Parameter.zig new file mode 100644 index 0000000..03260b2 --- /dev/null +++ b/src/http/Parameter.zig @@ -0,0 +1,71 @@ +const std = @import("std"); +const Route = @import("Route.zig"); + +repr: []const u8, + +pub fn as(self: @This(), T: type) (if (T == []const u8) T else anyerror!T) { + if (T == []const u8) { + return self.repr; + } else if (@typeInfo(T) == .int) { + return std.fmt.parseInt(T, self.repr, 10); + } + + @compileError(std.fmt.comptimePrint("unsupported default type '{}'", .{T})); +} + +pub fn convert(self: @This(), func: anytype) (@typeInfo(@TypeOf(func)).@"fn".return_type orelse void) { + return func(self.repr); +} + +pub fn Struct(comptime route: Route) type { + var names: []const []const u8 = &.{}; + + inline for (route.segments) |segment| { + switch (segment) { + .literal => {}, + .identifier => |id| { + const name: []const []const u8 = &.{id}; + names = names ++ name; + }, + } + } + + var types: [names.len]type = undefined; + var attrs: [names.len]std.builtin.Type.StructField.Attributes = undefined; + + @memset(&types, @This()); + @memset(&attrs, std.builtin.Type.StructField.Attributes{}); + + return @Struct(.auto, null, names, &types, &attrs); +} + +pub fn parse(P: type, comptime route: *const Route, url: []const u8) P { + var params: P = undefined; + var buffer = url; + + inline for (route.segments) |segment| { + buffer = std.mem.trimStart(u8, buffer, "/"); + const index = std.mem.indexOfScalar(u8, buffer, '/') orelse buffer.len; + const value = buffer[0..index]; + buffer = buffer[index..]; + + switch (segment) { + .literal => {}, + .identifier => |id| { + @field(params, id) = .{ .repr = value }; + }, + } + } + + return params; +} + +test "parse" { + const route: Route = comptime .fromAny(.{ "api", "users", .name, "settings", .id }); + const Params = Struct(route); + const url = "/api/users/nathan.reiner/settings/123456789"; + + const params = parse(Params, &route, url); + try std.testing.expectEqualStrings("nathan.reiner", params.name.as([]const u8)); + try std.testing.expectEqual(123456789, try params.id.as(usize)); +} diff --git a/src/http/Response.zig b/src/http/Response.zig new file mode 100644 index 0000000..a5f7727 --- /dev/null +++ b/src/http/Response.zig @@ -0,0 +1,28 @@ +const std = @import("std"); + +pub const Message = union(enum) { + void: void, + static: []const u8, + file_content: std.Io.File, + + pub fn string(content: []const u8) Message { + return .{ .static = content }; + } + + pub fn file(io: std.Io, path: []const u8) !Message { + return .{ .file_content = try std.Io.Dir.cwd().openFile(io, path, .{ + .allow_directory = false, + }) }; + } +}; + +status: std.http.Status, +message: Message, + +pub fn with(status: std.http.Status, message: Message) @This() { + return .{ .status = status, .message = message }; +} + +pub fn ok(message: Message) @This() { + return with(.ok, message); +} diff --git a/src/http/Route.zig b/src/http/Route.zig new file mode 100644 index 0000000..bc86544 --- /dev/null +++ b/src/http/Route.zig @@ -0,0 +1,45 @@ +const std = @import("std"); + +pub const Segment = union(enum) { + literal: []const u8, + identifier: []const u8, + + pub fn format(self: *const @This(), writer: *std.Io.Writer) !void { + switch (self.*) { + .literal => |l| try writer.print("{s}", .{l}), + .identifier => |l| try writer.print("{{{s}}}", .{l}), + } + } +}; + +segments: []const Segment, + +pub fn fromAny(comptime route: anytype) @This() { + var segments: []const Segment = &.{}; + + inline for(route) |r| { + const segment: []const Segment = &.{ switch (@TypeOf(r)) { + @EnumLiteral() => .{ .identifier = @tagName(r) }, + else => .{ .literal = r }, + } }; + + segments = segments ++ segment; + } + + return .{ .segments = segments }; +} + +pub fn format(self: *const @This(), writer: *std.Io.Writer) !void { + for (self.segments) |*seg| { + try writer.print("/{f}", .{seg}); + } +} + + +pub fn shift(self: *const @This()) struct { Segment, @This() } { + if (self.segments.len == 0) { + @compileError("shift on empty route"); + } + + return .{ self.segments[0], self.segments[1..] }; +} diff --git a/src/http/RouteSet.zig b/src/http/RouteSet.zig new file mode 100644 index 0000000..3b6bfa7 --- /dev/null +++ b/src/http/RouteSet.zig @@ -0,0 +1,49 @@ +const std = @import("std"); + +const Route = @import("Route.zig"); +const handler = @import("handler.zig"); +const Interface = handler.Interface; +const Context = handler.Context; + +literals: []struct { + value: []const u8, + node: *@This(), +}, +identifiers: []@This(), +interface: ?Interface, + +pub fn init(comptime interfaces: []const Interface) @This() { + _ = interfaces; + return undefined; +} + +pub fn getInterface(self: *const @This(), url: []const u8) ?Interface { + _ = self; + _ = url; + return null; +} + +test "init" { + const M = struct { + const Response = @import("Response.zig"); + + const C = Context(.{ "api", "images", .id, "metadata" }); + const context: C = .{}; + + const Query = struct { + condition: []const u8, + start: usize, + length: usize, + }; + + pub fn get(request: C.Request) Response { + _ = request; + return .ok(.static("hello")); + } + }; + + const routes = init(&.{M.context.interface()}); + + const interface = routes.getInterface("/api/images/123456/metadata"); + _ = interface; +} diff --git a/src/http/Url.zig b/src/http/Url.zig deleted file mode 100644 index 4a0a2b0..0000000 --- a/src/http/Url.zig +++ /dev/null @@ -1,39 +0,0 @@ -const std = @import("std"); - -const Segment = union(enum) { - literal: []const u8, - parameter: []const u8, - - pub fn format(self: @This(), writer: *std.Io.Writer) !void { - try writer.print("{s}{{ {s} }}", .{ @tagName(self), switch (self) { - inline else => |s| s, - } }); - } -}; - -pub fn Template(comptime template: []const u8) []const Segment { - var segments: []const Segment = &.{}; - var t = template; - - if (template.len == 0 or template[0] != '/') { - @compileError("template needs to be absolute"); - } - - while (t.len > 0) { - const index = std.mem.indexOfScalar(u8, t, '/'); - const segment = t[0 .. index orelse t.len]; - t = t[(index orelse t.len - 1) + 1 ..]; - - if (segment.len > 0) { - const s: []const Segment = &.{seg: { - if (segment[0] == '{' and segment[segment.len - 1] == '}') { - break :seg .{ .parameter = segment[1 .. segment.len - 1] }; - } else { - break :seg .{ .literal = segment }; - } - }}; - segments = segments ++ s; - } - } - return segments; -} diff --git a/src/http/handler.zig b/src/http/handler.zig new file mode 100644 index 0000000..2220fd0 --- /dev/null +++ b/src/http/handler.zig @@ -0,0 +1,149 @@ +const std = @import("std"); + +const Response = @import("Response.zig"); +const Route = @import("Route.zig"); +const Parameter = @import("Parameter.zig"); + +pub fn Context(comptime r: anytype) type { + return struct { + const route = Route.fromAny(r); + const Params = Parameter.Struct(route); + + const Request = struct { + base_request: *std.http.Server.Request, + params: Params, + arena: std.mem.Allocator, + gpa: std.mem.Allocator, + io: std.Io, + + /// Parses the body of the request and returns type `T`. + /// The arena allocator will be used to do the parsing. + pub fn json(self: @This(), T: type) !T { + var buffer: [1024]u8 = undefined; + var reader = std.json.Reader.init(self.arena, self.base_request.readerExpectNone(&buffer)); + return std.json.parseFromTokenSourceLeaky(T, self.arena, &reader, .{}); + } + }; + + const badRequest = struct { + pub fn badRequest(_: Request) Response { + return .with(.bad_request, .string("method does not exist")); + } + }.badRequest; + + get: *const fn (Request) Response = badRequest, + post: *const fn (Request) Response = badRequest, + delete: *const fn (Request) Response = badRequest, + head: *const fn (Request) Response = badRequest, + put: *const fn (Request) Response = badRequest, + connect: *const fn (Request) Response = badRequest, + options: *const fn (Request) Response = badRequest, + trace: *const fn (Request) Response = badRequest, + patch: *const fn (Request) Response = badRequest, + + pub fn handle( + self: *const @This(), + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + io: std.Io, + request: *std.http.Server.Request, + ) Response { + const req: Request = .{ + .base_request = request, + .arena = arena, + .gpa = gpa, + .io = io, + .params = Parameter.parse(Params, &route, request.head.target), + }; + + return (switch (request.head.method) { + .GET => self.get, + .POST => self.post, + .DELETE => self.delete, + .HEAD => self.head, + .PUT => self.put, + .CONNECT => self.connect, + .OPTIONS => self.options, + .TRACE => self.trace, + .PATCH => self.patch, + })(req); + } + + pub fn interface(self: *const @This()) Interface { + return .from(self, route); + } + }; +} + +pub const Interface = struct { + route: Route, + base: *const anyopaque, + handler: *const fn ( + *const anyopaque, + std.mem.Allocator, + std.mem.Allocator, + std.Io, + *std.http.Server.Request, + ) Response, + + pub fn from(instance: anytype, route: Route) @This() { + const PtrT = @TypeOf(instance); + const func = struct { + pub fn handle( + base: *const anyopaque, + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + io: std.Io, + request: *std.http.Server.Request, + ) Response { + return @as(PtrT, @ptrCast(@alignCast(base))).handle(gpa, arena, io, request); + } + }.handle; + + return .{ + .base = @ptrCast(instance), + .handler = func, + .route = route, + }; + } + + pub fn handle( + self: *const @This(), + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + io: std.Io, + request: std.http.Server.Request, + ) Response { + return self.handler(self.base, gpa, arena, io, request); + } +}; + +test "interface" { + const M = struct { + const C = Context(.{ "api", "images", .id, "metadata" }); + const context: C = .{ + .post = post, + .get = get, + }; + + const Query = struct { + condition: []const u8, + start: usize, + length: usize, + }; + + fn post(request: C.Request) Response { + const query = request.json(Query) catch return .with(.bad_request, .void); + _ = query; + _ = request.params.id; + + return .ok(.string(request.params.id.as([]const u8))); + } + + fn get(request: C.Request) Response { + return .ok(Response.Message.file(request.io, "./some/path.txt") catch return .with(.not_found, .void)); + } + }; + + _ = M.context.interface(); +} diff --git a/src/http/mime.zig b/src/http/mime.zig new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/http/mime.zig diff --git a/src/http/root.zig b/src/http/root.zig index 317dacd..2fa4042 100644 --- a/src/http/root.zig +++ b/src/http/root.zig @@ -3,7 +3,10 @@ const std = @import("std"); pub const Server = @import("Server.zig"); -pub const Url = @import("Url.zig"); +pub const handler = @import("handler.zig"); +pub const Response = @import("Response.zig"); +pub const Route = @import("Route.zig"); +pub const RouteSet = @import("RouteSet.zig"); test { _ = std.testing.refAllDecls(@This()); |