From 92dfc76802e025b10416a58076b6ab9e0bfda5ed Mon Sep 17 00:00:00 2001 From: Bernard Assan Date: Tue, 14 Jul 2026 00:36:18 +0000 Subject: [PATCH 1/8] simplify build.zig Signed-off-by: Bernard Assan --- build.zig | 53 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/build.zig b/build.zig index 0950864..a8d2e26 100644 --- a/build.zig +++ b/build.zig @@ -24,16 +24,39 @@ pub fn build(b: *std.Build) void { zzz.addImport("secsock", secsock); - add_example(b, "basic", false, target, optimize, zzz); - add_example(b, "cookies", false, target, optimize, zzz); - add_example(b, "form", false, target, optimize, zzz); - add_example(b, "fs", false, target, optimize, zzz); - add_example(b, "middleware", false, target, optimize, zzz); - add_example(b, "sse", false, target, optimize, zzz); - add_example(b, "tls", true, target, optimize, zzz); - - if (target.result.os.tag != .windows) - add_example(b, "unix", false, target, optimize, zzz); + for ([_][]const u8{ + "basic", + "cookies", + "form", + "fs", + "middleware", + "sse", + }) |name| add_example( + b, + name, + false, + target, + optimize, + zzz, + ); + + add_example( + b, + "tls", + true, + target, + optimize, + zzz, + ); + + if (target.result.os.tag != .windows) add_example( + b, + "unix", + false, + target, + optimize, + zzz, + ); const tests = b.addTest(.{ .name = "tests", @@ -80,13 +103,19 @@ fn add_example( const install_artifact = b.addInstallArtifact(example, .{}); b.getInstallStep().dependOn(&install_artifact.step); - const build_step = b.step(b.fmt("{s}", .{name}), b.fmt("Build zzz example ({s})", .{name})); + const build_step = b.step( + b.fmt("{s}", .{name}), + b.fmt("Build zzz example ({s})", .{name}), + ); build_step.dependOn(&install_artifact.step); const run_artifact = b.addRunArtifact(example); run_artifact.step.dependOn(&install_artifact.step); - const run_step = b.step(b.fmt("run_{s}", .{name}), b.fmt("Run zzz example ({s})", .{name})); + const run_step = b.step( + b.fmt("run_{s}", .{name}), + b.fmt("Run zzz example ({s})", .{name}), + ); run_step.dependOn(&install_artifact.step); run_step.dependOn(&run_artifact.step); } From 66c322300c55d7c386f1bf63cabadaff82c050e6 Mon Sep 17 00:00:00 2001 From: Bernard Assan Date: Tue, 14 Jul 2026 01:01:33 +0000 Subject: [PATCH 2/8] modularize core module improve namespacing of any_case_string_map to string_map.AnyCase Signed-off-by: Bernard Assan --- src/core.zig | 8 ++ src/core/Pseudoslice.zig | 108 ++++++++++++++++++ src/core/TypedStorage.zig | 82 +++++++++++++ src/core/lib.zig | 5 - src/core/pseudoslice.zig | 97 ---------------- ...any_case_string_map.zig => string_map.zig} | 31 ++--- src/core/typed_storage.zig | 73 ------------ 7 files changed, 214 insertions(+), 190 deletions(-) create mode 100644 src/core.zig create mode 100644 src/core/Pseudoslice.zig create mode 100644 src/core/TypedStorage.zig delete mode 100644 src/core/lib.zig delete mode 100644 src/core/pseudoslice.zig rename src/core/{any_case_string_map.zig => string_map.zig} (51%) delete mode 100644 src/core/typed_storage.zig diff --git a/src/core.zig b/src/core.zig new file mode 100644 index 0000000..057c631 --- /dev/null +++ b/src/core.zig @@ -0,0 +1,8 @@ +pub const string_map = @import("core/string_map.zig"); +pub const TypeStorage = @import("core/TypedStorage.zig"); +pub const wrapping = @import("core/wrapping.zig"); +pub const Pseudoslice = @import("pseudoslice.zig"); + +pub fn Pair(comptime A: type, comptime B: type) type { + return struct { A, B }; +} diff --git a/src/core/Pseudoslice.zig b/src/core/Pseudoslice.zig new file mode 100644 index 0000000..04dea87 --- /dev/null +++ b/src/core/Pseudoslice.zig @@ -0,0 +1,108 @@ +// The Pseudoslice will basically stitch together two different buffers, using +// a third provided buffer as the output. + +pub const Pseudoslice = @This(); + +first: []const u8, +second: []const u8, +shared: []u8, +len: usize, + +pub fn init(first: []const u8, second: []const u8, shared: []u8) Pseudoslice { + return .{ + .first = first, + .second = second, + .shared = shared, + .len = first.len + second.len, + }; +} + +/// Operates like a slice. That means it does not capture the end. +/// Start is an inclusive bound and end is an exclusive bound. +pub fn get(self: *const Pseudoslice, start: usize, end: usize) []const u8 { + debug.assert(end >= start); + debug.assert(self.shared.len >= end - start); + const clamped_end = @min(end, self.len); + + if (start < self.first.len) { + if (clamped_end <= self.first.len) { + // within first slice + return self.first[start..clamped_end]; + } else { + // across both slices + const first_len = self.first.len - start; + const second_len = clamped_end - self.first.len; + const total_len = clamped_end - start; + + if (self.first.ptr == self.shared.ptr) { + // just copy over the second. + @memcpy( + self.shared[self.first.len..][0..second_len], + self.second[0..second_len], + ); + return self.shared[start..clamped_end]; + } else { + // copy both over. + @memcpy(self.shared[0..first_len], self.first[start..]); + @memcpy( + self.shared[first_len..][0..second_len], + self.second[0..second_len], + ); + return self.shared[0..total_len]; + } + } + } else { + // within second slice + const second_start = start - self.first.len; + const second_end = clamped_end - self.first.len; + return self.second[second_start..second_end]; + } +} + +test "Pseudoslice General" { + var buffer: [1024]u8 = @splat(0); + const value = "hello, my name is muki"; + var pseudo: Pseudoslice = .init( + value[0..6], + value[6..], + buffer[0..], + ); + + for (0..pseudo.len) |i| { + for (0..i) |j| try testing.expectEqualStrings( + value[j..i], + pseudo.get(j, i), + ); + } +} + +test "Pseudoslice Empty Second" { + var buffer: [1024]u8 = @splat(0); + const value = "hello, my name is muki"; + var pseudo: Pseudoslice = .init(value[0..], &.{}, buffer[0..]); + + for (0..pseudo.len) |i| try testing.expectEqualStrings( + value[0..i], + pseudo.get(0, i), + ); +} + +test "Pseudoslice First and Shared Same" { + const buffer = try testing.allocator.alloc(u8, 1024); + defer testing.allocator.free(buffer); + + const value = "hello, my name is muki"; + @memcpy(buffer[0..6], value[0..6]); + + var pseudo: Pseudoslice = .init(buffer[0..6], value[6..], buffer); + + for (0..pseudo.len) |i| { + for (0..i) |j| { + try testing.expectEqualStrings(value[j..i], pseudo.get(j, i)); + } + } +} + +const std = @import("std"); +const debug = std.debug; +const testing = std.testing; diff --git a/src/core/TypedStorage.zig b/src/core/TypedStorage.zig new file mode 100644 index 0000000..0037465 --- /dev/null +++ b/src/core/TypedStorage.zig @@ -0,0 +1,82 @@ +pub const TypedStorage = @This(); + +arena: std.heap.ArenaAllocator, +storage: hash_map.Custom( + Key, + *anyopaque, + hash_map.AutoContext(Key), + hash_map.default_max_load_percentage, +), + +pub fn init(allocator: mem.Allocator) TypedStorage { + return .{ + .arena = .init(allocator), + .storage = .empty, + }; +} + +pub fn deinit(self: *TypedStorage) void { + self.arena.deinit(); +} + +/// Clears the Storage. +pub fn clear(self: *TypedStorage) void { + self.storage.clearAndFree(self.arena.allocator()); + _ = self.arena.reset(.retain_capacity); +} + +/// Inserts a value into the Storage. +/// It uses the given type as the K. +pub fn put(self: *TypedStorage, comptime T: type, value: T) !void { + const allocator = self.arena.allocator(); + const ptr = try allocator.create(T); + ptr.* = value; + const type_id = comptime std.hash.Wyhash.hash(0, @typeName(T)); + try self.storage.put(allocator, type_id, @ptrCast(ptr)); +} + +/// Extracts a value out of the Storage. +/// It uses the given type as the K. +pub fn get(self: *TypedStorage, comptime T: type) ?T { + const type_id = comptime std.hash.Wyhash.hash(0, @typeName(T)); + const ptr = self.storage.get(type_id) orelse return null; + return @as(*T, @ptrCast(@alignCast(ptr))).*; +} + +test "TypedStorage: Basic" { + var storage: TypedStorage = .init(testing.allocator); + defer storage.deinit(); + + // Test inserting and getting different types + try storage.put(u32, 42); + try storage.put([]const u8, "hello"); + try storage.put(f32, 3.14); + + try testing.expectEqual(42, storage.get(u32).?); + try testing.expectEqualStrings("hello", storage.get([]const u8).?); + try testing.expectEqual(3.14, storage.get(f32).?); + + // Test overwriting a value + try storage.put(u32, 100); + try testing.expectEqual(100, storage.get(u32).?); + + // Test getting non-existent type + try testing.expectEqual(null, storage.get(bool)); + + // Test clearing + storage.clear(); + try testing.expectEqual(null, storage.get(u32)); + try testing.expectEqual(null, storage.get([]const u8)); + try testing.expectEqual(null, storage.get(f32)); + + // Test inserting after clear + try storage.put(u32, 200); + try testing.expectEqual(200, storage.get(u32).?); +} + +const Key = u64; + +const std = @import("std"); +const testing = std.testing; +const mem = std.mem; +const hash_map = std.hash_map; diff --git a/src/core/lib.zig b/src/core/lib.zig deleted file mode 100644 index 718ad2f..0000000 --- a/src/core/lib.zig +++ /dev/null @@ -1,5 +0,0 @@ -pub const Pseudoslice = @import("pseudoslice.zig").Pseudoslice; - -pub fn Pair(comptime A: type, comptime B: type) type { - return struct { A, B }; -} diff --git a/src/core/pseudoslice.zig b/src/core/pseudoslice.zig deleted file mode 100644 index bd59ae5..0000000 --- a/src/core/pseudoslice.zig +++ /dev/null @@ -1,97 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const testing = std.testing; - -const log = std.log.scoped(.@"zzz/core/pseudoslice"); - -// The Pseudoslice will basically stitch together two different buffers, using -// a third provided buffer as the output. -pub const Pseudoslice = struct { - first: []const u8, - second: []const u8, - shared: []u8, - len: usize, - - pub fn init(first: []const u8, second: []const u8, shared: []u8) Pseudoslice { - return .{ - .first = first, - .second = second, - .shared = shared, - .len = first.len + second.len, - }; - } - - /// Operates like a slice. That means it does not capture the end. - /// Start is an inclusive bound and end is an exclusive bound. - pub fn get(self: *const Pseudoslice, start: usize, end: usize) []const u8 { - assert(end >= start); - assert(self.shared.len >= end - start); - const clamped_end = @min(end, self.len); - - if (start < self.first.len) { - if (clamped_end <= self.first.len) { - // within first slice - return self.first[start..clamped_end]; - } else { - // across both slices - const first_len = self.first.len - start; - const second_len = clamped_end - self.first.len; - const total_len = clamped_end - start; - - if (self.first.ptr == self.shared.ptr) { - // just copy over the second. - @memcpy(self.shared[self.first.len..][0..second_len], self.second[0..second_len]); - return self.shared[start..clamped_end]; - } else { - // copy both over. - @memcpy(self.shared[0..first_len], self.first[start..]); - @memcpy(self.shared[first_len..][0..second_len], self.second[0..second_len]); - return self.shared[0..total_len]; - } - } - } else { - // within second slice - const second_start = start - self.first.len; - const second_end = clamped_end - self.first.len; - return self.second[second_start..second_end]; - } - } -}; - -test "Pseudoslice General" { - var buffer: [1024]u8 = @splat(0); - const value = "hello, my name is muki"; - var pseudo: Pseudoslice = .init(value[0..6], value[6..], buffer[0..]); - - for (0..pseudo.len) |i| { - for (0..i) |j| { - try testing.expectEqualStrings(value[j..i], pseudo.get(j, i)); - } - } -} - -test "Pseudoslice Empty Second" { - var buffer: [1024]u8 = @splat(0); - const value = "hello, my name is muki"; - var pseudo: Pseudoslice = .init(value[0..], &.{}, buffer[0..]); - - for (0..pseudo.len) |i| { - try testing.expectEqualStrings(value[0..i], pseudo.get(0, i)); - } -} - -test "Pseudoslice First and Shared Same" { - const buffer = try testing.allocator.alloc(u8, 1024); - defer testing.allocator.free(buffer); - - const value = "hello, my name is muki"; - @memcpy(buffer[0..6], value[0..6]); - - var pseudo: Pseudoslice = .init(buffer[0..6], value[6..], buffer); - - for (0..pseudo.len) |i| { - for (0..i) |j| { - try testing.expectEqualStrings(value[j..i], pseudo.get(j, i)); - } - } -} diff --git a/src/core/any_case_string_map.zig b/src/core/string_map.zig similarity index 51% rename from src/core/any_case_string_map.zig rename to src/core/string_map.zig index 08ef8b2..7185312 100644 --- a/src/core/any_case_string_map.zig +++ b/src/core/string_map.zig @@ -1,29 +1,27 @@ -const std = @import("std"); -const assert = std.debug.assert; -const testing = std.testing; - -const Pool = @import("tardy").Pool; - -const AnyCaseStringContext = struct { - const Self = @This(); - - pub fn hash(_: Self, key: []const u8) u64 { +const Context = struct { + pub fn hash(_: Context, key: []const u8) u64 { var wyhash: std.hash.Wyhash = .init(0); for (key) |b| wyhash.update(&.{std.ascii.toLower(b)}); return wyhash.final(); } - pub fn eql(_: Self, key1: []const u8, key2: []const u8) bool { + pub fn eql(_: Context, key1: []const u8, key2: []const u8) bool { if (key1.len != key2.len) return false; - for (key1, key2) |b1, b2| if (std.ascii.toLower(b1) != std.ascii.toLower(b2)) return false; + for (key1, key2) |b1, b2| + if (std.ascii.toLower(b1) != std.ascii.toLower(b2)) return false; return true; } }; -pub const AnyCaseStringMap = std.HashMap([]const u8, []const u8, AnyCaseStringContext, 80); +pub const AnyCase = std.hash_map.HashMap( + []const u8, + []const u8, + Context, + 80, +); -test "AnyCaseStringMap: Add Stuff" { - var map: AnyCaseStringMap = .init(testing.allocator); +test "string_map.AnyCase: Add Stuff" { + var map: AnyCase = .init(testing.allocator); defer map.deinit(); try map.put("Content-Length", "100"); @@ -35,3 +33,6 @@ test "AnyCaseStringMap: Add Stuff" { const host = map.get("host"); try testing.expect(host != null); } + +const std = @import("std"); +const testing = std.testing; diff --git a/src/core/typed_storage.zig b/src/core/typed_storage.zig deleted file mode 100644 index 06866fb..0000000 --- a/src/core/typed_storage.zig +++ /dev/null @@ -1,73 +0,0 @@ -const std = @import("std"); -const testing = std.testing; - -pub const TypedStorage = struct { - arena: std.heap.ArenaAllocator, - storage: std.AutoHashMapUnmanaged(u64, *anyopaque), - - pub fn init(allocator: std.mem.Allocator) TypedStorage { - return .{ - .arena = .init(allocator), - .storage = .empty, - }; - } - - pub fn deinit(self: *TypedStorage) void { - self.arena.deinit(); - } - - /// Clears the Storage. - pub fn clear(self: *TypedStorage) void { - self.storage.clearAndFree(self.arena.allocator()); - _ = self.arena.reset(.retain_capacity); - } - - /// Inserts a value into the Storage. - /// It uses the given type as the K. - pub fn put(self: *TypedStorage, comptime T: type, value: T) !void { - const allocator = self.arena.allocator(); - const ptr = try allocator.create(T); - ptr.* = value; - const type_id = comptime std.hash.Wyhash.hash(0, @typeName(T)); - try self.storage.put(allocator, type_id, @ptrCast(ptr)); - } - - /// Extracts a value out of the Storage. - /// It uses the given type as the K. - pub fn get(self: *TypedStorage, comptime T: type) ?T { - const type_id = comptime std.hash.Wyhash.hash(0, @typeName(T)); - const ptr = self.storage.get(type_id) orelse return null; - return @as(*T, @ptrCast(@alignCast(ptr))).*; - } -}; - -test "TypedStorage: Basic" { - var storage: TypedStorage = .init(testing.allocator); - defer storage.deinit(); - - // Test inserting and getting different types - try storage.put(u32, 42); - try storage.put([]const u8, "hello"); - try storage.put(f32, 3.14); - - try testing.expectEqual(@as(u32, 42), storage.get(u32).?); - try testing.expectEqualStrings("hello", storage.get([]const u8).?); - try testing.expectEqual(@as(f32, 3.14), storage.get(f32).?); - - // Test overwriting a value - try storage.put(u32, 100); - try testing.expectEqual(@as(u32, 100), storage.get(u32).?); - - // Test getting non-existent type - try testing.expectEqual(@as(?bool, null), storage.get(bool)); - - // Test clearing - storage.clear(); - try testing.expectEqual(@as(?u32, null), storage.get(u32)); - try testing.expectEqual(@as(?[]const u8, null), storage.get([]const u8)); - try testing.expectEqual(@as(?f32, null), storage.get(f32)); - - // Test inserting after clear - try storage.put(u32, 200); - try testing.expectEqual(@as(u32, 200), storage.get(u32).?); -} From d9d8f2b552c02bf3b5f1b10b484a1379a1faa023 Mon Sep 17 00:00:00 2001 From: Bernard Assan Date: Tue, 14 Jul 2026 01:09:50 +0000 Subject: [PATCH 3/8] modularize http.Context start modularizing http modules and namespace Signed-off-by: Bernard Assan --- examples/basic/main.zig | 2 +- examples/cookies/main.zig | 2 +- examples/form/main.zig | 2 +- examples/fs/main.zig | 2 +- examples/middleware/main.zig | 2 +- examples/sse/main.zig | 2 +- examples/tls/main.zig | 2 +- examples/unix/main.zig | 2 +- src/core.zig | 2 +- src/http.zig | 32 ++++++++++++++++++++++++++++++++ src/http/Context.zig | 30 ++++++++++++++++++++++++++++++ src/http/context.zig | 31 ------------------------------- src/http/lib.zig | 32 -------------------------------- src/root.zig | 5 ++--- 14 files changed, 73 insertions(+), 75 deletions(-) create mode 100644 src/http.zig create mode 100644 src/http/Context.zig delete mode 100644 src/http/context.zig delete mode 100644 src/http/lib.zig diff --git a/examples/basic/main.zig b/examples/basic/main.zig index 64c5c61..f4f9ff5 100644 --- a/examples/basic/main.zig +++ b/examples/basic/main.zig @@ -1,7 +1,7 @@ const std = @import("std"); const zzz = @import("zzz"); -const http = zzz.HTTP; +const http = zzz.http; const tardy = zzz.tardy; const Runtime = tardy.Runtime; const Socket = tardy.net.Socket; diff --git a/examples/cookies/main.zig b/examples/cookies/main.zig index c742e20..ffc49be 100644 --- a/examples/cookies/main.zig +++ b/examples/cookies/main.zig @@ -1,7 +1,7 @@ const std = @import("std"); const zzz = @import("zzz"); -const http = zzz.HTTP; +const http = zzz.http; const tardy = zzz.tardy; const Runtime = tardy.Runtime; const Socket = tardy.net.Socket; diff --git a/examples/form/main.zig b/examples/form/main.zig index 6c19825..a683a6a 100644 --- a/examples/form/main.zig +++ b/examples/form/main.zig @@ -1,7 +1,7 @@ const std = @import("std"); const zzz = @import("zzz"); -const http = zzz.HTTP; +const http = zzz.http; const tardy = zzz.tardy; const Runtime = tardy.Runtime; const Socket = tardy.net.Socket; diff --git a/examples/fs/main.zig b/examples/fs/main.zig index ed80d03..f95bedb 100644 --- a/examples/fs/main.zig +++ b/examples/fs/main.zig @@ -1,7 +1,7 @@ const std = @import("std"); const zzz = @import("zzz"); -const http = zzz.HTTP; +const http = zzz.http; const tardy = zzz.tardy; const Runtime = tardy.Runtime; const Socket = tardy.net.Socket; diff --git a/examples/middleware/main.zig b/examples/middleware/main.zig index 02acccd..3959a84 100644 --- a/examples/middleware/main.zig +++ b/examples/middleware/main.zig @@ -1,7 +1,7 @@ const std = @import("std"); const zzz = @import("zzz"); -const http = zzz.HTTP; +const http = zzz.http; const tardy = zzz.tardy; const Runtime = tardy.Runtime; const Socket = tardy.net.Socket; diff --git a/examples/sse/main.zig b/examples/sse/main.zig index dd178d0..3d4b3c5 100644 --- a/examples/sse/main.zig +++ b/examples/sse/main.zig @@ -1,7 +1,7 @@ const std = @import("std"); const zzz = @import("zzz"); -const http = zzz.HTTP; +const http = zzz.http; const tardy = zzz.tardy; const Runtime = tardy.Runtime; const Socket = tardy.net.Socket; diff --git a/examples/tls/main.zig b/examples/tls/main.zig index 2463748..0d21a13 100644 --- a/examples/tls/main.zig +++ b/examples/tls/main.zig @@ -1,7 +1,7 @@ const std = @import("std"); const zzz = @import("zzz"); -const http = zzz.HTTP; +const http = zzz.http; const tardy = zzz.tardy; const Runtime = tardy.Runtime; const Socket = tardy.net.Socket; diff --git a/examples/unix/main.zig b/examples/unix/main.zig index 7b36cc4..008ef80 100644 --- a/examples/unix/main.zig +++ b/examples/unix/main.zig @@ -1,7 +1,7 @@ const std = @import("std"); const zzz = @import("zzz"); -const http = zzz.HTTP; +const http = zzz.http; const tardy = zzz.tardy; const Runtime = tardy.Runtime; const Socket = tardy.net.Socket; diff --git a/src/core.zig b/src/core.zig index 057c631..ded0d09 100644 --- a/src/core.zig +++ b/src/core.zig @@ -1,5 +1,5 @@ pub const string_map = @import("core/string_map.zig"); -pub const TypeStorage = @import("core/TypedStorage.zig"); +pub const TypedStorage = @import("core/TypedStorage.zig"); pub const wrapping = @import("core/wrapping.zig"); pub const Pseudoslice = @import("pseudoslice.zig"); diff --git a/src/http.zig b/src/http.zig new file mode 100644 index 0000000..a9f58ee --- /dev/null +++ b/src/http.zig @@ -0,0 +1,32 @@ +pub const Context = @import("http/Context.zig"); +pub const Cookie = @import("http/cookie.zig").Cookie; +pub const Date = @import("http/date.zig").Date; +pub const Encoding = @import("http/encoding.zig").Encoding; +pub const Form = @import("http/form.zig").Form; +pub const Method = @import("http/method.zig").Method; +pub const Middlewares = @import("http/middlewares/lib.zig"); +pub const Mime = @import("http/mime.zig").Mime; +pub const Query = @import("http/form.zig").Query; +pub const Request = @import("http/request.zig").Request; +pub const Respond = @import("http/response.zig").Respond; +pub const Response = @import("http/response.zig").Response; +pub const Router = @import("http/router.zig").Router; +pub const FsDir = @import("http/router/fs_dir.zig").FsDir; +pub const Layer = @import("http/router/middleware.zig").Layer; +pub const Middleware = @import("http/router/middleware.zig").Middleware; +pub const MiddlewareFn = @import("http/router/middleware.zig").MiddlewareFn; +pub const Next = @import("http/router/middleware.zig").Next; +pub const Route = @import("http/router/route.zig").Route; +pub const Server = @import("http/server.zig").Server; +pub const ServerConfig = @import("http/server.zig").ServerConfig; +pub const SSE = @import("http/sse.zig").SSE; +pub const Status = @import("http/status.zig").Status; + +pub const HTTPError = error{ + TooManyHeaders, + ContentTooLarge, + MalformedRequest, + InvalidMethod, + URITooLong, + HTTPVersionNotSupported, +}; diff --git a/src/http/Context.zig b/src/http/Context.zig new file mode 100644 index 0000000..b85becb --- /dev/null +++ b/src/http/Context.zig @@ -0,0 +1,30 @@ +/// HTTP Context. Contains all of the various information +/// that will persist throughout the lifetime of this Request/Response. +pub const Context = @This(); + +allocator: std.mem.Allocator, +header_writer: *Io.Writer, +runtime: *Runtime, +/// The Request that triggered this handler. +request: *const Request, +response: *Response, +/// Storage +storage: *core.TypedStorage, +/// Socket for this Connection. +socket: SecureSocket, +/// Slice of the URL Slug Captures +captures: []const Capture, +/// Map of the KV Query pairs in the URL +queries: *const string_map.AnyCase, + +const std = @import("std"); +const Io = std.Io; + +const zzz = @import("../root.zig"); +const core = zzz.core; +const string_map = core.string_map; +const Runtime = zzz.tardy.Runtime; +const SecureSocket = zzz.secsock.SecureSocket; +const Request = @import("request.zig").Request; +const Response = @import("response.zig").Response; +const Capture = @import("router/routing_trie.zig").Capture; diff --git a/src/http/context.zig b/src/http/context.zig deleted file mode 100644 index 923e64d..0000000 --- a/src/http/context.zig +++ /dev/null @@ -1,31 +0,0 @@ -const std = @import("std"); -const Io = std.Io; - -const Runtime = @import("tardy").Runtime; -const secsock = @import("secsock"); -const SecureSocket = secsock.SecureSocket; - -const AnyCaseStringMap = @import("../core/any_case_string_map.zig").AnyCaseStringMap; -const TypedStorage = @import("../core/typed_storage.zig").TypedStorage; -const Request = @import("request.zig").Request; -const Response = @import("response.zig").Response; -const Capture = @import("router/routing_trie.zig").Capture; - -/// HTTP Context. Contains all of the various information -/// that will persist throughout the lifetime of this Request/Response. -pub const Context = struct { - allocator: std.mem.Allocator, - header_writer: *Io.Writer, - runtime: *Runtime, - /// The Request that triggered this handler. - request: *const Request, - response: *Response, - /// Storage - storage: *TypedStorage, - /// Socket for this Connection. - socket: SecureSocket, - /// Slice of the URL Slug Captures - captures: []const Capture, - /// Map of the KV Query pairs in the URL - queries: *const AnyCaseStringMap, -}; diff --git a/src/http/lib.zig b/src/http/lib.zig deleted file mode 100644 index 148534e..0000000 --- a/src/http/lib.zig +++ /dev/null @@ -1,32 +0,0 @@ -pub const Context = @import("context.zig").Context; -pub const Cookie = @import("cookie.zig").Cookie; -pub const Date = @import("date.zig").Date; -pub const Encoding = @import("encoding.zig").Encoding; -pub const Form = @import("form.zig").Form; -pub const Method = @import("method.zig").Method; -pub const Middlewares = @import("middlewares/lib.zig"); -pub const Mime = @import("mime.zig").Mime; -pub const Query = @import("form.zig").Query; -pub const Request = @import("request.zig").Request; -pub const Respond = @import("response.zig").Respond; -pub const Response = @import("response.zig").Response; -pub const Router = @import("router.zig").Router; -pub const FsDir = @import("router/fs_dir.zig").FsDir; -pub const Layer = @import("router/middleware.zig").Layer; -pub const Middleware = @import("router/middleware.zig").Middleware; -pub const MiddlewareFn = @import("router/middleware.zig").MiddlewareFn; -pub const Next = @import("router/middleware.zig").Next; -pub const Route = @import("router/route.zig").Route; -pub const Server = @import("server.zig").Server; -pub const ServerConfig = @import("server.zig").ServerConfig; -pub const SSE = @import("sse.zig").SSE; -pub const Status = @import("status.zig").Status; - -pub const HTTPError = error{ - TooManyHeaders, - ContentTooLarge, - MalformedRequest, - InvalidMethod, - URITooLong, - HTTPVersionNotSupported, -}; diff --git a/src/root.zig b/src/root.zig index ca7256c..b45372c 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,10 +1,9 @@ -const std = @import("std"); - /// Internally exposed secsock. pub const secsock = @import("secsock"); /// Internally exposed Tardy. pub const tardy = @import("tardy"); +pub const core = @import("core.zig"); /// HyperText Transfer Protocol. /// Supports: HTTP/1.1 -pub const HTTP = @import("http/lib.zig"); +pub const http = @import("http.zig"); From 1892ce2af37d0254b3b6fe37f580c917af809005 Mon Sep 17 00:00:00 2001 From: Bernard Assan Date: Wed, 15 Jul 2026 22:03:11 +0000 Subject: [PATCH 4/8] properly modularize and namespace types namespace Route under Router improve middleware namespacing modularize core.* improve importing structure use struct imports where appropriate improve formating Signed-off-by: Bernard Assan Signed-off-by: Bernard Assan --- examples/basic/main.zig | 5 +- examples/cookies/main.zig | 5 +- examples/form/main.zig | 23 +- examples/fs/main.zig | 19 +- examples/middleware/main.zig | 28 +- examples/sse/main.zig | 5 +- examples/tls/main.zig | 7 +- examples/unix/main.zig | 15 +- src/core.zig | 2 +- src/core/TypedStorage.zig | 9 +- src/http.zig | 49 +- src/http/Context.zig | 15 +- src/http/Cookie.zig | 178 +++++ src/http/Date.zig | 168 +++++ src/http/Mime.zig | 446 ++++++++++++ src/http/Request.zig | 275 ++++++++ src/http/Response.zig | 85 +++ src/http/Router.zig | 84 +++ src/http/SSE.zig | 75 ++ src/http/Server.zig | 641 ++++++++++++++++++ src/http/cookie.zig | 177 ----- src/http/date.zig | 150 ---- src/http/encoding.zig | 7 - src/http/form.zig | 153 +++-- src/http/method.zig | 38 +- src/http/middleware.zig | 2 + .../compression.zig | 16 +- .../rate_limit.zig | 50 +- src/http/middlewares/lib.zig | 3 - src/http/mime.zig | 272 -------- src/http/request.zig | 247 ------- src/http/response.zig | 84 --- src/http/router.zig | 72 -- src/http/router/FsDir.zig | 122 ++++ src/http/router/Middleware.zig | 59 ++ src/http/router/Route.zig | 252 +++++++ src/http/router/Trie.zig | 580 ++++++++++++++++ src/http/router/fs_dir.zig | 111 --- src/http/router/middleware.zig | 63 -- src/http/router/route.zig | 218 ------ src/http/router/routing_trie.zig | 543 --------------- src/http/router/token.zig | 119 ++++ src/http/server.zig | 557 --------------- src/http/sse.zig | 70 -- 44 files changed, 3340 insertions(+), 2759 deletions(-) create mode 100644 src/http/Cookie.zig create mode 100644 src/http/Date.zig create mode 100644 src/http/Mime.zig create mode 100644 src/http/Request.zig create mode 100644 src/http/Response.zig create mode 100644 src/http/Router.zig create mode 100644 src/http/SSE.zig create mode 100644 src/http/Server.zig delete mode 100644 src/http/cookie.zig delete mode 100644 src/http/date.zig delete mode 100644 src/http/encoding.zig create mode 100644 src/http/middleware.zig rename src/http/{middlewares => middleware}/compression.zig (75%) rename src/http/{middlewares => middleware}/rate_limit.zig (67%) delete mode 100644 src/http/middlewares/lib.zig delete mode 100644 src/http/mime.zig delete mode 100644 src/http/request.zig delete mode 100644 src/http/response.zig delete mode 100644 src/http/router.zig create mode 100644 src/http/router/FsDir.zig create mode 100644 src/http/router/Middleware.zig create mode 100644 src/http/router/Route.zig create mode 100644 src/http/router/Trie.zig delete mode 100644 src/http/router/fs_dir.zig delete mode 100644 src/http/router/middleware.zig delete mode 100644 src/http/router/route.zig delete mode 100644 src/http/router/routing_trie.zig create mode 100644 src/http/router/token.zig delete mode 100644 src/http/server.zig delete mode 100644 src/http/sse.zig diff --git a/examples/basic/main.zig b/examples/basic/main.zig index f4f9ff5..fddc1c0 100644 --- a/examples/basic/main.zig +++ b/examples/basic/main.zig @@ -8,7 +8,7 @@ const Socket = tardy.net.Socket; const Server = http.Server; const Router = http.Router; const Context = http.Context; -const Route = http.Route; +const Route = Router.Route; const Respond = http.Respond; const log = std.log.scoped(.@"examples/basic"); @@ -47,9 +47,10 @@ pub fn main(init: std.process.Init) !void { router: *const Router, socket: Socket, }; + const params: EntryParams = .{ .router = &router, .socket = socket }; try t.entry( - EntryParams{ .router = &router, .socket = socket }, + params, struct { fn entry(rt: *Runtime, p: EntryParams) !void { var server: Server = .init(.{ diff --git a/examples/cookies/main.zig b/examples/cookies/main.zig index ffc49be..e3954d0 100644 --- a/examples/cookies/main.zig +++ b/examples/cookies/main.zig @@ -8,7 +8,7 @@ const Socket = tardy.net.Socket; const Server = http.Server; const Router = http.Router; const Context = http.Context; -const Route = http.Route; +const Route = Router.Route; const Middleware = http.Middleware; const Respond = http.Respond; const Cookie = http.Cookie; @@ -54,9 +54,10 @@ pub fn main(init: std.process.Init) !void { router: *const Router, socket: Socket, }; + const params: EntryParams = .{ .router = &router, .socket = socket }; try t.entry( - EntryParams{ .router = &router, .socket = socket }, + params, struct { fn entry(rt: *Runtime, p: EntryParams) !void { var server: Server = .init(.{ diff --git a/examples/form/main.zig b/examples/form/main.zig index a683a6a..8844b3f 100644 --- a/examples/form/main.zig +++ b/examples/form/main.zig @@ -8,9 +8,10 @@ const Socket = tardy.net.Socket; const Server = http.Server; const Router = http.Router; const Context = http.Context; -const Route = http.Route; -const Form = http.Form; -const Query = http.Query; +const Route = Router.Route; +const form = http.form; +const Form = form.Form; +const Query = form.Query; const Respond = http.Respond; const log = std.log.scoped(.@"examples/form"); @@ -35,7 +36,7 @@ fn base_handler(ctx: *const Context, _: void) !Respond { return ctx.response.apply(.{ .status = .OK, - .mime = http.Mime.HTML, + .mime = .HTML, .body = body, }); } @@ -71,7 +72,7 @@ fn generate_handler(ctx: *const Context, _: void) !Respond { return ctx.response.apply(.{ .status = .OK, - .mime = http.Mime.TEXT, + .mime = .TEXT, .body = body, }); } @@ -85,11 +86,16 @@ pub fn main(init: std.process.Init) !void { var router: Router = try .init(init.gpa, &.{ Route.init("/").get({}, base_handler).layer(), - Route.init("/generate").get({}, generate_handler).post({}, generate_handler).layer(), + Route.init("/generate").get({}, generate_handler).post( + {}, + generate_handler, + ).layer(), }, .{}); defer router.deinit(init.gpa); - var socket: Socket = try .init(init.io, .{ .tcp = .{ .host = host, .port = port } }); + var socket: Socket = try .init(init.io, .{ + .tcp = .{ .host = host, .port = port }, + }); defer socket.close_blocking(); try socket.bind(); try socket.listen(4096); @@ -98,9 +104,10 @@ pub fn main(init: std.process.Init) !void { router: *const Router, socket: Socket, }; + const params: EntryParams = .{ .router = &router, .socket = socket }; try t.entry( - EntryParams{ .router = &router, .socket = socket }, + params, struct { fn entry(rt: *Runtime, p: EntryParams) !void { var server: Server = .init(.{ diff --git a/examples/fs/main.zig b/examples/fs/main.zig index f95bedb..2c56d26 100644 --- a/examples/fs/main.zig +++ b/examples/fs/main.zig @@ -9,10 +9,10 @@ const Dir = tardy.fs.Dir; const Server = http.Server; const Router = http.Router; const Context = http.Context; -const Route = http.Route; +const Route = Router.Route; const Respond = http.Respond; -const FsDir = http.FsDir; -const Compression = http.Middlewares.Compression; +const FsDir = Router.FsDir; +const Compression = http.middleware.Compression; const log = std.log.scoped(.@"examples/fs"); @@ -56,11 +56,6 @@ pub fn main(init: std.process.Init) !void { }, .{}); defer router.deinit(init.gpa); - const EntryParams = struct { - router: *const Router, - socket: Socket, - }; - var socket: Socket = try .init( init.io, .{ .tcp = .{ .host = host, .port = port } }, @@ -69,8 +64,14 @@ pub fn main(init: std.process.Init) !void { try socket.bind(); try socket.listen(256); + const EntryParams = struct { + router: *const Router, + socket: Socket, + }; + const params: EntryParams = .{ .router = &router, .socket = socket }; + try t.entry( - EntryParams{ .router = &router, .socket = socket }, + params, struct { fn entry(rt: *Runtime, p: EntryParams) !void { var server: Server = .init(.{ diff --git a/examples/middleware/main.zig b/examples/middleware/main.zig index 3959a84..1426530 100644 --- a/examples/middleware/main.zig +++ b/examples/middleware/main.zig @@ -8,10 +8,10 @@ const Socket = tardy.net.Socket; const Server = http.Server; const Router = http.Router; const Context = http.Context; -const Route = http.Route; -const Next = http.Next; +const Route = Router.Route; const Respond = http.Respond; -const Middleware = http.Middleware; +const Middleware = Router.Middleware; +const Next = Middleware.Next; const log = std.log.scoped(.@"examples/middleware"); @@ -59,10 +59,6 @@ pub fn main(init: std.process.Init) !void { const host: []const u8 = "0.0.0.0"; const port: u16 = 9862; - var gpa: std.heap.DebugAllocator(.{}) = .init; - const allocator = gpa.allocator(); - defer _ = gpa.deinit(); - var t: Tardy = try .init(init.gpa, init.io, .{ .threading = .single }); defer t.deinit(); @@ -75,20 +71,24 @@ pub fn main(init: std.process.Init) !void { Route.init("/").post(num, root_handler).layer(), Route.init("/fail").get(num, root_handler).layer(), }, .{}); - defer router.deinit(allocator); + defer router.deinit(init.gpa); + + var socket: Socket = try .init(init.io, .{ + .tcp = .{ .host = host, .port = port }, + }); + defer socket.close_blocking(); + + try socket.bind(); + try socket.listen(256); const EntryParams = struct { router: *const Router, socket: Socket, }; - - var socket: Socket = try .init(init.io, .{ .tcp = .{ .host = host, .port = port } }); - defer socket.close_blocking(); - try socket.bind(); - try socket.listen(256); + const params: EntryParams = .{ .router = &router, .socket = socket }; try t.entry( - EntryParams{ .router = &router, .socket = socket }, + params, struct { fn entry(rt: *Runtime, p: EntryParams) !void { var server: Server = .init(.{}); diff --git a/examples/sse/main.zig b/examples/sse/main.zig index 3d4b3c5..7d80d65 100644 --- a/examples/sse/main.zig +++ b/examples/sse/main.zig @@ -9,7 +9,7 @@ const Timer = Runtime.Timer; const Server = http.Server; const Router = http.Router; const Context = http.Context; -const Route = http.Route; +const Route = Router.Route; const Respond = http.Respond; const SSE = http.SSE; @@ -53,9 +53,10 @@ pub fn main(init: std.process.Init) !void { router: *const Router, socket: Socket, }; + const params: EntryParams = .{ .router = &router, .socket = socket }; try t.entry( - EntryParams{ .router = &router, .socket = socket }, + params, struct { fn entry(rt: *Runtime, p: EntryParams) !void { var server: Server = .init(.{ diff --git a/examples/tls/main.zig b/examples/tls/main.zig index 0d21a13..4d07706 100644 --- a/examples/tls/main.zig +++ b/examples/tls/main.zig @@ -7,12 +7,12 @@ const Runtime = tardy.Runtime; const Socket = tardy.net.Socket; const Server = http.Server; const Context = http.Context; -const Route = http.Route; +const Route = Router.Route; const Router = http.Router; const Respond = http.Respond; const secsock = zzz.secsock; const SecureSocket = secsock.SecureSocket; -const Compression = http.Middlewares.Compression; +const Compression = http.middleware.Compression; const log = std.log.scoped(.@"examples/tls"); @@ -79,9 +79,10 @@ pub fn main(init: std.process.Init) !void { router: *const Router, socket: SecureSocket, }; + const params: EntryParams = .{ .router = &router, .socket = secure }; try t.entry( - EntryParams{ .router = &router, .socket = secure }, + params, struct { fn entry(rt: *Runtime, p: EntryParams) !void { var server: Server = .init(.{ .stack_size = .max }); diff --git a/examples/unix/main.zig b/examples/unix/main.zig index 008ef80..ab4be38 100644 --- a/examples/unix/main.zig +++ b/examples/unix/main.zig @@ -7,8 +7,8 @@ const Runtime = tardy.Runtime; const Socket = tardy.net.Socket; const Server = http.Server; const Context = http.Context; -const Route = http.Route; const Router = http.Router; +const Route = Router.Route; const Respond = http.Respond; const log = std.log.scoped(.@"examples/benchmark"); @@ -34,11 +34,6 @@ pub fn main(init: std.process.Init) !void { }, .{}); defer router.deinit(init.gpa); - const EntryParams = struct { - router: *const Router, - socket: Socket, - }; - var socket: Socket = try .init(init.io, .{ .unix = "/tmp/zzz.sock" }); defer std.Io.Dir.deleteDirAbsolute(init.io, "/tmp/zzz.sock") catch unreachable; defer socket.close_blocking(); @@ -46,8 +41,14 @@ pub fn main(init: std.process.Init) !void { try socket.bind(); try socket.listen(256); + const EntryParams = struct { + router: *const Router, + socket: Socket, + }; + const params: EntryParams = .{ .router = &router, .socket = socket }; + try t.entry( - EntryParams{ .router = &router, .socket = socket }, + params, struct { fn entry(rt: *Runtime, p: EntryParams) !void { var server: Server = .init(.{}); diff --git a/src/core.zig b/src/core.zig index ded0d09..b6be2b9 100644 --- a/src/core.zig +++ b/src/core.zig @@ -1,7 +1,7 @@ pub const string_map = @import("core/string_map.zig"); pub const TypedStorage = @import("core/TypedStorage.zig"); pub const wrapping = @import("core/wrapping.zig"); -pub const Pseudoslice = @import("pseudoslice.zig"); +pub const Pseudoslice = @import("core/Pseudoslice.zig"); pub fn Pair(comptime A: type, comptime B: type) type { return struct { A, B }; diff --git a/src/core/TypedStorage.zig b/src/core/TypedStorage.zig index 0037465..ef2e6d9 100644 --- a/src/core/TypedStorage.zig +++ b/src/core/TypedStorage.zig @@ -1,11 +1,9 @@ pub const TypedStorage = @This(); arena: std.heap.ArenaAllocator, -storage: hash_map.Custom( +storage: hash_map.AutoHashMapUnmanaged( Key, *anyopaque, - hash_map.AutoContext(Key), - hash_map.default_max_load_percentage, ), pub fn init(allocator: mem.Allocator) TypedStorage { @@ -31,14 +29,14 @@ pub fn put(self: *TypedStorage, comptime T: type, value: T) !void { const allocator = self.arena.allocator(); const ptr = try allocator.create(T); ptr.* = value; - const type_id = comptime std.hash.Wyhash.hash(0, @typeName(T)); + const type_id = comptime hash.Wyhash.hash(0, @typeName(T)); try self.storage.put(allocator, type_id, @ptrCast(ptr)); } /// Extracts a value out of the Storage. /// It uses the given type as the K. pub fn get(self: *TypedStorage, comptime T: type) ?T { - const type_id = comptime std.hash.Wyhash.hash(0, @typeName(T)); + const type_id = comptime hash.Wyhash.hash(0, @typeName(T)); const ptr = self.storage.get(type_id) orelse return null; return @as(*T, @ptrCast(@alignCast(ptr))).*; } @@ -78,5 +76,6 @@ const Key = u64; const std = @import("std"); const testing = std.testing; +const hash = std.hash; const mem = std.mem; const hash_map = std.hash_map; diff --git a/src/http.zig b/src/http.zig index a9f58ee..ad6e533 100644 --- a/src/http.zig +++ b/src/http.zig @@ -1,28 +1,35 @@ pub const Context = @import("http/Context.zig"); -pub const Cookie = @import("http/cookie.zig").Cookie; -pub const Date = @import("http/date.zig").Date; -pub const Encoding = @import("http/encoding.zig").Encoding; -pub const Form = @import("http/form.zig").Form; +pub const Cookie = @import("http/Cookie.zig"); +pub const Date = @import("http/Date.zig"); +pub const form = @import("http/form.zig"); pub const Method = @import("http/method.zig").Method; -pub const Middlewares = @import("http/middlewares/lib.zig"); -pub const Mime = @import("http/mime.zig").Mime; -pub const Query = @import("http/form.zig").Query; -pub const Request = @import("http/request.zig").Request; -pub const Respond = @import("http/response.zig").Respond; -pub const Response = @import("http/response.zig").Response; -pub const Router = @import("http/router.zig").Router; -pub const FsDir = @import("http/router/fs_dir.zig").FsDir; -pub const Layer = @import("http/router/middleware.zig").Layer; -pub const Middleware = @import("http/router/middleware.zig").Middleware; -pub const MiddlewareFn = @import("http/router/middleware.zig").MiddlewareFn; -pub const Next = @import("http/router/middleware.zig").Next; -pub const Route = @import("http/router/route.zig").Route; -pub const Server = @import("http/server.zig").Server; -pub const ServerConfig = @import("http/server.zig").ServerConfig; -pub const SSE = @import("http/sse.zig").SSE; +pub const middleware = @import("http/middleware.zig"); +pub const Mime = @import("http/Mime.zig"); +pub const Request = @import("http/Request.zig"); +pub const Response = @import("http/Response.zig"); +pub const Router = @import("http/Router.zig"); +pub const Server = @import("http/Server.zig"); +pub const SSE = @import("http/SSE.zig"); pub const Status = @import("http/status.zig").Status; -pub const HTTPError = error{ +pub const Respond = enum { + // When we are returning a real HTTP request, we use this. + standard, + // If we responded and we want to give control back to the HTTP engine. + responded, + // If we want the connection to close. + close, +}; + +pub const Encoding = enum { + gzip, + compress, + deflate, + br, + zstd, +}; + +pub const Error = error{ TooManyHeaders, ContentTooLarge, MalformedRequest, diff --git a/src/http/Context.zig b/src/http/Context.zig index b85becb..0ebaf3d 100644 --- a/src/http/Context.zig +++ b/src/http/Context.zig @@ -6,14 +6,14 @@ allocator: std.mem.Allocator, header_writer: *Io.Writer, runtime: *Runtime, /// The Request that triggered this handler. -request: *const Request, -response: *Response, +request: *const http.Request, +response: *http.Response, /// Storage storage: *core.TypedStorage, /// Socket for this Connection. -socket: SecureSocket, +socket: secsock.SecureSocket, /// Slice of the URL Slug Captures -captures: []const Capture, +captures: []const Trie.Capture, /// Map of the KV Query pairs in the URL queries: *const string_map.AnyCase, @@ -23,8 +23,7 @@ const Io = std.Io; const zzz = @import("../root.zig"); const core = zzz.core; const string_map = core.string_map; +const http = zzz.http; const Runtime = zzz.tardy.Runtime; -const SecureSocket = zzz.secsock.SecureSocket; -const Request = @import("request.zig").Request; -const Response = @import("response.zig").Response; -const Capture = @import("router/routing_trie.zig").Capture; +const secsock = zzz.secsock; +const Trie = @import("router/Trie.zig"); diff --git a/src/http/Cookie.zig b/src/http/Cookie.zig new file mode 100644 index 0000000..74e8a57 --- /dev/null +++ b/src/http/Cookie.zig @@ -0,0 +1,178 @@ +pub const Cookie = @This(); + +name: []const u8, +value: []const u8, +path: ?[]const u8 = null, +domain: ?[]const u8 = null, +expires: ?Date = null, +max_age: ?u32 = null, +secure: bool = false, +http_only: bool = false, +same_site: ?SameSite = null, + +pub fn init(name: []const u8, value: []const u8) Cookie { + return .{ + .name = name, + .value = value, + }; +} + +pub const SameSite = enum { + strict, + lax, + none, + + pub fn to_string(self: SameSite) []const u8 { + return switch (self) { + .strict => "Strict", + .lax => "Lax", + .none => "None", + }; + } +}; + +pub fn to_string_buf(self: Cookie, buf: []u8) ![]const u8 { + const writer: Io.Writer = .fixed(buf); + + try writer.print("{s}={s}", .{ self.name, self.value }); + if (self.domain) |domain| try writer.print("; Domain={s}", .{domain}); + if (self.path) |path| try writer.print("; Path={s}", .{path}); + if (self.expires) |exp| { + try writer.writeAll("; Expires="); + try exp.to_http_date().into_writer(&writer); + } + if (self.max_age) |age| try writer.print("; Max-Age={d}", .{age}); + if (self.same_site) |same_site| try writer.print( + "; SameSite={s}", + .{same_site.to_string()}, + ); + if (self.secure) try writer.writeAll("; Secure"); + if (self.http_only) try writer.writeAll("; HttpOnly"); + + return writer.buffered(); +} + +pub fn to_string_alloc(self: Cookie, allocator: mem.Allocator) ![]const u8 { + var aw: Io.Writer.Allocating = try .initCapacity(allocator, 128); + errdefer aw.deinit(); + const writer = &aw.writer; + + try writer.print("{s}={s}", .{ self.name, self.value }); + if (self.domain) |domain| try writer.print("; Domain={s}", .{domain}); + if (self.path) |path| try writer.print("; Path={s}", .{path}); + if (self.expires) |exp| { + try writer.writeAll("; Expires="); + try exp.to_http_date().into_writer(writer); + } + if (self.max_age) |age| try writer.print("; Max-Age={d}", .{age}); + if (self.same_site) |same_site| try writer.print( + "; SameSite={s}", + .{same_site.to_string()}, + ); + if (self.secure) try writer.writeAll("; Secure"); + if (self.http_only) try writer.writeAll("; HttpOnly"); + + return try aw.toOwnedSlice(); +} + +pub const Map = struct { + allocator: mem.Allocator, + map: std.StringHashMap([]const u8), + + pub fn init(allocator: mem.Allocator) Map { + return .{ + .allocator = allocator, + .map = .init(allocator), + }; + } + + pub fn deinit(self: *Map) void { + var iter = self.map.iterator(); + while (iter.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + self.allocator.free(entry.value_ptr.*); + } + self.map.deinit(); + } + + pub fn clear(self: *Map) void { + var iter = self.map.iterator(); + while (iter.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + self.allocator.free(entry.value_ptr.*); + } + self.map.clearRetainingCapacity(); + } + + pub fn get(self: Map, name: []const u8) ?[]const u8 { + return self.map.get(name); + } + + pub fn count(self: Map) usize { + return self.map.count(); + } + + pub fn iterator(self: *const Map) std.StringHashMap([]const u8).Iterator { + return self.map.iterator(); + } + + // For parsing request cookies (simple key=value pairs) + pub fn parse_from_header(self: *Map, cookie_header: []const u8) !void { + self.clear(); + + var pairs = mem.splitSequence(u8, cookie_header, "; "); + while (pairs.next()) |pair| { + var kv = mem.splitScalar(u8, pair, '='); + const key = kv.next() orelse continue; + const value = kv.rest(); + + const key_dup = try self.allocator.dupe(u8, key); + errdefer self.allocator.free(key_dup); + const value_dup = try self.allocator.dupe(u8, value); + errdefer self.allocator.free(value_dup); + + if (try self.map.fetchPut(key_dup, value_dup)) |existing| { + self.allocator.free(existing.key); + self.allocator.free(existing.value); + } + } + } +}; + +test "Cookie: Header Parsing" { + var cookie_map: Cookie.Map = .init(testing.allocator); + defer cookie_map.deinit(); + + try cookie_map.parse_from_header("sessionId=abc123; java=slop; foo=bar=baz"); + try testing.expectEqualStrings("abc123", cookie_map.get("sessionId").?); + try testing.expectEqualStrings("slop", cookie_map.get("java").?); + try testing.expectEqualStrings("bar=baz", cookie_map.get("foo").?); +} + +test "Cookie: Response Formatting" { + const cookie: Cookie = .{ + .name = "session", + .value = "abc123", + .path = "/", + .domain = "example.com", + .secure = true, + .http_only = true, + .same_site = .strict, + .max_age = 3600, + }; + + const formatted = try cookie.to_string_alloc(testing.allocator); + defer testing.allocator.free(formatted); + + try testing.expectEqualStrings( + "session=abc123; Domain=example.com; Path=/; Max-Age=3600; SameSite=Strict; Secure; HttpOnly", + formatted, + ); +} + +const std = @import("std"); +const mem = std.mem; +const testing = std.testing; +const Io = std.Io; + +const Date = @import("Date.zig"); diff --git a/src/http/Date.zig b/src/http/Date.zig new file mode 100644 index 0000000..8ef03c3 --- /dev/null +++ b/src/http/Date.zig @@ -0,0 +1,168 @@ +pub const Date = @This(); + +// TODO: think of a better namespace name +const HTTPDate = struct { + const format = std.fmt.comptimePrint( + "{s}, {s} {s} {s} {s}:{s}:{s} GMT", + .{ + "{[day_name]s}", + "{[day]d}", + "{[month]s}", + "{[year]d}", + "{[hour]d:0>2}", + "{[minute]d:0>2}", + "{[second]d:0>2}", + }, + ); + + day_name: []const u8, + day: u8, + month: []const u8, + year: u16, + hour: u8, + minute: u8, + second: u8, + + pub fn into_buf(date: HTTPDate, buffer: []u8) ![]u8 { + assert(buffer.len >= 29); + return try std.fmt.bufPrint(buffer, format, date); + } + + pub fn into_alloc(date: HTTPDate, allocator: std.mem.Allocator) ![]const u8 { + return try std.fmt.allocPrint(allocator, format, date); + } + + pub fn into_writer(date: HTTPDate, writer: *Io.Writer) !void { + try writer.print(format, date); + } +}; + +// TODO: use timestamp type +ts: i64, + +pub fn init(ts: i64) Date { + return .{ .ts = ts }; +} + +fn is_leap_year(year: i64) bool { + return (@rem(year, 4) == 0 and @rem(year, 100) != 0) or (@rem(year, 400) == 0); +} + +pub fn to_http_date(date: Date) HTTPDate { + const secs = date.ts; + const days = @divFloor(secs, 86400); + const remsecs = @mod(secs, 86400); + + var year: i64 = 1970; + var remaining_days = days; + while (true) { + const days_in_year: i64 = if (is_leap_year(year)) 366 else 365; + if (remaining_days < days_in_year) break; + remaining_days -= days_in_year; + year += 1; + } + + var month: usize = 0; + for (months, 0..) |m, i| { + const days_in_month = if (i == 1 and is_leap_year(year)) 29 else m.days; + if (remaining_days < days_in_month) break; + remaining_days -= days_in_month; + month += 1; + } + + const day = remaining_days + 1; + const week_day = @mod((days + 3), 7); + + const hour: u8 = @intCast(@divFloor(remsecs, 3600)); + const minute: u8 = @intCast(@mod(@divFloor(remsecs, 60), 60)); + const second: u8 = @intCast(@mod(remsecs, 60)); + + return .{ + .day_name = day_names[@intCast(week_day)], + .day = @intCast(day), + .month = months[month].name, + .year = @intCast(year), + .hour = hour, + .minute = minute, + .second = second, + }; +} + +const day_names: []const []const u8 = &.{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", +}; + +const Month = struct { + name: []const u8, + days: u32, +}; + +const months: []const Month = &.{ + .{ .name = "Jan", .days = 31 }, + .{ .name = "Feb", .days = 28 }, + .{ .name = "Mar", .days = 31 }, + .{ .name = "Apr", .days = 30 }, + .{ .name = "May", .days = 31 }, + .{ .name = "Jun", .days = 30 }, + .{ .name = "Jul", .days = 31 }, + .{ .name = "Aug", .days = 31 }, + .{ .name = "Sep", .days = 30 }, + .{ .name = "Oct", .days = 31 }, + .{ .name = "Nov", .days = 30 }, + .{ .name = "Dec", .days = 31 }, +}; + +test "Parse Basic Date (Buffer)" { + const ts = 1727411110; + var date: Date = .init(ts); + + const http_date = date.to_http_date(); + + var buffer: [29]u8 = @splat(0); + try testing.expectEqualStrings( + "Fri, 27 Sep 2024 04:25:10 GMT", + try http_date.into_buf(buffer[0..]), + ); +} + +test "Parse Basic Date (Alloc)" { + const ts = 1727464105; + var date: Date = .init(ts); + + const http_date = date.to_http_date(); + const http_string = try http_date.into_alloc(testing.allocator); + defer testing.allocator.free(http_string); + + try testing.expectEqualStrings( + "Fri, 27 Sep 2024 19:08:25 GMT", + http_string, + ); +} + +test "Parse Basic Date (Writer)" { + const ts = 672452112; + var date: Date = .init(ts); + + const http_date = date.to_http_date(); + + var buffer: [29]u8 = @splat(0); + var stream_w: Io.Writer = .fixed(&buffer); + try http_date.into_writer(&stream_w); + const http_string = stream_w.buffered(); + + try testing.expectEqualStrings( + "Wed, 24 Apr 1991 00:15:12 GMT", + http_string, + ); +} + +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const Io = std.Io; diff --git a/src/http/Mime.zig b/src/http/Mime.zig new file mode 100644 index 0000000..519c27f --- /dev/null +++ b/src/http/Mime.zig @@ -0,0 +1,446 @@ +/// MIME Types. +pub const Mime = @This(); +/// This is the actual MIME type. +content_type: Option, +extension: Option, +description: []const u8, + +pub const AAC: Mime = .init( + &.{"audio/acc"}, + &.{"acc"}, + "AAC Audio", +); +pub const APNG: Mime = .init( + &.{"image/apng"}, + &.{"apng"}, + "Animated Portable Network Graphics (APNG) Image", +); +pub const AVIF: Mime = .init( + &.{"image/avif"}, + &.{"avif"}, + "AVIF Image", +); +pub const AVI: Mime = .init( + &.{"video/x-msvideo"}, + &.{"avi"}, + "AVI: Audio Video Interleave", +); +pub const AZW: Mime = .init( + &.{"application/vnd.amazon.ebook"}, + &.{"azw"}, + "AZW: Amazon Kindle eBook format", +); +pub const BIN: Mime = .init( + &.{"application/octet-stream"}, + &.{"bin"}, + "Any kind of binary data", +); +pub const BMP: Mime = .init( + &.{"image/bmp"}, + &.{"bmp"}, + "Windows OS/2 Bitmap Graphics", +); +pub const BZ: Mime = .init( + &.{"application/x-bzip"}, + &.{"bz"}, + "BZip archive", +); +pub const BZ2: Mime = .init( + &.{"application/x-bzip2"}, + &.{"bz2"}, + "BZip2 archive", +); +pub const CDA: Mime = .init( + &.{"application/x-cdf"}, + &.{"cda"}, + "CD audio", +); +pub const CSS: Mime = .init( + &.{"text/css"}, + &.{"css"}, + "Cascading Style Sheets (CSS)", +); +pub const CSV: Mime = .init( + &.{"text/csv"}, + &.{"csv"}, + "Comma-separated values (CSV)", +); +pub const DOC: Mime = .init( + &.{"application/msword"}, + &.{"doc"}, + "Microsoft Word", +); +pub const DOCX: Mime = .init( + &.{"application/vnd.openxlformats-officedocument.wordprocessingml.document"}, + &.{"docx"}, + "Microsoft Word (OpenXML)", +); +pub const EPUB: Mime = .init( + &.{"application/epub+zip"}, + &.{"epub"}, + "Electronic Publication", +); +pub const GIF: Mime = .init( + &.{"image/gif"}, + &.{"gif"}, + "Graphics Interchange Format (GIF)", +); +pub const GZ: Mime = .init( + &.{ "application/gzip", "application/x-gzip" }, + &.{"gz"}, + "GZip Compressed Archive", +); +pub const HTML: Mime = .init( + &.{"text/html"}, + &.{ "html", "htm" }, + "HyperText Markup Language (HTML)", +); +pub const ICO: Mime = .init( + &.{ "image/x-icon", "image/vnd.microsoft.icon" }, + &.{"ico"}, + "Icon Format", +); +pub const ICS: Mime = .init( + &.{"text/calander"}, + &.{"ics"}, + "iCalendar format", +); +pub const JAR: Mime = .init( + &.{"application/java-archive"}, + &.{"jar"}, + "Java Archive", +); +pub const JPEG: Mime = .init( + &.{"image/jpeg"}, + &.{ "jpeg", "jpg" }, + "JPEG Image", +); +pub const JS: Mime = .init( + &.{ "text/javascript", "application/javascript" }, + &.{"js"}, + "JavaScript", +); +pub const JSON: Mime = .init( + &.{"application/json"}, + &.{"json"}, + "JSON Format", +); +pub const MP3: Mime = .init( + &.{"audio/mpeg"}, + &.{"mp3"}, + "MP3 audio", +); +pub const MP4: Mime = .init( + &.{"video/mp4"}, + &.{"mp4"}, + "MP4 Video", +); +pub const OGA: Mime = .init( + &.{"audio/ogg"}, + &.{"ogg"}, + "Ogg audio", +); +pub const OGV: Mime = .init( + &.{"video/ogg"}, + &.{"ogv"}, + "Ogg video", +); +pub const OGX: Mime = .init( + &.{"application/ogg"}, + &.{"ogx"}, + "Ogg multiplexed audo and video", +); +pub const OTF: Mime = .init( + &.{"font/otf"}, + &.{"otf"}, + "OpenType font", +); +pub const PDF: Mime = .init( + &.{"application/pdf"}, + &.{"pdf"}, + "Adobe Portable Document Format", +); +pub const PHP: Mime = .init( + &.{"application/x-httpd-php"}, + &.{"php"}, + "Hypertext Preprocessor (Personal Home Page)", +); +pub const PNG: Mime = .init( + &.{"image/png"}, + &.{"png"}, + "Portable Network Graphics", +); +pub const RAR: Mime = .init( + &.{"application/vnd.rar"}, + &.{"rar"}, + "RAR archive", +); +pub const RTF: Mime = .init( + &.{"application/rtf"}, + &.{"rtf"}, + "Rich Text Format (RTF)", +); +pub const SH: Mime = .init( + &.{"application/x-sh"}, + &.{"sh"}, + "Bourne shell script", +); +pub const SVG: Mime = .init( + &.{"image/svg+xml"}, + &.{"svg"}, + "Scalable Vector Graphics (SVG)", +); +pub const TAR: Mime = .init( + &.{"application/x-tar"}, + &.{"tar"}, + "Tape Archive (TAR)", +); +pub const TEXT: Mime = .init( + &.{"text/plain"}, + &.{"txt"}, + "Text (generally ASCII or ISO-8859-n)", +); +pub const TSV: Mime = .init( + &.{"text/tab-seperated-values"}, + &.{"tsv"}, + "Tab-seperated values (TSV)", +); +pub const TTF: Mime = .init( + &.{"font/ttf"}, + &.{"ttf"}, + "TrueType Font", +); +pub const WAV: Mime = .init( + &.{"audio/wav"}, + &.{"wav"}, + "Waveform Audio Format", +); +pub const WEBA: Mime = .init( + &.{"audio/webm"}, + &.{"weba"}, + "WEBM Audio", +); +pub const WEBM: Mime = .init( + &.{"video/webm"}, + &.{"webm"}, + "WEBM Video", +); +pub const WEBP: Mime = .init( + &.{"image/webp"}, + &.{"webp"}, + "WEBP Image", +); +pub const WOFF: Mime = .init( + &.{"font/woff"}, + &.{"woff"}, + "Web Open Font Format (WOFF)", +); +pub const WOFF2: Mime = .init( + &.{"font/woff2"}, + &.{"woff2"}, + "Web Open Font Format (WOFF)", +); +pub const XML: Mime = .init( + &.{"application/xml"}, + &.{"xml"}, + "XML", +); +pub const ZIP: Mime = .init( + &.{"application/zip"}, + &.{"zip"}, + "ZIP Archive", +); +pub const @"7Z": Mime = .init( + &.{"application/x-7z-compressed"}, + &.{"7z"}, + "7-zip archive", +); + +pub fn init( + comptime content_type: []const [:0]const u8, + comptime extension: []const [:0]const u8, + description: []const u8, +) Mime { + return .{ + .content_type = generate_mime_helper(content_type), + .extension = generate_mime_helper(extension), + .description = description, + }; +} + +pub fn from_extension(extension: []const u8) Mime { + assert(extension.len > 0); + return mime_extension_map.get(extension) orelse .BIN; +} + +pub fn from_content_type(content_type: []const u8) Mime { + assert(content_type.len > 0); + return mime_content_map.get(content_type) orelse .BIN; +} + +const Option = union(enum) { + single: [:0]const u8, + /// The first one should be the priority one. + /// The rest should just be there for compatibility reasons. + multiple: []const [:0]const u8, +}; + +fn generate_mime_helper(comptime mime: []const [:0]const u8) Option { + switch (mime.len) { + else => unreachable, + 1 => return .{ .single = mime[0] }, + 2 => return .{ .multiple = mime }, + } +} + +const all_mime_types = blk: { + const decls_names = @typeInfo(Mime).@"struct".decl_names; + var mimes: [decls_names.len]Mime = undefined; + var index: usize = 0; + for (decls_names) |decl| { + if (@TypeOf(@field(Mime, decl)) == Mime) { + mimes[index] = @field(Mime, decl); + index += 1; + } + } + + var return_mimes: [index]Mime = undefined; + for (0..index) |i| { + return_mimes[i] = mimes[i]; + } + + break :blk return_mimes; +}; + +const mime_extension_map: std.StaticStringMap(Mime) = blk: { + const num_pairs = num: { + var count: usize = 0; + for (all_mime_types) |mime| { + var value: usize = 0; + value += switch (mime.extension) { + .single => 1, + .multiple => |items| items.len, + }; + count += value; + } + + break :num count; + }; + + var pairs: [num_pairs]core.Pair([]const u8, Mime) = undefined; + + var index: usize = 0; + for (all_mime_types[0..]) |mime| { + switch (mime.extension) { + .single => |inner| { + defer index += 1; + pairs[index] = .{ inner, mime }; + }, + .multiple => |extensions| { + for (extensions) |ext| { + defer index += 1; + pairs[index] = .{ ext, mime }; + } + }, + } + } + + break :blk .initComptime(pairs); +}; + +const mime_content_map: std.StaticStringMap(Mime) = blk: { + const num_pairs = num: { + var count: usize = 0; + for (all_mime_types) |mime| { + var value: usize = 0; + value += switch (mime.content_type) { + .single => 1, + .multiple => |items| items.len, + }; + count += value; + } + + break :num count; + }; + + var pairs: [num_pairs]core.Pair([]const u8, Mime) = undefined; + + var index: usize = 0; + for (all_mime_types[0..]) |mime| { + switch (mime.content_type) { + .single => |inner| { + defer index += 1; + pairs[index] = .{ inner, mime }; + }, + .multiple => |content_types| { + for (content_types) |ext| { + defer index += 1; + pairs[index] = .{ ext, mime }; + } + }, + } + } + + break :blk .initComptime(pairs); +}; + +test "MIME from extensions" { + for (all_mime_types) |mime| { + switch (mime.extension) { + .single => |inner| { + try testing.expectEqualStrings( + mime.description, + Mime.from_extension(inner).description, + ); + }, + .multiple => |extensions| { + for (extensions) |ext| { + try testing.expectEqualStrings( + mime.description, + Mime.from_extension(ext).description, + ); + } + }, + } + } +} + +test "MIME from unknown extension" { + const extension = ".whatami"; + const mime = Mime.from_extension(extension); + try testing.expectEqual(Mime.BIN, mime); +} + +test "MIME from content types" { + for (all_mime_types) |mime| { + switch (mime.content_type) { + .single => |inner| { + try testing.expectEqualStrings( + mime.description, + Mime.from_content_type(inner).description, + ); + }, + .multiple => |content_types| { + for (content_types) |ext| { + try testing.expectEqualStrings( + mime.description, + Mime.from_content_type(ext).description, + ); + } + }, + } + } +} + +test "MIME from unknown content type" { + const content_type = "application/whatami"; + const mime = Mime.from_content_type(content_type); + try testing.expectEqual(Mime.BIN, mime); +} + +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; + +const zzz = @import("../root.zig"); +const core = zzz.core; diff --git a/src/http/Request.zig b/src/http/Request.zig new file mode 100644 index 0000000..8abc64e --- /dev/null +++ b/src/http/Request.zig @@ -0,0 +1,275 @@ +pub const Request = @This(); + +allocator: mem.Allocator, +method: ?http.Method = null, +uri: ?[]const u8 = null, +version: ?std.http.Version = .@"HTTP/1.1", +headers: string_map.AnyCase, +cookies: Cookie.Map, +body: ?[]const u8 = null, + +/// This is for constructing a Request. +pub fn init(allocator: mem.Allocator) Request { + const headers: string_map.AnyCase = .init(allocator); + const cookies: Cookie.Map = .init(allocator); + + return .{ + .allocator = allocator, + .headers = headers, + .cookies = cookies, + }; +} + +pub fn deinit(self: *Request) void { + self.cookies.deinit(); + self.headers.deinit(); +} + +pub fn clear(self: *Request) void { + self.method = null; + self.uri = null; + self.body = null; + self.cookies.clear(); + self.headers.clearRetainingCapacity(); +} + +const RequestParseOptions = struct { + request_bytes_max: u32, + request_uri_bytes_max: u32, +}; + +pub fn parse_headers( + self: *Request, + bytes: []const u8, + options: RequestParseOptions, +) !void { + self.clear(); + var total_size: u32 = 0; + var lines = mem.tokenizeAny(u8, bytes, "\r\n"); + + if (lines.peek() == null) { + return http.Error.MalformedRequest; + } + + var parsing_first_line = true; + while (lines.next()) |line| { + total_size += @intCast(line.len); + + if (total_size > options.request_bytes_max) { + return http.Error.ContentTooLarge; + } + + if (parsing_first_line) { + var chunks = mem.tokenizeScalar(u8, line, ' '); + + const method_string = chunks.next() orelse + return http.Error.MalformedRequest; + const method = http.Method.parse(method_string) catch { + log.warn("invalid method: {s}", .{method_string}); + return http.Error.InvalidMethod; + }; + + const uri_string = chunks.next() orelse + return http.Error.MalformedRequest; + if (uri_string.len >= options.request_uri_bytes_max) + return http.Error.URITooLong; + if (uri_string[0] != '/') return http.Error.MalformedRequest; + + const version_string = chunks.next() orelse + return http.Error.MalformedRequest; + if (!mem.eql(u8, version_string, "HTTP/1.1")) + return http.Error.HTTPVersionNotSupported; + self.set(.{ .method = method, .uri = uri_string }); + + // There shouldn't be anything else. + if (chunks.next() != null) return http.Error.MalformedRequest; + parsing_first_line = false; + } else { + var header_iter = mem.tokenizeScalar(u8, line, ':'); + const key = header_iter.next() orelse + return http.Error.MalformedRequest; + const value = mem.trimStart( + u8, + header_iter.rest(), + &.{' '}, + ); + if (value.len == 0) return http.Error.MalformedRequest; + try self.headers.put(key, value); + } + } + + if (self.headers.get("Cookie")) |cookies| + try self.cookies.parse_from_header(cookies); +} + +pub const RequestSetOptions = struct { + method: ?http.Method = null, + uri: ?[]const u8 = null, + body: ?[]const u8 = null, +}; + +pub fn set(self: *Request, options: RequestSetOptions) void { + if (options.method) |method| { + self.method = method; + } + + if (options.uri) |uri| { + self.uri = uri; + } + + if (options.body) |body| { + self.body = body; + } +} + +/// Should this specific Request expect to capture a body. +pub fn expect_body(self: Request) bool { + return switch (self.method orelse return false) { + .POST, .PUT, .PATCH => true, + .GET, .HEAD, .DELETE, .CONNECT, .OPTIONS, .TRACE => false, + }; +} + +test "Parse Request" { + const request_text = + \\GET / HTTP/1.1 + \\Host: localhost:9862 + \\Connection: keep-alive + \\Accept: text/html + ; + + var request = Request.init(testing.allocator); + defer request.deinit(); + + try request.parse_headers(request_text[0..], .{ + .request_bytes_max = 1024, + .request_uri_bytes_max = 256, + }); + + try testing.expectEqual(.GET, request.method); + try testing.expectEqualStrings("/", request.uri.?); + try testing.expectEqual(.@"HTTP/1.1", request.version); + + try testing.expectEqualStrings("localhost:9862", request.headers.get("Host").?); + try testing.expectEqualStrings("keep-alive", request.headers.get("Connection").?); + try testing.expectEqualStrings("text/html", request.headers.get("Accept").?); +} + +test "Expect ContentTooLong Error" { + const request_text_format = + \\GET {s} HTTP/1.1 + \\Host: localhost:9862 + \\Connection: keep-alive + \\Accept: text/html + ; + + const large_content: [4096]u8 = @splat('a'); + const request_text = std.fmt.comptimePrint(request_text_format, .{large_content}); + var request: Request = .init(testing.allocator); + defer request.deinit(); + + const err = request.parse_headers(request_text[0..], .{ + .request_bytes_max = 128, + .request_uri_bytes_max = 64, + }); + try testing.expectError(http.Error.ContentTooLarge, err); +} + +test "Expect URITooLong Error" { + const request_text_format = + \\GET {s} HTTP/1.1 + \\Host: localhost:9862 + \\Connection: keep-alive + \\Accept: text/html + ; + + const large_content: [4096]u8 = @splat('a'); + const request_text = std.fmt.comptimePrint( + request_text_format, + .{large_content[0..]}, + ); + var request: Request = .init(testing.allocator); + defer request.deinit(); + + const err = request.parse_headers(request_text[0..], .{ + .request_bytes_max = 1024 * 1024, + .request_uri_bytes_max = 2048, + }); + try testing.expectError(http.Error.URITooLong, err); +} + +test "Expect Malformed when URI missing /" { + const request_text_format = + \\GET {s} HTTP/1.1 + \\Host: localhost:9862 + \\Connection: keep-alive + \\Accept: text/html + ; + const content: [256]u8 = @splat('a'); + const request_text = std.fmt.comptimePrint( + request_text_format, + .{content[0..]}, + ); + var request: Request = .init(testing.allocator); + defer request.deinit(); + + const err = request.parse_headers(request_text[0..], .{ + .request_bytes_max = 1024, + .request_uri_bytes_max = 512, + }); + try testing.expectError( + http.Error.MalformedRequest, + err, + ); +} + +test "Expect Incorrect HTTP Version" { + const request_text = + \\GET / HTTP/1.4 + \\Host: localhost:9862 + \\Connection: keep-alive + \\Accept: text/html + ; + + var request: Request = .init(testing.allocator); + defer request.deinit(); + + const err = request.parse_headers(request_text[0..], .{ + .request_bytes_max = 1024, + .request_uri_bytes_max = 512, + }); + try testing.expectError( + http.Error.HTTPVersionNotSupported, + err, + ); +} + +test "Malformed string_map.AnyCase" { + const request_text = + \\GET / HTTP/1.1 + \\Host: localhost:9862 + \\Connection: + \\Accept: text/html + ; + + var request: Request = .init(testing.allocator); + defer request.deinit(); + + const err = request.parse_headers(request_text[0..], .{ + .request_bytes_max = 1024, + .request_uri_bytes_max = 512, + }); + try testing.expectError(http.Error.MalformedRequest, err); +} + +const log = std.log.scoped(.@"zzz/http/request"); + +const std = @import("std"); +const mem = std.mem; +const assert = std.debug.assert; +const testing = std.testing; + +const zzz = @import("../root.zig"); +const string_map = zzz.core.string_map; +const http = zzz.http; +const Cookie = @import("Cookie.zig"); diff --git a/src/http/Response.zig b/src/http/Response.zig new file mode 100644 index 0000000..b8f1ea4 --- /dev/null +++ b/src/http/Response.zig @@ -0,0 +1,85 @@ +pub const Response = @This(); + +status: ?Status = null, +mime: ?Mime = null, +body: ?[]const u8 = null, +headers: string_map.AnyCase, + +pub const Fields = struct { + status: Status, + mime: Mime, + body: []const u8 = "", + headers: []const [2][]const u8 = &.{}, +}; + +pub fn init(allocator: std.mem.Allocator) Response { + const headers: string_map.AnyCase = .init(allocator); + return .{ .headers = headers }; +} + +pub fn deinit(self: *Response) void { + self.headers.deinit(); +} + +pub fn apply(self: *Response, into: Fields) !http.Respond { + self.status = into.status; + self.mime = into.mime; + self.body = into.body; + for (into.headers) |pair| + try self.headers.put(pair[0], pair[1]); + return .standard; +} + +pub fn clear(self: *Response) void { + self.status = null; + self.mime = null; + self.body = null; + self.headers.clearRetainingCapacity(); +} + +pub fn headers_into_writer( + self: *Response, + writer: *Io.Writer, + content_length: ?usize, +) !void { + // Status Line + const status = self.status.?; + try writer.print( + "HTTP/1.1 {d} {t}\r\n", + .{ status, status }, + ); + + // Headers + try writer.writeAll("Server: zzz\r\nConnection: keep-alive\r\n"); + var iter = self.headers.iterator(); + while (iter.next()) |entry| try writer.print( + "{s}: {s}\r\n", + .{ entry.key_ptr.*, entry.value_ptr.* }, + ); + + // Content-Type + const mime = self.mime.?; + const content_type = switch (mime.content_type) { + .single => |inner| inner, + .multiple => |content_types| content_types[0], + }; + try writer.print("Content-Type: {s}\r\n", .{content_type}); + + // Content-Length + if (content_length) |length| + try writer.print("Content-Length: {d}\r\n", .{length}); + + try writer.writeAll("\r\n"); +} + +const std = @import("std"); +const assert = std.debug.assert; +const Io = std.Io; + +const zzz = @import("../root.zig"); +const tardy = zzz.tardy; +const http = zzz.http; +const string_map = zzz.core.string_map; +const Date = @import("Date.zig"); +const Mime = @import("Mime.zig"); +const Status = @import("status.zig").Status; diff --git a/src/http/Router.zig b/src/http/Router.zig new file mode 100644 index 0000000..78f3e8e --- /dev/null +++ b/src/http/Router.zig @@ -0,0 +1,84 @@ +/// Initialize a router with the given routes. +pub const Router = @This(); + +routes: Trie, +configuration: Configuration, + +pub fn init( + allocator: mem.Allocator, + layers: []const Middleware.Layer, + configuration: Configuration, +) !Router { + return .{ + .routes = try .init(allocator, layers), + .configuration = configuration, + }; +} + +pub fn deinit(self: *Router, allocator: mem.Allocator) void { + self.routes.deinit(allocator); +} + +pub fn get_bundle_from_host( + self: *const Router, + allocator: mem.Allocator, + path: []const u8, + captures: []Trie.Capture, + queries: *string_map.AnyCase, +) !Trie.Bundle { + queries.clearRetainingCapacity(); + + return try self.routes.get_bundle( + allocator, + path, + captures, + queries, + ) orelse .{ + .route = Route.init("").all( + {}, + self.configuration.not_found, + ), + .captures = captures[0..], + .queries = queries, + .duped = &.{}, + }; +} + +const log = std.log.scoped(.@"zzz/http/router"); + +/// Router configuration structure. +pub const Configuration = struct { + not_found: Route.Handler.TypedFn(void) = default_not_found_handler, +}; + +pub const Query = struct { + key: []const u8, + value: []const u8, +}; + +/// Default not found handler: send a plain text response. +pub const default_not_found_handler = struct { + fn not_found_handler(ctx: *const Context, _: void) !Respond { + const response = ctx.response; + response.status = .@"Not Found"; + response.mime = .TEXT; + response.body = "404 | Not Found"; + + return .standard; + } +}.not_found_handler; + +const std = @import("std"); +const mem = std.mem; + +const zzz = @import("../root.zig"); +const string_map = zzz.core.string_map; +const http = zzz.http; +const Context = http.Context; +const Mime = http.Mime; +const Request = http.Request; +const Respond = http.Respond; +pub const Middleware = @import("router/Middleware.zig"); +pub const Route = @import("router/Route.zig"); +pub const Trie = @import("router/Trie.zig"); +pub const FsDir = @import("router/FsDir.zig"); diff --git a/src/http/SSE.zig b/src/http/SSE.zig new file mode 100644 index 0000000..fde09e7 --- /dev/null +++ b/src/http/SSE.zig @@ -0,0 +1,75 @@ +pub const SSE = @This(); + +socket: secsock.SecureSocket, +writer: Writer.Allocating, +runtime: *tardy.Runtime, + +pub fn init(ctx: *const http.Context) !SSE { + const response = ctx.response; + response.status = .OK; + response.mime = .{ + .content_type = .{ .single = "text/event-stream" }, + .extension = .{ .single = "" }, + .description = "SSE", + }; + + var writer: Writer.Allocating = .init(ctx.allocator); + errdefer writer.deinit(); + + try ctx.response.headers_into_writer(ctx.header_writer, null); + const headers = ctx.header_writer.buffered(); + + const sent = try ctx.socket.send_all(ctx.runtime, headers); + if (sent != headers.len) return error.Closed; + + return .{ + .socket = ctx.socket, + .writer = writer, + .runtime = ctx.runtime, + }; +} + +pub fn send(self: *SSE, message: Message) !void { + var aw = &self.writer; + defer aw.clearRetainingCapacity(); // reuse the writer + const writer = &aw.writer; + + if (message.id) |id| + try writer.print("id: {s}\n", .{id}); + + if (message.event) |event| + try writer.print("event: {s}\n", .{event}); + + if (message.data) |data| { + var iter = mem.splitScalar(u8, data, '\n'); + while (iter.next()) |line| + try writer.print("data: {s}\n", .{line}); + } + + if (message.retry) |retry| + try writer.print("retry: {d}\n", .{retry}); + + try writer.writeByte('\n'); + + const written = aw.written(); + const sent = try self.socket.send_all(self.runtime, written); + if (sent != written.len) return error.Closed; +} + +const log = std.log.scoped(.@"zzz/http/sse"); + +const Message = struct { + id: ?[]const u8 = null, + event: ?[]const u8 = null, + data: ?[]const u8 = null, + retry: ?u64 = null, +}; + +const std = @import("std"); +const mem = std.mem; +const Writer = std.Io.Writer; + +const zzz = @import("../root.zig"); +const http = zzz.http; +const tardy = zzz.tardy; +const secsock = zzz.secsock; diff --git a/src/http/Server.zig b/src/http/Server.zig new file mode 100644 index 0000000..6b61cab --- /dev/null +++ b/src/http/Server.zig @@ -0,0 +1,641 @@ +pub const Server = @This(); + +config: Config, + +pub fn init(config: Config) Server { + return .{ .config = config }; +} + +pub fn deinit(self: *const Server) void { + if (self.tls_ctx) |tls| { + tls.deinit(); + } +} + +fn prepare_new_request( + state: ?*State, + provision: *Provision, + config: Config, +) !void { + debug.assert(provision.initalized); + provision.request.clear(); + provision.response.clear(); + provision.storage.clear(); + provision.zc_recv_buffer.clear_retaining_capacity(); + _ = provision.header_writer.consumeAll(); + _ = provision.arena.reset(.{ + .retain_with_limit = config.connection_arena_bytes_retain, + }); + provision.recv_slice = try provision.zc_recv_buffer.get_write_area( + config.socket_buffer_bytes, + ); + + if (state) |s| s.* = .{ .request = .header }; +} + +pub fn main_frame( + rt: *Runtime, + config: Config, + router: *const Router, + server_socket: SecureSocket, + provisions: *pool.Pool(Provision), + connection_count: *usize, + accept_queued: *bool, +) !void { + accept_queued.* = false; + const secure = server_socket.accept(rt) catch |e| { + if (!accept_queued.*) { + try rt.spawn( + main_frame, + .{ + rt, + config, + router, + server_socket, + provisions, + connection_count, + accept_queued, + }, + config.stack_size, + ); + accept_queued.* = true; + } + return e; + }; + defer secure.socket.close_blocking(); + defer secure.deinit(); + + connection_count.* += 1; + defer connection_count.* -= 1; + + if (secure.socket.addr.family() != .unix) { + try cross.socket.disable_nagle(secure.socket.handle); + } + + if (config.connection_count_max) |max| if (connection_count.* > max) { + log.debug("over connection max, closing", .{}); + return; + }; + + log.debug("queuing up a new accept request", .{}); + try rt.spawn( + main_frame, + .{ + rt, + config, + router, + server_socket, + provisions, + connection_count, + accept_queued, + }, + config.stack_size, + ); + accept_queued.* = true; + + const index = try provisions.borrow(); + defer provisions.release(index); + const provision = provisions.get_ptr(index); + + // if we are growing, we can handle a newly allocated provision here. + // otherwise, it should be initalized. + if (!provision.initalized) { + log.debug("initalizing new provision", .{}); + provision.zc_recv_buffer = ZeroCopy(u8).init( + rt.allocator, + config.socket_buffer_bytes, + ) catch { + @panic("attempting to allocate more memory than available. (ZeroCopyBuffer)"); + }; + provision.arena = .init(rt.allocator); + // TODO: use a server config option + provision.header_writer = .fixed(try provision.arena.allocator().alloc(u8, 8 * 1024)); + provision.captures = rt.allocator.alloc( + Trie.Capture, + config.capture_count_max, + ) catch { + @panic("attempting to allocate more memory than available. (Captures)"); + }; + provision.queries = .init(rt.allocator); + provision.storage = .init(rt.allocator); + provision.request = .init(rt.allocator); + provision.response = .init(rt.allocator); + provision.initalized = true; + } + defer prepare_new_request( + null, + provision, + config, + ) catch unreachable; + + var state: State = .{ .request = .header }; + const buffer = try provision.zc_recv_buffer.get_write_area( + config.socket_buffer_bytes, + ); + _ = buffer; + provision.recv_slice = try provision.zc_recv_buffer.get_write_area( + config.socket_buffer_bytes, + ); + + var keepalive_count: u16 = 0; + + http_loop: while (true) switch (state) { + .request => |*kind| switch (kind.*) { + .header => { + const recv_count = secure.recv(rt, provision.recv_slice) catch |e| + switch (e) { + error.Closed => break, + else => { + log.debug( + "recv failed on socket | {}", + .{e}, + ); + break; + }, + }; + + provision.zc_recv_buffer.mark_written(recv_count); + provision.recv_slice = try provision.zc_recv_buffer.get_write_area( + config.socket_buffer_bytes, + ); + if (provision.zc_recv_buffer.len > config.request_bytes_max) break; + const search_area_start = (provision.zc_recv_buffer.len - recv_count) -| 4; + + if (std.mem.indexOf( + u8, + // Minimize the search area. + provision.zc_recv_buffer.subslice(.{ + .start = search_area_start, + }), + "\r\n\r\n", + )) |header_end| { + const real_header_end = header_end + 4; + try provision.request.parse_headers( + // Add 4 to account for the actual header end sequence. + provision.zc_recv_buffer.subslice( + .{ .end = real_header_end }, + ), + .{ + .request_bytes_max = config.request_bytes_max, + .request_uri_bytes_max = config.request_uri_bytes_max, + }, + ); + + log.info("rt{d} - \"{t} {s}\" {s} ({f})", .{ + rt.id, + provision.request.method.?, + provision.request.uri.?, + provision.request.headers.get("User-Agent") orelse "N/A", + secure.socket.addr, + }); + + const content_length_str = provision.request.headers.get( + "Content-Length", + ) orelse "0"; + const content_length = try std.fmt.parseUnsigned( + usize, + content_length_str, + 10, + ); + log.debug("content length={d}", .{content_length}); + + if (provision.request.expect_body() and content_length != 0) { + state = .{ + .request = .{ + .body = .{ + .current_length = provision.zc_recv_buffer.len - real_header_end, + .content_length = content_length, + }, + }, + }; + } else state = .handler; + } + }, + .body => |*info| { + if (info.current_length == info.content_length) { + provision.request.body = provision.zc_recv_buffer.subslice( + .{ + .start = provision.zc_recv_buffer.len - info.content_length, + }, + ); + state = .handler; + continue; + } + + const recv_count = secure.recv(rt, provision.recv_slice) catch |e| + switch (e) { + error.Closed => break, + else => { + log.debug( + "recv failed on socket | {}", + .{e}, + ); + break; + }, + }; + + provision.zc_recv_buffer.mark_written(recv_count); + provision.recv_slice = try provision.zc_recv_buffer.get_write_area( + config.socket_buffer_bytes, + ); + if (provision.zc_recv_buffer.len > config.request_bytes_max) break; + + info.current_length += recv_count; + debug.assert(info.current_length <= info.content_length); + }, + }, + .handler => { + const found = try router.get_bundle_from_host( + rt.allocator, + provision.request.uri.?, + provision.captures, + &provision.queries, + ); + defer rt.allocator.free(found.duped); + defer for (found.duped) |dupe| rt.allocator.free(dupe); + + const h_with_data: Route.Handler.WithData = found.route.get_handler( + provision.request.method.?, + ) orelse { + provision.response.headers.clearRetainingCapacity(); + provision.response.status = .@"Method Not Allowed"; + provision.response.mime = .TEXT; + provision.response.body = ""; + + state = .respond; + continue; + }; + + const context: http.Context = .{ + .runtime = rt, + .allocator = provision.arena.allocator(), + .header_writer = &provision.header_writer, + .request = &provision.request, + .response = &provision.response, + .storage = &provision.storage, + .socket = secure, + .captures = found.captures, + .queries = found.queries, + }; + + var next: Middleware.Next = .{ + .context = &context, + .middlewares = h_with_data.middlewares, + .handler = h_with_data, + }; + + const next_respond: http.Respond = next.run() catch |e| blk: { + log.warn("rt{d} - \"{s} {s}\" {} ({f})", .{ + rt.id, + @tagName(provision.request.method.?), + provision.request.uri.?, + e, + secure.socket.addr, + }); + + // If in Debug Mode, we will return the error name. In other modes, + // we won't to avoid leaking implemenation details. + const body = if (comptime builtin.mode == .Debug) + @errorName(e) + else + ""; + + break :blk try provision.response.apply(.{ + .status = .@"Internal Server Error", + .mime = .TEXT, + .body = body, + }); + }; + + switch (next_respond) { + .standard => { + // applies the respond onto the response + //try provision.response.apply(respond); + state = .respond; + }, + .responded => { + const connection = provision.request.headers.get("Connection") orelse "keep-alive"; + if (std.mem.eql(u8, connection, "close")) break :http_loop; + if (config.keepalive_count_max) |max| { + if (keepalive_count > max) { + log.debug( + "closing connection, exceeded keepalive max", + .{}, + ); + break :http_loop; + } + + keepalive_count += 1; + } + + try prepare_new_request( + &state, + provision, + config, + ); + }, + .close => break :http_loop, + } + }, + .respond => { + const body = provision.response.body orelse ""; + const content_length = body.len; + + try provision.response.headers_into_writer( + &provision.header_writer, + content_length, + ); + const headers = provision.header_writer.buffered(); + + var sent: usize = 0; + const pseudo: zcore.Pseudoslice = .init( + headers, + body, + provision.recv_slice, + ); + + while (sent < pseudo.len) { + const send_slice = pseudo.get( + sent, + sent + provision.recv_slice.len, + ); + + const sent_length = secure.send_all(rt, send_slice) catch |e| { + log.debug("send failed on socket | {}", .{e}); + break; + }; + if (sent_length != send_slice.len) break :http_loop; + sent += sent_length; + } + + const connection = provision.request.headers.get("Connection") orelse "keep-alive"; + if (std.mem.eql(u8, connection, "close")) break; + if (config.keepalive_count_max) |max| { + if (keepalive_count > max) { + log.debug( + "closing connection, exceeded keepalive max", + .{}, + ); + break; + } + + keepalive_count += 1; + } + + try prepare_new_request( + &state, + provision, + config, + ); + }, + }; + + log.info("connection ({f}) closed", .{secure.socket.addr}); + + if (!accept_queued.*) { + try rt.spawn( + main_frame, + .{ + rt, + config, + router, + server_socket, + provisions, + connection_count, + accept_queued, + }, + config.stack_size, + ); + accept_queued.* = true; + } +} + +/// Serve an HTTP server. +pub fn serve( + self: *Server, + rt: *Runtime, + router: *const Router, + sock: SocketKind, +) !void { + log.info("security mode: {t}", .{sock}); + + const secure: SecureSocket = switch (sock) { + .normal => |s| .unsecured(s), + .secure => |sec| sec, + }; + + const count = self.config.connection_count_max orelse 1024; + const pooling: pool.Kind = if (self.config.connection_count_max == null) + .grow + else + .static; + + const provision_pool = try rt.allocator.create(pool.Pool(Provision)); + provision_pool.* = try .init(rt.allocator, count, pooling); + errdefer rt.allocator.destroy(provision_pool); + + const connection_count = try rt.allocator.create(usize); + errdefer rt.allocator.destroy(connection_count); + connection_count.* = 0; + + const accept_queued = try rt.allocator.create(bool); + errdefer rt.allocator.destroy(accept_queued); + accept_queued.* = true; + + // Use a Max Header Size of 8KiB same as Nginx, Tomcat and Httpd but + // consider making this configurable + // https://stackoverflow.com/questions/686217/maximum-on-http-header-values + const max_http_header_size = 1024 * 8; + const pool_header_buffer: []u8 = try rt.allocator.alloc( + u8, + count * max_http_header_size, + ); + errdefer rt.allocator.free(pool_header_buffer); + var next_header_buffer_index: usize = 0; + + // initialize first batch of provisions :) + for (provision_pool.items) |*provision| { + provision.initalized = true; + provision.zc_recv_buffer = ZeroCopy(u8).init( + rt.allocator, + self.config.socket_buffer_bytes, + ) catch { + @panic("attempting to allocate more memory than available. (ZeroCopy)"); + }; + provision.header_writer = .fixed( + pool_header_buffer[next_header_buffer_index..][0..max_http_header_size], + ); + next_header_buffer_index += max_http_header_size; + + provision.arena = .init(rt.allocator); + provision.captures = rt.allocator.alloc( + Trie.Capture, + self.config.capture_count_max, + ) catch { + @panic("attempting to allocate more memory than available. (Captures)"); + }; + provision.queries = .init(rt.allocator); + provision.storage = .init(rt.allocator); + provision.request = .init(rt.allocator); + provision.response = .init(rt.allocator); + } + + try rt.spawn( + main_frame, + .{ + rt, + self.config, + router, + secure, + provision_pool, + connection_count, + accept_queued, + }, + self.config.stack_size, + ); +} + +/// These are various general configuration +/// options that are important for the actual framework. +/// +/// This includes various different options and limits +/// for interacting with the underlying network. +pub const Config = struct { + /// Stack Size + /// + /// If you have a large number of middlewares or + /// create a LOT of stack memory, you may want to increase this. + /// + /// P.S: A lot of functions in the standard library do end up allocating + /// a lot on the stack (such as std.log). + /// + /// Default: 1MB + stack_size: Coroutine.Stack = .@"1MiB", + /// Number of Maximum Concurrent Connections. + /// + /// This is applied PER runtime. + /// zzz will drop/close any connections greater + /// than this. + /// + /// You can set this to `null` to have no maximum. + /// + /// Default: `null` + connection_count_max: ?u32 = null, + /// Number of times a Request-Response can happen with keep-alive. + /// + /// Setting this to `null` will set no limit. + /// + /// Default: `null` + keepalive_count_max: ?u16 = null, + /// Amount of allocated memory retained + /// after an arena is cleared. + /// + /// A higher value will increase memory usage but + /// should make allocators faster. + /// + /// A lower value will reduce memory usage but + /// will make allocators slower. + /// + /// Default: 1KB + connection_arena_bytes_retain: u32 = 1024, + /// Amount of space on the `recv_buffer` retained + /// after every send. + /// + /// Default: 1KB + list_recv_bytes_retain: u32 = 1024, + /// Maximum size (in bytes) of the Recv buffer. + /// This is mainly a concern when you are reading in + /// large requests before responding. + /// + /// Default: 2MB + list_recv_bytes_max: u32 = 1024 * 1024 * 2, + /// Size of the buffer (in bytes) used for + /// interacting with the socket. + /// + /// Default: 1 KB + socket_buffer_bytes: u32 = 1024, + /// Maximum number of Captures in a Route + /// + /// Default: 8 + capture_count_max: u16 = 8, + /// Maximum size (in bytes) of the Request. + /// + /// Default: 2MB + request_bytes_max: u32 = 1024 * 1024 * 2, + /// Maximum size (in bytes) of the Request URI. + /// + /// Default: 2KB + request_uri_bytes_max: u32 = 1024 * 2, +}; + +const RequestBodyState = struct { + content_length: usize, + current_length: usize, +}; + +const RequestState = union(enum) { + header, + body: RequestBodyState, +}; + +const State = union(enum) { + request: RequestState, + handler, + respond, +}; + +pub const Provision = struct { + initalized: bool = false, + recv_slice: []u8, + zc_recv_buffer: ZeroCopy(u8), + header_writer: Io.Writer, + arena: std.heap.ArenaAllocator, + storage: zcore.TypedStorage, + captures: []Trie.Capture, + queries: string_map.AnyCase, + request: http.Request, + response: http.Response, +}; + +// TODO: find an appropriate place for this +const SocketKind = union(enum) { + normal: Socket, + secure: SecureSocket, +}; + +// TODO: find a find a more appropriate place for this +pub const TLSFileOptions = union(enum) { + buffer: []const u8, + file: struct { + path: []const u8, + size_buffer_max: u32 = 1024 * 1024, + }, +}; + +const log = std.log.scoped(.@"zzz/http/server"); + +const std = @import("std"); +const debug = std.debug; +const Io = std.Io; +const builtin = @import("builtin"); +const tag = builtin.os.tag; + +const zzz = @import("../root.zig"); +const zcore = zzz.core; +const string_map = zcore.string_map; +const tardy = zzz.tardy; +const Coroutine = tardy.Coroutine; +const tcore = tardy.core; +const ZeroCopy = tcore.ZeroCopy; +const cross = tardy.cross; +const pool = tcore.pool; +const Runtime = tardy.Runtime; +const secsock = zzz.secsock; +const SecureSocket = secsock.SecureSocket; +const Socket = tardy.net.Socket; +pub const Task = Runtime.Task; +const http = zzz.http; +const Router = @import("Router.zig"); +const Route = Router.Route; +const Middleware = Router.Middleware; +const Trie = Router.Trie; diff --git a/src/http/cookie.zig b/src/http/cookie.zig deleted file mode 100644 index a8c39df..0000000 --- a/src/http/cookie.zig +++ /dev/null @@ -1,177 +0,0 @@ -const std = @import("std"); -const testing = std.testing; -const Io = std.Io; - -const Date = @import("date.zig").Date; - -pub const Cookie = struct { - name: []const u8, - value: []const u8, - path: ?[]const u8 = null, - domain: ?[]const u8 = null, - expires: ?Date = null, - max_age: ?u32 = null, - secure: bool = false, - http_only: bool = false, - same_site: ?SameSite = null, - - pub fn init(name: []const u8, value: []const u8) Cookie { - return .{ - .name = name, - .value = value, - }; - } - - pub const SameSite = enum { - strict, - lax, - none, - - pub fn to_string(self: SameSite) []const u8 { - return switch (self) { - .strict => "Strict", - .lax => "Lax", - .none => "None", - }; - } - }; - - pub fn to_string_buf(self: Cookie, buf: []u8) ![]const u8 { - const writer: std.Io.Writer = .fixed(buf); - - try writer.print("{s}={s}", .{ self.name, self.value }); - if (self.domain) |domain| try writer.print("; Domain={s}", .{domain}); - if (self.path) |path| try writer.print("; Path={s}", .{path}); - if (self.expires) |exp| { - try writer.writeAll("; Expires="); - try exp.to_http_date().into_writer(&writer); - } - if (self.max_age) |age| try writer.print("; Max-Age={d}", .{age}); - if (self.same_site) |same_site| try writer.print( - "; SameSite={s}", - .{same_site.to_string()}, - ); - if (self.secure) try writer.writeAll("; Secure"); - if (self.http_only) try writer.writeAll("; HttpOnly"); - - return writer.buffered(); - } - - pub fn to_string_alloc(self: Cookie, allocator: std.mem.Allocator) ![]const u8 { - var aw: Io.Writer.Allocating = try .initCapacity(allocator, 128); - errdefer aw.deinit(); - const writer = &aw.writer; - - try writer.print("{s}={s}", .{ self.name, self.value }); - if (self.domain) |domain| try writer.print("; Domain={s}", .{domain}); - if (self.path) |path| try writer.print("; Path={s}", .{path}); - if (self.expires) |exp| { - try writer.writeAll("; Expires="); - try exp.to_http_date().into_writer(writer); - } - if (self.max_age) |age| try writer.print("; Max-Age={d}", .{age}); - if (self.same_site) |same_site| try writer.print( - "; SameSite={s}", - .{same_site.to_string()}, - ); - if (self.secure) try writer.writeAll("; Secure"); - if (self.http_only) try writer.writeAll("; HttpOnly"); - - return try aw.toOwnedSlice(); - } -}; - -pub const CookieMap = struct { - allocator: std.mem.Allocator, - map: std.StringHashMap([]const u8), - - pub fn init(allocator: std.mem.Allocator) CookieMap { - return .{ - .allocator = allocator, - .map = .init(allocator), - }; - } - - pub fn deinit(self: *CookieMap) void { - var iter = self.map.iterator(); - while (iter.next()) |entry| { - self.allocator.free(entry.key_ptr.*); - self.allocator.free(entry.value_ptr.*); - } - self.map.deinit(); - } - - pub fn clear(self: *CookieMap) void { - var iter = self.map.iterator(); - while (iter.next()) |entry| { - self.allocator.free(entry.key_ptr.*); - self.allocator.free(entry.value_ptr.*); - } - self.map.clearRetainingCapacity(); - } - - pub fn get(self: CookieMap, name: []const u8) ?[]const u8 { - return self.map.get(name); - } - - pub fn count(self: CookieMap) usize { - return self.map.count(); - } - - pub fn iterator(self: *const CookieMap) std.StringHashMap([]const u8).Iterator { - return self.map.iterator(); - } - - // For parsing request cookies (simple key=value pairs) - pub fn parse_from_header(self: *CookieMap, cookie_header: []const u8) !void { - self.clear(); - - var pairs = std.mem.splitSequence(u8, cookie_header, "; "); - while (pairs.next()) |pair| { - var kv = std.mem.splitScalar(u8, pair, '='); - const key = kv.next() orelse continue; - const value = kv.rest(); - - const key_dup = try self.allocator.dupe(u8, key); - errdefer self.allocator.free(key_dup); - const value_dup = try self.allocator.dupe(u8, value); - errdefer self.allocator.free(value_dup); - - if (try self.map.fetchPut(key_dup, value_dup)) |existing| { - self.allocator.free(existing.key); - self.allocator.free(existing.value); - } - } - } -}; - -test "Cookie: Header Parsing" { - var cookie_map: CookieMap = .init(testing.allocator); - defer cookie_map.deinit(); - - try cookie_map.parse_from_header("sessionId=abc123; java=slop; foo=bar=baz"); - try testing.expectEqualStrings("abc123", cookie_map.get("sessionId").?); - try testing.expectEqualStrings("slop", cookie_map.get("java").?); - try testing.expectEqualStrings("bar=baz", cookie_map.get("foo").?); -} - -test "Cookie: Response Formatting" { - const cookie: Cookie = .{ - .name = "session", - .value = "abc123", - .path = "/", - .domain = "example.com", - .secure = true, - .http_only = true, - .same_site = .strict, - .max_age = 3600, - }; - - const formatted = try cookie.to_string_alloc(testing.allocator); - defer testing.allocator.free(formatted); - - try testing.expectEqualStrings( - "session=abc123; Domain=example.com; Path=/; Max-Age=3600; SameSite=Strict; Secure; HttpOnly", - formatted, - ); -} diff --git a/src/http/date.zig b/src/http/date.zig deleted file mode 100644 index 1a1a26e..0000000 --- a/src/http/date.zig +++ /dev/null @@ -1,150 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const testing = std.testing; -const Io = std.Io; - -const day_names: []const []const u8 = &.{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", -}; - -const Month = struct { - name: []const u8, - days: u32, -}; - -const months: []const Month = &.{ - .{ .name = "Jan", .days = 31 }, - .{ .name = "Feb", .days = 28 }, - .{ .name = "Mar", .days = 31 }, - .{ .name = "Apr", .days = 30 }, - .{ .name = "May", .days = 31 }, - .{ .name = "Jun", .days = 30 }, - .{ .name = "Jul", .days = 31 }, - .{ .name = "Aug", .days = 31 }, - .{ .name = "Sep", .days = 30 }, - .{ .name = "Oct", .days = 31 }, - .{ .name = "Nov", .days = 30 }, - .{ .name = "Dec", .days = 31 }, -}; - -pub const Date = struct { - const HTTPDate = struct { - const format = std.fmt.comptimePrint( - "{s}, {s} {s} {s} {s}:{s}:{s} GMT", - .{ - "{[day_name]s}", - "{[day]d}", - "{[month]s}", - "{[year]d}", - "{[hour]d:0>2}", - "{[minute]d:0>2}", - "{[second]d:0>2}", - }, - ); - - day_name: []const u8, - day: u8, - month: []const u8, - year: u16, - hour: u8, - minute: u8, - second: u8, - - pub fn into_buf(date: HTTPDate, buffer: []u8) ![]u8 { - assert(buffer.len >= 29); - return try std.fmt.bufPrint(buffer, format, date); - } - - pub fn into_alloc(date: HTTPDate, allocator: std.mem.Allocator) ![]const u8 { - return try std.fmt.allocPrint(allocator, format, date); - } - - pub fn into_writer(date: HTTPDate, writer: *Io.Writer) !void { - try writer.print(format, date); - } - }; - - ts: i64, - - pub fn init(ts: i64) Date { - return .{ .ts = ts }; - } - - fn is_leap_year(year: i64) bool { - return (@rem(year, 4) == 0 and @rem(year, 100) != 0) or (@rem(year, 400) == 0); - } - - pub fn to_http_date(date: Date) HTTPDate { - const secs = date.ts; - const days = @divFloor(secs, 86400); - const remsecs = @mod(secs, 86400); - - var year: i64 = 1970; - var remaining_days = days; - while (true) { - const days_in_year: i64 = if (is_leap_year(year)) 366 else 365; - if (remaining_days < days_in_year) break; - remaining_days -= days_in_year; - year += 1; - } - - var month: usize = 0; - for (months, 0..) |m, i| { - const days_in_month = if (i == 1 and is_leap_year(year)) 29 else m.days; - if (remaining_days < days_in_month) break; - remaining_days -= days_in_month; - month += 1; - } - - const day = remaining_days + 1; - const week_day = @mod((days + 3), 7); - - const hour: u8 = @intCast(@divFloor(remsecs, 3600)); - const minute: u8 = @intCast(@mod(@divFloor(remsecs, 60), 60)); - const second: u8 = @intCast(@mod(remsecs, 60)); - - return .{ - .day_name = day_names[@intCast(week_day)], - .day = @intCast(day), - .month = months[month].name, - .year = @intCast(year), - .hour = hour, - .minute = minute, - .second = second, - }; - } -}; - -test "Parse Basic Date (Buffer)" { - const ts = 1727411110; - var date: Date = .init(ts); - var buffer: [29]u8 = @splat(0); - const http_date = date.to_http_date(); - try testing.expectEqualStrings("Fri, 27 Sep 2024 04:25:10 GMT", try http_date.into_buf(buffer[0..])); -} - -test "Parse Basic Date (Alloc)" { - const ts = 1727464105; - var date: Date = .init(ts); - const http_date = date.to_http_date(); - const http_string = try http_date.into_alloc(testing.allocator); - defer testing.allocator.free(http_string); - try testing.expectEqualStrings("Fri, 27 Sep 2024 19:08:25 GMT", http_string); -} - -test "Parse Basic Date (Writer)" { - const ts = 672452112; - var date: Date = .init(ts); - const http_date = date.to_http_date(); - var buffer: [29]u8 = @splat(0); - var stream_w: Io.Writer = .fixed(&buffer); - try http_date.into_writer(&stream_w); - const http_string = stream_w.buffered(); - try testing.expectEqualStrings("Wed, 24 Apr 1991 00:15:12 GMT", http_string); -} diff --git a/src/http/encoding.zig b/src/http/encoding.zig deleted file mode 100644 index f2b401a..0000000 --- a/src/http/encoding.zig +++ /dev/null @@ -1,7 +0,0 @@ -pub const Encoding = enum { - gzip, - compress, - deflate, - br, - zstd, -}; diff --git a/src/http/form.zig b/src/http/form.zig index b778edd..c7e4d48 100644 --- a/src/http/form.zig +++ b/src/http/form.zig @@ -1,11 +1,37 @@ -const std = @import("std"); -const assert = std.debug.assert; -const testing = std.testing; +/// Parses Form data from a request body in `x-www-form-urlencoded` format. +pub fn Form(comptime T: type) type { + return struct { + pub fn parse(allocator: mem.Allocator, ctx: *const Context) !T { + var m: string_map.AnyCase = .init(ctx.allocator); + defer { + var it = m.iterator(); + while (it.next()) |entry| { + allocator.free(entry.key_ptr.*); + allocator.free(entry.value_ptr.*); + } + m.deinit(); + } -const AnyCaseStringMap = @import("../core/any_case_string_map.zig").AnyCaseStringMap; -const Context = @import("context.zig").Context; + if (ctx.request.body) |body| + try construct_map_from_body(allocator, &m, body) + else + return error.BodyEmpty; + + return parse_struct(allocator, T, &m); + } + }; +} + +/// Parses Form data from request URL query parameters. +pub fn Query(comptime T: type) type { + return struct { + pub fn parse(allocator: mem.Allocator, ctx: *const Context) !T { + return parse_struct(allocator, T, ctx.queries); + } + }; +} -pub fn decode_alloc(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { +pub fn decode_alloc(allocator: mem.Allocator, input: []const u8) ![]const u8 { var list: std.ArrayList(u8) = try .initCapacity(allocator, input.len); defer list.deinit(allocator); @@ -17,7 +43,11 @@ pub fn decode_alloc(allocator: std.mem.Allocator, input: []const u8) ![]const u8 '%' => { if (input_index + 2 >= input.len) return error.InvalidEncoding; list.appendAssumeCapacity( - try std.fmt.parseInt(u8, input[input_index + 1 .. input_index + 3], 16), + try std.fmt.parseInt( + u8, + input[input_index + 1 .. input_index + 3], + 16, + ), ); input_index += 2; }, @@ -29,25 +59,39 @@ pub fn decode_alloc(allocator: std.mem.Allocator, input: []const u8) ![]const u8 return list.toOwnedSlice(allocator); } -fn parse_from(allocator: std.mem.Allocator, comptime T: type, comptime name: []const u8, value: []const u8) !T { +fn parse_from( + allocator: mem.Allocator, + comptime T: type, + comptime name: []const u8, + value: []const u8, +) !T { return switch (@typeInfo(T)) { .int => |info| switch (info.signedness) { .unsigned => try std.fmt.parseUnsigned(T, value, 10), .signed => try std.fmt.parseInt(T, value, 10), }, .float => try std.fmt.parseFloat(T, value), - .optional => |info| @as(T, try parse_from(allocator, info.child, name, value)), + .optional => |info| try parse_from( + allocator, + info.child, + name, + value, + ), .@"enum" => std.meta.stringToEnum(T, value) orelse return error.InvalidEnumValue, - .bool => std.mem.eql(u8, value, "true"), + .bool => mem.eql(u8, value, "true"), else => switch (T) { []const u8 => try allocator.dupe(u8, value), - [:0]const u8 => try allocator.dupeZ(u8, value), - else => std.debug.panic("Unsupported field type \"{s}\"", .{@typeName(T)}), + [:0]const u8 => try allocator.dupeSentinel(u8, value), + else => std.debug.panic("Unsupported field type \"{t}\"", .{T}), }, }; } -fn parse_struct(allocator: std.mem.Allocator, comptime T: type, map: *const AnyCaseStringMap) !T { +fn parse_struct( + allocator: mem.Allocator, + comptime T: type, + map: *const string_map.AnyCase, +) !T { var ret: T = undefined; assert(@typeInfo(T) == .@"struct"); const struct_info = @typeInfo(T).@"struct"; @@ -75,22 +119,34 @@ fn parse_struct(allocator: std.mem.Allocator, comptime T: type, map: *const AnyC return ret; } -fn construct_map_from_body(allocator: std.mem.Allocator, m: *AnyCaseStringMap, body: []const u8) !void { - var pairs = std.mem.splitScalar(u8, body, '&'); +fn construct_map_from_body( + allocator: mem.Allocator, + m: *string_map.AnyCase, + body: []const u8, +) !void { + var pairs = mem.splitScalar(u8, body, '&'); while (pairs.next()) |pair| { - const field_idx = std.mem.indexOfScalar(u8, pair, '=') orelse return error.MissingSeperator; + const field_idx = mem.indexOfScalar(u8, pair, '=') orelse + return error.MissingSeperator; if (pair.len < field_idx + 2) return error.MissingValue; const key = pair[0..field_idx]; const value = pair[(field_idx + 1)..]; - if (std.mem.indexOfScalar(u8, value, '=') != null) return error.MalformedPair; + if (mem.indexOfScalar(u8, value, '=') != null) + return error.MalformedPair; - const decoded_key = try decode_alloc(allocator, key); + const decoded_key = try decode_alloc( + allocator, + key, + ); errdefer allocator.free(decoded_key); - const decoded_value = try decode_alloc(allocator, value); + const decoded_value = try decode_alloc( + allocator, + value, + ); errdefer allocator.free(decoded_value); // Allow for duplicates (like with the URL params), @@ -104,45 +160,12 @@ fn construct_map_from_body(allocator: std.mem.Allocator, m: *AnyCaseStringMap, b } } -/// Parses Form data from a request body in `x-www-form-urlencoded` format. -pub fn Form(comptime T: type) type { - return struct { - pub fn parse(allocator: std.mem.Allocator, ctx: *const Context) !T { - var m: AnyCaseStringMap = .init(ctx.allocator); - defer { - var it = m.iterator(); - while (it.next()) |entry| { - allocator.free(entry.key_ptr.*); - allocator.free(entry.value_ptr.*); - } - m.deinit(); - } - - if (ctx.request.body) |body| - try construct_map_from_body(allocator, &m, body) - else - return error.BodyEmpty; - - return parse_struct(allocator, T, &m); - } - }; -} - -/// Parses Form data from request URL query parameters. -pub fn Query(comptime T: type) type { - return struct { - pub fn parse(allocator: std.mem.Allocator, ctx: *const Context) !T { - return parse_struct(allocator, T, ctx.queries); - } - }; -} - test "FormData: Parsing from Body" { const UserRole = enum { admin, visitor }; const User = struct { id: u32, name: []const u8, age: u8, role: UserRole }; const body: []const u8 = "id=10&name=John&age=12&role=visitor"; - var m: AnyCaseStringMap = .init(testing.allocator); + var m: string_map.AnyCase = .init(testing.allocator); defer { var it = m.iterator(); while (it.next()) |entry| { @@ -166,7 +189,7 @@ test "FormData: Parsing Missing Fields" { const User = struct { id: u32, name: []const u8, age: u8 }; const body: []const u8 = "id=10"; - var m: AnyCaseStringMap = .init(testing.allocator); + var m: string_map.AnyCase = .init(testing.allocator); defer { var it = m.iterator(); while (it.next()) |entry| { @@ -185,7 +208,7 @@ test "FormData: Parsing Missing Fields" { test "FormData: Parsing Missing Value" { const body: []const u8 = "abc=abc&id="; - var m: AnyCaseStringMap = .init(testing.allocator); + var m: string_map.AnyCase = .init(testing.allocator); defer { var it = m.iterator(); while (it.next()) |entry| { @@ -195,6 +218,22 @@ test "FormData: Parsing Missing Value" { m.deinit(); } - const result = construct_map_from_body(testing.allocator, &m, body); - try testing.expectError(error.MissingValue, result); + const result = construct_map_from_body( + testing.allocator, + &m, + body, + ); + try testing.expectError( + error.MissingValue, + result, + ); } + +const std = @import("std"); +const mem = std.mem; +const assert = std.debug.assert; +const testing = std.testing; + +const zzz = @import("../root.zig"); +const string_map = zzz.core.string_map; +const Context = @import("Context.zig"); diff --git a/src/http/method.zig b/src/http/method.zig index 1bb089e..2c0e8cc 100644 --- a/src/http/method.zig +++ b/src/http/method.zig @@ -1,8 +1,3 @@ -const std = @import("std"); -const testing = std.testing; - -const log = std.log.scoped(.@"zzz/http/method"); - pub const Method = enum(u8) { GET = 0, HEAD = 1, @@ -14,11 +9,17 @@ pub const Method = enum(u8) { TRACE = 7, PATCH = 8, + // TODO: Why do we need this and not a simple switch fn encode(method: []const u8) u64 { var buffer: [@sizeOf(u64)]u8 = @splat(0); @memcpy(buffer[0..method.len], method); - return std.mem.readPackedInt(u64, buffer[0..], 0, .native); + return std.mem.readPackedInt( + u64, + buffer[0..], + 0, + .native, + ); } pub fn parse(method: []const u8) !Method { @@ -30,15 +31,15 @@ pub const Method = enum(u8) { const encoded = encode(method); return switch (encoded) { - encode("GET") => Method.GET, - encode("HEAD") => Method.HEAD, - encode("POST") => Method.POST, - encode("PUT") => Method.PUT, - encode("DELETE") => Method.DELETE, - encode("CONNECT") => Method.CONNECT, - encode("OPTIONS") => Method.OPTIONS, - encode("TRACE") => Method.TRACE, - encode("PATCH") => Method.PATCH, + encode("GET") => .GET, + encode("HEAD") => .HEAD, + encode("POST") => .POST, + encode("PUT") => .PUT, + encode("DELETE") => .DELETE, + encode("CONNECT") => .CONNECT, + encode("OPTIONS") => .OPTIONS, + encode("TRACE") => .TRACE, + encode("PATCH") => .PATCH, else => { log.warn("unable to match method: {s} | {d}", .{ method, encoded }); return error.CannotParse; @@ -50,6 +51,11 @@ pub const Method = enum(u8) { test "Parsing Strings" { for (std.meta.tags(Method)) |method| { const method_string = @tagName(method); - try testing.expectEqual(method, Method.parse(method_string)); + try testing.expectEqual(method, try Method.parse(method_string)); } } + +const log = std.log.scoped(.@"zzz/http/method"); + +const std = @import("std"); +const testing = std.testing; diff --git a/src/http/middleware.zig b/src/http/middleware.zig new file mode 100644 index 0000000..416db9f --- /dev/null +++ b/src/http/middleware.zig @@ -0,0 +1,2 @@ +pub const Compression = @import("middleware/compression.zig").Compression; +pub const rate_limit = @import("middleware/rate_limit.zig"); diff --git a/src/http/middlewares/compression.zig b/src/http/middleware/compression.zig similarity index 75% rename from src/http/middlewares/compression.zig rename to src/http/middleware/compression.zig index 6eba75c..69a6da7 100644 --- a/src/http/middlewares/compression.zig +++ b/src/http/middleware/compression.zig @@ -1,11 +1,10 @@ const std = @import("std"); const flate = std.compress.flate; -const Respond = @import("../response.zig").Respond; -const Middleware = @import("../router/middleware.zig").Middleware; -const Next = @import("../router/middleware.zig").Next; -const Layer = @import("../router/middleware.zig").Layer; -const TypedMiddlewareFn = @import("../router/middleware.zig").TypedMiddlewareFn; +const zzz = @import("../../root.zig"); +const http = zzz.http; +const Router = http.Router; +const Middleware = Router.Middleware; const Kind = union(enum) { gzip: struct { @@ -14,14 +13,15 @@ const Kind = union(enum) { }, }; +// TODO: add examples to excercis these /// Compression Middleware. /// /// Provides a Compression Layer for all routes under this that /// will properly compress the body and add the proper `Content-Encoding` header. -pub fn Compression(comptime compression: Kind) Layer { - const func: TypedMiddlewareFn(void) = switch (compression) { +pub fn Compression(comptime compression: Kind) Middleware.Layer { + const func: Middleware.TypedFn(void) = switch (compression) { .gzip => |gzip| struct { - fn gzip_mw(next: *Next, _: void) !Respond { + fn gzip_mw(next: *Middleware.Next, _: void) !http.Respond { const respond = try next.run(); const response = next.context.response; if (response.body) |body| if (respond == .standard) { diff --git a/src/http/middlewares/rate_limit.zig b/src/http/middleware/rate_limit.zig similarity index 67% rename from src/http/middlewares/rate_limit.zig rename to src/http/middleware/rate_limit.zig index c0286e6..f7448e2 100644 --- a/src/http/middlewares/rate_limit.zig +++ b/src/http/middleware/rate_limit.zig @@ -1,19 +1,10 @@ -const std = @import("std"); - -const Mime = @import("../mime.zig").Mime; -const Respond = @import("../response.zig").Respond; -const Response = @import("../response.zig").Response; -const Middleware = @import("../router/middleware.zig").Middleware; -const Next = @import("../router/middleware.zig").Next; -const Layer = @import("../router/middleware.zig").Layer; -const TypedMiddlewareFn = @import("../router/middleware.zig").TypedMiddlewareFn; - +//TODO: add examples utilizing this to prevent bitrot /// Rate Limiting Middleware. /// /// Provides a IP-matching Bucket-based Rate Limiter. -pub fn RateLimiting(config: *RateLimitConfig) Layer { - const func: TypedMiddlewareFn(*RateLimitConfig) = struct { - fn rate_limit_mw(next: *Next, c: *RateLimitConfig) !Respond { +pub fn RateLimiting(config: *Config) Middleware.Layer { + const func: Middleware.TypedFn(*Config) = struct { + fn rate_limit_mw(next: *Middleware.Next, c: *Config) !http.Respond { const ip = get_ip(next.context.socket.inner.addr); const time = std.time.milliTimestamp(); @@ -21,7 +12,11 @@ pub fn RateLimiting(config: *RateLimitConfig) Layer { const entry = try c.map.getOrPut(ip); if (entry.found_existing) { - entry.value_ptr.replenish(time, c.tokens_per_sec, c.max_tokens); + entry.value_ptr.replenish( + time, + c.tokens_per_sec, + c.max_tokens, + ); if (entry.value_ptr.take()) { c.mutex.unlock(); return try next.run(); @@ -31,7 +26,10 @@ pub fn RateLimiting(config: *RateLimitConfig) Layer { return c.response_on_limited; } - entry.value_ptr.* = .{ .tokens = c.max_tokens, .last_refill_ms = time }; + entry.value_ptr.* = .{ + .tokens = c.max_tokens, + .last_refill_ms = time, + }; c.mutex.unlock(); return try next.run(); } @@ -40,19 +38,19 @@ pub fn RateLimiting(config: *RateLimitConfig) Layer { return Middleware.init(config, func).layer(); } -pub const RateLimitConfig = struct { +pub const Config = struct { map: std.AutoHashMap(u128, Bucket), tokens_per_sec: u16, max_tokens: u16, - response_on_limited: Response.Fields, + response_on_limited: http.Response.Fields, mutex: std.Thread.Mutex = .{}, pub fn init( - allocator: std.mem.Allocator, + allocator: mem.Allocator, tokens_per_sec: u16, max_tokens: u16, - response_on_limited: ?Respond, - ) RateLimitConfig { + response_on_limited: ?http.Respond, + ) Config { const map: std.AutoHashMap(u128, Bucket) = .init(allocator); const respond = response_on_limited orelse .{ .status = .@"Too Many Requests", @@ -68,7 +66,7 @@ pub const RateLimitConfig = struct { }; } - pub fn deinit(self: *RateLimitConfig) void { + pub fn deinit(self: *Config) void { self.map.deinit(); } }; @@ -97,7 +95,15 @@ const Bucket = struct { fn get_ip(addr: std.net.Address) u128 { return switch (addr.any.family) { std.posix.AF.INET => @intCast(addr.in.sa.addr), - std.posix.AF.INET6 => std.mem.bytesAsValue(u128, &addr.in6.sa.addr[0]).*, + std.posix.AF.INET6 => mem.bytesAsValue(u128, &addr.in6.sa.addr[0]).*, else => @panic("Not an IP address."), }; } + +const std = @import("std"); +const mem = std.mem; + +const zzz = @import("../../root.zig"); +const http = zzz.http; +const Router = zzz.http.Router; +const Middleware = Router.Middleware; diff --git a/src/http/middlewares/lib.zig b/src/http/middlewares/lib.zig deleted file mode 100644 index 8b52164..0000000 --- a/src/http/middlewares/lib.zig +++ /dev/null @@ -1,3 +0,0 @@ -pub const Compression = @import("compression.zig").Compression; -pub const RateLimitConfig = @import("rate_limit.zig").RateLimitConfig; -pub const RateLimiting = @import("rate_limit.zig").RateLimiting; diff --git a/src/http/mime.zig b/src/http/mime.zig deleted file mode 100644 index 28cef72..0000000 --- a/src/http/mime.zig +++ /dev/null @@ -1,272 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const testing = std.testing; - -const Pair = @import("../core/lib.zig").Pair; - -const MimeOption = union(enum) { - single: []const u8, - /// The first one should be the priority one. - /// The rest should just be there for compatibility reasons. - multiple: []const []const u8, -}; - -fn generate_mime_helper(any: anytype) MimeOption { - assert(@typeInfo(@TypeOf(any)) == .pointer); - const ptr_info = @typeInfo(@TypeOf(any)).pointer; - assert(ptr_info.attrs.@"const"); - - switch (ptr_info.size) { - else => unreachable, - .one => { - switch (@typeInfo(ptr_info.child)) { - else => unreachable, - .array => |arr_info| { - assert(arr_info.child == u8); - return .{ .single = any }; - }, - .@"struct" => |struct_info| { - for (struct_info.field_types) |field_type| { - assert(@typeInfo(field_type) == .pointer); - const p_info = @typeInfo(field_type).pointer; - assert(@typeInfo(p_info.child) == .array); - const a_info = @typeInfo(p_info.child).array; - assert(a_info.child == u8); - } - - return .{ .multiple = any }; - }, - } - }, - } -} - -/// MIME Types. -pub const Mime = struct { - /// This is the actual MIME type. - content_type: MimeOption, - extension: MimeOption, - description: []const u8, - - pub const AAC = generate("audio/acc", "acc", "AAC Audio"); - pub const APNG = generate("image/apng", "apng", "Animated Portable Network Graphics (APNG) Image"); - pub const AVIF = generate("image/avif", "avif", "AVIF Image"); - pub const AVI = generate("video/x-msvideo", "avi", "AVI: Audio Video Interleave"); - pub const AZW = generate("application/vnd.amazon.ebook", "azw", "AZW: Amazon Kindle eBook format"); - pub const BIN = generate("application/octet-stream", "bin", "Any kind of binary data"); - pub const BMP = generate("image/bmp", "bmp", "Windows OS/2 Bitmap Graphics"); - pub const BZ = generate("application/x-bzip", "bz", "BZip archive"); - pub const BZ2 = generate("application/x-bzip2", "bz2", "BZip2 archive"); - pub const CDA = generate("application/x-cdf", "cda", "CD audio"); - pub const CSS = generate("text/css", "css", "Cascading Style Sheets (CSS)"); - pub const CSV = generate("text/csv", "csv", "Comma-separated values (CSV)"); - pub const DOC = generate("application/msword", "doc", "Microsoft Word"); - pub const DOCX = generate( - "application/vnd.openxlformats-officedocument.wordprocessingml.document", - "docx", - "Microsoft Word (OpenXML)", - ); - pub const EPUB = generate("application/epub+zip", "epub", "Electronic Publication"); - pub const GIF = generate("image/gif", "gif", "Graphics Interchange Format (GIF)"); - pub const GZ = generate(&.{ "application/gzip", "application/x-gzip" }, "gz", "GZip Compressed Archive"); - pub const HTML = generate("text/html", &.{ "html", "htm" }, "HyperText Markup Language (HTML)"); - pub const ICO = generate(&.{ "image/x-icon", "image/vnd.microsoft.icon" }, "ico", "Icon Format"); - pub const ICS = generate("text/calander", "ics", "iCalendar format"); - pub const JAR = generate("application/java-archive", "jar", "Java Archive"); - pub const JPEG = generate("image/jpeg", &.{ "jpeg", "jpg" }, "JPEG Image"); - pub const JS = generate(&.{ "text/javascript", "application/javascript" }, "js", "JavaScript"); - pub const JSON = generate("application/json", "json", "JSON Format"); - pub const MP3 = generate("audio/mpeg", "mp3", "MP3 audio"); - pub const MP4 = generate("video/mp4", "mp4", "MP4 Video"); - pub const OGA = generate("audio/ogg", "ogg", "Ogg audio"); - pub const OGV = generate("video/ogg", "ogv", "Ogg video"); - pub const OGX = generate("application/ogg", "ogx", "Ogg multiplexed audo and video"); - pub const OTF = generate("font/otf", "otf", "OpenType font"); - pub const PDF = generate("application/pdf", "pdf", "Adobe Portable Document Format"); - pub const PHP = generate("application/x-httpd-php", "php", "Hypertext Preprocessor (Personal Home Page)"); - pub const PNG = generate("image/png", "png", "Portable Network Graphics"); - pub const RAR = generate("application/vnd.rar", "rar", "RAR archive"); - pub const RTF = generate("application/rtf", "rtf", "Rich Text Format (RTF)"); - pub const SH = generate("application/x-sh", "sh", "Bourne shell script"); - pub const SVG = generate("image/svg+xml", "svg", "Scalable Vector Graphics (SVG)"); - pub const TAR = generate("application/x-tar", "tar", "Tape Archive (TAR)"); - pub const TEXT = generate("text/plain", "txt", "Text (generally ASCII or ISO-8859-n)"); - pub const TSV = generate("text/tab-seperated-values", "tsv", "Tab-seperated values (TSV)"); - pub const TTF = generate("font/ttf", "ttf", "TrueType Font"); - pub const WAV = generate("audio/wav", "wav", "Waveform Audio Format"); - pub const WEBA = generate("audio/webm", "weba", "WEBM Audio"); - pub const WEBM = generate("video/webm", "webm", "WEBM Video"); - pub const WEBP = generate("image/webp", "webp", "WEBP Image"); - pub const WOFF = generate("font/woff", "woff", "Web Open Font Format (WOFF)"); - pub const WOFF2 = generate("font/woff2", "woff2", "Web Open Font Format (WOFF)"); - pub const XML = generate("application/xml", "xml", "XML"); - pub const ZIP = generate("application/zip", "zip", "ZIP Archive"); - pub const @"7Z" = generate("application/x-7z-compressed", "7z", "7-zip archive"); - - pub fn generate( - comptime content_type: anytype, - comptime extension: anytype, - description: []const u8, - ) Mime { - return Mime{ - .content_type = generate_mime_helper(content_type), - .extension = generate_mime_helper(extension), - .description = description, - }; - } - - pub fn from_extension(extension: []const u8) Mime { - assert(extension.len > 0); - return mime_extension_map.get(extension) orelse Mime.BIN; - } - - pub fn from_content_type(content_type: []const u8) Mime { - assert(content_type.len > 0); - return mime_content_map.get(content_type) orelse Mime.BIN; - } -}; - -const all_mime_types = blk: { - const decls_names = @typeInfo(Mime).@"struct".decl_names; - var mimes: [decls_names.len]Mime = undefined; - var index: usize = 0; - for (decls_names) |decl| { - if (@TypeOf(@field(Mime, decl)) == Mime) { - mimes[index] = @field(Mime, decl); - index += 1; - } - } - - var return_mimes: [index]Mime = undefined; - for (0..index) |i| { - return_mimes[i] = mimes[i]; - } - - break :blk return_mimes; -}; - -const mime_extension_map: std.StaticStringMap(Mime) = blk: { - const num_pairs = num: { - var count: usize = 0; - for (all_mime_types) |mime| { - var value: usize = 0; - value += switch (mime.extension) { - .single => 1, - .multiple => |items| items.len, - }; - count += value; - } - - break :num count; - }; - - var pairs: [num_pairs]Pair([]const u8, Mime) = undefined; - - var index: usize = 0; - for (all_mime_types[0..]) |mime| { - switch (mime.extension) { - .single => |inner| { - defer index += 1; - pairs[index] = .{ inner, mime }; - }, - .multiple => |extensions| { - for (extensions) |ext| { - defer index += 1; - pairs[index] = .{ ext, mime }; - } - }, - } - } - - break :blk .initComptime(pairs); -}; - -const mime_content_map: std.StaticStringMap(Mime) = blk: { - const num_pairs = num: { - var count: usize = 0; - for (all_mime_types) |mime| { - var value: usize = 0; - value += switch (mime.content_type) { - .single => 1, - .multiple => |items| items.len, - }; - count += value; - } - - break :num count; - }; - - var pairs: [num_pairs]Pair([]const u8, Mime) = undefined; - - var index: usize = 0; - for (all_mime_types[0..]) |mime| { - switch (mime.content_type) { - .single => |inner| { - defer index += 1; - pairs[index] = .{ inner, mime }; - }, - .multiple => |content_types| { - for (content_types) |ext| { - defer index += 1; - pairs[index] = .{ ext, mime }; - } - }, - } - } - - break :blk .initComptime(pairs); -}; - -test "MIME from extensions" { - for (all_mime_types) |mime| { - switch (mime.extension) { - .single => |inner| { - try testing.expectEqualStrings( - mime.description, - Mime.from_extension(inner).description, - ); - }, - .multiple => |extensions| { - for (extensions) |ext| { - try testing.expectEqualStrings( - mime.description, - Mime.from_extension(ext).description, - ); - } - }, - } - } -} - -test "MIME from unknown extension" { - const extension = ".whatami"; - const mime = Mime.from_extension(extension); - try testing.expectEqual(Mime.BIN, mime); -} - -test "MIME from content types" { - for (all_mime_types) |mime| { - switch (mime.content_type) { - .single => |inner| { - try testing.expectEqualStrings( - mime.description, - Mime.from_content_type(inner).description, - ); - }, - .multiple => |content_types| { - for (content_types) |ext| { - try testing.expectEqualStrings( - mime.description, - Mime.from_content_type(ext).description, - ); - } - }, - } - } -} - -test "MIME from unknown content type" { - const content_type = "application/whatami"; - const mime = Mime.from_content_type(content_type); - try testing.expectEqual(Mime.BIN, mime); -} diff --git a/src/http/request.zig b/src/http/request.zig deleted file mode 100644 index afbfd9a..0000000 --- a/src/http/request.zig +++ /dev/null @@ -1,247 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const testing = std.testing; - -const AnyCaseStringMap = @import("../core/any_case_string_map.zig").AnyCaseStringMap; -const CookieMap = @import("cookie.zig").CookieMap; -const HTTPError = @import("lib.zig").HTTPError; -const Method = @import("lib.zig").Method; - -const log = std.log.scoped(.@"zzz/http/request"); - -pub const Request = struct { - allocator: std.mem.Allocator, - method: ?Method = null, - uri: ?[]const u8 = null, - version: ?std.http.Version = .@"HTTP/1.1", - headers: AnyCaseStringMap, - cookies: CookieMap, - body: ?[]const u8 = null, - - /// This is for constructing a Request. - pub fn init(allocator: std.mem.Allocator) Request { - const headers: AnyCaseStringMap = .init(allocator); - const cookies: CookieMap = .init(allocator); - - return .{ - .allocator = allocator, - .headers = headers, - .cookies = cookies, - }; - } - - pub fn deinit(self: *Request) void { - self.cookies.deinit(); - self.headers.deinit(); - } - - pub fn clear(self: *Request) void { - self.method = null; - self.uri = null; - self.body = null; - self.cookies.clear(); - self.headers.clearRetainingCapacity(); - } - - const RequestParseOptions = struct { - request_bytes_max: u32, - request_uri_bytes_max: u32, - }; - - pub fn parse_headers(self: *Request, bytes: []const u8, options: RequestParseOptions) !void { - self.clear(); - var total_size: u32 = 0; - var lines = std.mem.tokenizeAny(u8, bytes, "\r\n"); - - if (lines.peek() == null) { - return HTTPError.MalformedRequest; - } - - var parsing_first_line = true; - while (lines.next()) |line| { - total_size += @intCast(line.len); - - if (total_size > options.request_bytes_max) { - return HTTPError.ContentTooLarge; - } - - if (parsing_first_line) { - var chunks = std.mem.tokenizeScalar(u8, line, ' '); - - const method_string = chunks.next() orelse return HTTPError.MalformedRequest; - const method = Method.parse(method_string) catch { - log.warn("invalid method: {s}", .{method_string}); - return HTTPError.InvalidMethod; - }; - - const uri_string = chunks.next() orelse return HTTPError.MalformedRequest; - if (uri_string.len >= options.request_uri_bytes_max) return HTTPError.URITooLong; - if (uri_string[0] != '/') return HTTPError.MalformedRequest; - - const version_string = chunks.next() orelse return HTTPError.MalformedRequest; - if (!std.mem.eql(u8, version_string, "HTTP/1.1")) return HTTPError.HTTPVersionNotSupported; - self.set(.{ .method = method, .uri = uri_string }); - - // There shouldn't be anything else. - if (chunks.next() != null) return HTTPError.MalformedRequest; - parsing_first_line = false; - } else { - var header_iter = std.mem.tokenizeScalar(u8, line, ':'); - const key = header_iter.next() orelse return HTTPError.MalformedRequest; - const value = std.mem.trimStart(u8, header_iter.rest(), &.{' '}); - if (value.len == 0) return HTTPError.MalformedRequest; - try self.headers.put(key, value); - } - } - - if (self.headers.get("Cookie")) |cookies| try self.cookies.parse_from_header(cookies); - } - - pub const RequestSetOptions = struct { - method: ?Method = null, - uri: ?[]const u8 = null, - body: ?[]const u8 = null, - }; - - pub fn set(self: *Request, options: RequestSetOptions) void { - if (options.method) |method| { - self.method = method; - } - - if (options.uri) |uri| { - self.uri = uri; - } - - if (options.body) |body| { - self.body = body; - } - } - - /// Should this specific Request expect to capture a body. - pub fn expect_body(self: Request) bool { - return switch (self.method orelse return false) { - .POST, .PUT, .PATCH => true, - .GET, .HEAD, .DELETE, .CONNECT, .OPTIONS, .TRACE => false, - }; - } -}; - -test "Parse Request" { - const request_text = - \\GET / HTTP/1.1 - \\Host: localhost:9862 - \\Connection: keep-alive - \\Accept: text/html - ; - - var request = Request.init(testing.allocator); - defer request.deinit(); - - try request.parse_headers(request_text[0..], .{ - .request_bytes_max = 1024, - .request_uri_bytes_max = 256, - }); - - try testing.expectEqual(.GET, request.method); - try testing.expectEqualStrings("/", request.uri.?); - try testing.expectEqual(.@"HTTP/1.1", request.version); - - try testing.expectEqualStrings("localhost:9862", request.headers.get("Host").?); - try testing.expectEqualStrings("keep-alive", request.headers.get("Connection").?); - try testing.expectEqualStrings("text/html", request.headers.get("Accept").?); -} - -test "Expect ContentTooLong Error" { - const request_text_format = - \\GET {s} HTTP/1.1 - \\Host: localhost:9862 - \\Connection: keep-alive - \\Accept: text/html - ; - - const large_content: [4096]u8 = @splat('a'); - const request_text = std.fmt.comptimePrint(request_text_format, .{large_content}); - var request: Request = .init(testing.allocator); - defer request.deinit(); - - const err = request.parse_headers(request_text[0..], .{ - .request_bytes_max = 128, - .request_uri_bytes_max = 64, - }); - try testing.expectError(HTTPError.ContentTooLarge, err); -} - -test "Expect URITooLong Error" { - const request_text_format = - \\GET {s} HTTP/1.1 - \\Host: localhost:9862 - \\Connection: keep-alive - \\Accept: text/html - ; - - const large_content: [4096]u8 = @splat('a'); - const request_text = std.fmt.comptimePrint(request_text_format, .{large_content[0..]}); - var request: Request = .init(testing.allocator); - defer request.deinit(); - - const err = request.parse_headers(request_text[0..], .{ - .request_bytes_max = 1024 * 1024, - .request_uri_bytes_max = 2048, - }); - try testing.expectError(HTTPError.URITooLong, err); -} - -test "Expect Malformed when URI missing /" { - const request_text_format = - \\GET {s} HTTP/1.1 - \\Host: localhost:9862 - \\Connection: keep-alive - \\Accept: text/html - ; - const content: [256]u8 = @splat('a'); - const request_text = std.fmt.comptimePrint(request_text_format, .{content[0..]}); - var request: Request = .init(testing.allocator); - defer request.deinit(); - - const err = request.parse_headers(request_text[0..], .{ - .request_bytes_max = 1024, - .request_uri_bytes_max = 512, - }); - try testing.expectError(HTTPError.MalformedRequest, err); -} - -test "Expect Incorrect HTTP Version" { - const request_text = - \\GET / HTTP/1.4 - \\Host: localhost:9862 - \\Connection: keep-alive - \\Accept: text/html - ; - - var request: Request = .init(testing.allocator); - defer request.deinit(); - - const err = request.parse_headers(request_text[0..], .{ - .request_bytes_max = 1024, - .request_uri_bytes_max = 512, - }); - try testing.expectError(HTTPError.HTTPVersionNotSupported, err); -} - -test "Malformed AnyCaseStringMap" { - const request_text = - \\GET / HTTP/1.1 - \\Host: localhost:9862 - \\Connection: - \\Accept: text/html - ; - - var request: Request = .init(testing.allocator); - defer request.deinit(); - - const err = request.parse_headers(request_text[0..], .{ - .request_bytes_max = 1024, - .request_uri_bytes_max = 512, - }); - try testing.expectError(HTTPError.MalformedRequest, err); -} diff --git a/src/http/response.zig b/src/http/response.zig deleted file mode 100644 index a7a606f..0000000 --- a/src/http/response.zig +++ /dev/null @@ -1,84 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Io = std.Io; - -const Stream = @import("tardy").Stream; - -const AnyCaseStringMap = @import("../core/any_case_string_map.zig").AnyCaseStringMap; -const Date = @import("lib.zig").Date; -const Mime = @import("lib.zig").Mime; -const Status = @import("lib.zig").Status; - -pub const Respond = enum { - // When we are returning a real HTTP request, we use this. - standard, - // If we responded and we want to give control back to the HTTP engine. - responded, - // If we want the connection to close. - close, -}; - -pub const Response = struct { - status: ?Status = null, - mime: ?Mime = null, - body: ?[]const u8 = null, - headers: AnyCaseStringMap, - - pub const Fields = struct { - status: Status, - mime: Mime, - body: []const u8 = "", - headers: []const [2][]const u8 = &.{}, - }; - - pub fn init(allocator: std.mem.Allocator) Response { - const headers: AnyCaseStringMap = .init(allocator); - return .{ .headers = headers }; - } - - pub fn deinit(self: *Response) void { - self.headers.deinit(); - } - - pub fn apply(self: *Response, into: Fields) !Respond { - self.status = into.status; - self.mime = into.mime; - self.body = into.body; - for (into.headers) |pair| try self.headers.put(pair[0], pair[1]); - return .standard; - } - - pub fn clear(self: *Response) void { - self.status = null; - self.mime = null; - self.body = null; - self.headers.clearRetainingCapacity(); - } - - pub fn headers_into_writer(self: *Response, writer: *Io.Writer, content_length: ?usize) !void { - // Status Line - const status = self.status.?; - try writer.print("HTTP/1.1 {d} {s}\r\n", .{ @intFromEnum(status), @tagName(status) }); - - // Headers - try writer.writeAll("Server: zzz\r\nConnection: keep-alive\r\n"); - var iter = self.headers.iterator(); - while (iter.next()) |entry| try writer.print( - "{s}: {s}\r\n", - .{ entry.key_ptr.*, entry.value_ptr.* }, - ); - - // Content-Type - const mime = self.mime.?; - const content_type = switch (mime.content_type) { - .single => |inner| inner, - .multiple => |content_types| content_types[0], - }; - try writer.print("Content-Type: {s}\r\n", .{content_type}); - - // Content-Length - if (content_length) |length| try writer.print("Content-Length: {d}\r\n", .{length}); - - try writer.writeAll("\r\n"); - } -}; diff --git a/src/http/router.zig b/src/http/router.zig deleted file mode 100644 index e68caea..0000000 --- a/src/http/router.zig +++ /dev/null @@ -1,72 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; - -const AnyCaseStringMap = @import("../core/any_case_string_map.zig").AnyCaseStringMap; -const Context = @import("context.zig").Context; -const Mime = @import("mime.zig").Mime; -const Request = @import("request.zig").Request; -const Respond = @import("response.zig").Respond; -const Response = @import("response.zig").Response; -const Layer = @import("router/middleware.zig").Layer; -const Route = @import("router/route.zig").Route; -const TypedHandlerFn = @import("router/route.zig").TypedHandlerFn; -const Bundle = @import("router/routing_trie.zig").Bundle; -const Capture = @import("router/routing_trie.zig").Capture; -const RoutingTrie = @import("router/routing_trie.zig").RoutingTrie; - -const log = std.log.scoped(.@"zzz/http/router"); - -/// Default not found handler: send a plain text response. -pub const default_not_found_handler = struct { - fn not_found_handler(ctx: *const Context, _: void) !Respond { - const response = ctx.response; - response.status = .@"Not Found"; - response.mime = Mime.TEXT; - response.body = "404 | Not Found"; - - return .standard; - } -}.not_found_handler; - -/// Initialize a router with the given routes. -pub const Router = struct { - /// Router configuration structure. - pub const Configuration = struct { - not_found: TypedHandlerFn(void) = default_not_found_handler, - }; - - routes: RoutingTrie, - configuration: Configuration, - - pub fn init( - allocator: std.mem.Allocator, - layers: []const Layer, - configuration: Configuration, - ) !Router { - return .{ - .routes = try .init(allocator, layers), - .configuration = configuration, - }; - } - - pub fn deinit(self: *Router, allocator: std.mem.Allocator) void { - self.routes.deinit(allocator); - } - - pub fn get_bundle_from_host( - self: *const Router, - allocator: std.mem.Allocator, - path: []const u8, - captures: []Capture, - queries: *AnyCaseStringMap, - ) !Bundle { - queries.clearRetainingCapacity(); - - return try self.routes.get_bundle(allocator, path, captures, queries) orelse .{ - .route = Route.init("").all({}, self.configuration.not_found), - .captures = captures[0..], - .queries = queries, - .duped = &.{}, - }; - } -}; diff --git a/src/http/router/FsDir.zig b/src/http/router/FsDir.zig new file mode 100644 index 0000000..6ccf035 --- /dev/null +++ b/src/http/router/FsDir.zig @@ -0,0 +1,122 @@ +pub const FsDir = @This(); + +fn fs_dir_handler(ctx: *const http.Context, dir: fs.Dir) !http.Respond { + if (ctx.captures.len == 0) return ctx.response.apply(.{ + .status = .@"Not Found", + .mime = .HTML, + }); + + const response = ctx.response; + + // Resolving the requested file. + const search_path = ctx.captures[0].remaining; + const file_path_z = try ctx.allocator.dupeSentinel( + u8, + search_path, + 0x0, + ); + + // TODO: check that the path is valid. + const extension_start = mem.lastIndexOfScalar( + u8, + search_path, + '.', + ); + const mime: http.Mime = blk: { + if (extension_start) |start| { + if (search_path.len - start == 0) break :blk .BIN; + break :blk .from_extension(search_path[start + 1 ..]); + } else { + break :blk .BIN; + } + }; + + const file = dir.open_file(ctx.runtime, file_path_z, .{ .mode = .read }) catch |e| switch (e) { + error.NotFound => { + return ctx.response.apply(.{ + .status = .@"Not Found", + .mime = .HTML, + }); + }, + else => return e, + }; + const stat = try file.stat(ctx.runtime); + + var hash: std.hash.Wyhash = .init(0); + hash.update(mem.asBytes(&stat.size)); + if (stat.modified) |modified| { + hash.update(mem.asBytes(&modified.nanoseconds)); + } + const etag_hash = hash.final(); + + const calc_etag = try ctx.allocator.print( + "\"{d}\"", + .{etag_hash}, + ); + try response.headers.put("ETag", calc_etag); + + // If we have an ETag on the request... + if (ctx.request.headers.get("If-None-Match")) |etag| { + if (mem.eql(u8, etag, calc_etag)) { + // If the ETag matches. + return ctx.response.apply(.{ + .status = .@"Not Modified", + .mime = .HTML, + }); + } + } + + // apply the fields. + response.status = .OK; + response.mime = mime; + + response.headers_into_writer(ctx.header_writer, stat.size) catch |err| switch (err) { + error.WriteFailed => return error.ExceededMaxHttpHeaderSize, + else => unreachable, + }; + const headers = ctx.header_writer.buffered(); + const length = try ctx.socket.send_all(ctx.runtime, headers); + if (headers.len != length) return error.SendingHeadersFailed; + + // Reuse the header_buffer for file read/send + var buffer = ctx.header_writer.buffer[0..]; + while (true) { + const read_count = file.read(ctx.runtime, buffer, null) catch |e| switch (e) { + error.EndOfFile => break, + else => return e, + }; + + _ = ctx.socket.send(ctx.runtime, buffer[0..read_count]) catch |e| switch (e) { + error.Closed => break, + else => return e, + }; + } + + return .responded; +} + +/// Serve a Filesystem Directory as a Layer. +pub fn serve(comptime url_path: []const u8, dir: fs.Dir) Middelware.Layer { + const url_with_match_all = comptime std.fmt.comptimePrint( + "{s}/%r", + .{mem.trimEnd(u8, url_path, "/")}, + ); + log.debug("url with match {s}", .{url_with_match_all}); + + return Route.init(url_with_match_all).get( + dir, + fs_dir_handler, + ).layer(); +} + +const log = std.log.scoped(.@"zzz/http/router"); + +const std = @import("std"); +const mem = std.mem; + +const zzz = @import("../../root.zig"); +const tardy = zzz.tardy; +const http = zzz.http; +const fs = tardy.fs; +const Middelware = @import("Middleware.zig"); +const Route = @import("Route.zig"); diff --git a/src/http/router/Middleware.zig b/src/http/router/Middleware.zig new file mode 100644 index 0000000..3244810 --- /dev/null +++ b/src/http/router/Middleware.zig @@ -0,0 +1,59 @@ +pub const Middleware = @This(); + +inner: WithData, + +pub fn init(data: anytype, func: TypedFn(@TypeOf(data))) Middleware { + return .{ + .inner = .{ + .func = @ptrCast(func), + .data = wrapping.wrap(usize, data), + }, + }; +} + +pub fn layer(self: Middleware) Layer { + return .{ .middleware = self.inner }; +} + +pub const Layer = union(enum) { + /// Route + route: Route, + /// Middleware + middleware: WithData, +}; + +pub const Next = struct { + context: *const http.Context, + middlewares: []const WithData, + handler: Route.Handler.WithData, + + pub fn run(self: *Next) !Respond { + if (self.middlewares.len > 0) { + const middleware = self.middlewares[0]; + self.middlewares = self.middlewares[1..]; + return try middleware.func(self, middleware.data); + } else return try self.handler.handler(self.context, self.handler.data); + } +}; + +pub const Fn = *const fn (*Next, usize) anyerror!Respond; + +pub fn TypedFn(comptime T: type) type { + return *const fn (*Next, T) anyerror!Respond; +} + +pub const WithData = struct { + func: Fn, + data: usize, +}; + +const log = std.log.scoped(.@"zzz/router/middleware"); + +const std = @import("std"); +const assert = std.debug.assert; + +const zzz = @import("../../root.zig"); +const wrapping = zzz.core.wrapping; +const http = zzz.http; +const Respond = http.Respond; +const Route = @import("Route.zig"); diff --git a/src/http/router/Route.zig b/src/http/router/Route.zig new file mode 100644 index 0000000..c76e14c --- /dev/null +++ b/src/http/router/Route.zig @@ -0,0 +1,252 @@ +/// Structure of a server route definition. +pub const Route = @This(); + +/// Defined route path. +path: []const u8, +/// Route Handlers. +handlers: [9]?Handler.WithData = @splat(null), + +/// Initialize a route for the given path. +pub fn init(path: []const u8) Route { + return .{ .path = path }; +} + +/// Returns a comma delinated list of allowed Methods for this route. This +/// is meant to be used as the value for the 'Allow' header in the Response. +pub fn get_allowed(self: Route, allocator: std.mem.Allocator) ![]const u8 { + // This gets allocated within the context of the connection's arena. + const allowed_size = comptime blk: { + var size = 0; + for (std.meta.tags(http.Method)) |method| { + size += @tagName(method).len + 1; + } + break :blk size; + }; + + const buffer = try allocator.alloc(u8, allowed_size); + + var current: []u8 = ""; + inline for (std.meta.tags(http.Method)) |method| { + if (self.handlers[@intFromEnum(method)] != null) { + current = std.fmt.bufPrint( + buffer, + "{s},{s}", + .{ @tagName(method), current }, + ) catch unreachable; + } + } + + if (current.len == 0) { + return current; + } else { + return current[0 .. current.len - 1]; + } +} + +/// Get a defined request handler for the provided method. +/// Return NULL if no handler is defined for this method. +pub fn get_handler(self: Route, method: http.Method) ?Handler.WithData { + return self.handlers[@intFromEnum(method)]; +} + +pub fn layer(self: Route) Middleware.Layer { + return .{ .route = self }; +} + +/// Set a handler function for the provided method. +inline fn inner_route( + comptime method: http.Method, + self: Route, + data: anytype, + handler_fn: Handler.TypedFn(@TypeOf(data)), +) Route { + const wrapped = wrapping.wrap(usize, data); + var new_handlers = self.handlers; + new_handlers[comptime @intFromEnum(method)] = .{ + .handler = @ptrCast(handler_fn), + .middlewares = &.{}, + .data = wrapped, + }; + + return .{ + .path = self.path, + .handlers = new_handlers, + }; +} + +/// Set a handler function for all methods. +pub fn all( + self: Route, + data: anytype, + handler_fn: Handler.TypedFn(@TypeOf(data)), +) Route { + const wrapped = wrapping.wrap(usize, data); + var new_handlers = self.handlers; + + for (&new_handlers) |*new_handler| { + new_handler.* = .{ + .handler = @ptrCast(handler_fn), + .middlewares = &.{}, + .data = wrapped, + }; + } + + return .{ + .path = self.path, + .handlers = new_handlers, + }; +} + +pub fn get( + self: Route, + data: anytype, + handler_fn: Handler.TypedFn(@TypeOf(data)), +) Route { + return inner_route(.GET, self, data, handler_fn); +} + +pub fn head( + self: Route, + data: anytype, + handler_fn: Handler.TypedFn(@TypeOf(data)), +) Route { + return inner_route(.HEAD, self, data, handler_fn); +} + +pub fn post( + self: Route, + data: anytype, + handler_fn: Handler.TypedFn(@TypeOf(data)), +) Route { + return inner_route(.POST, self, data, handler_fn); +} + +pub fn put( + self: Route, + data: anytype, + handler_fn: Handler.TypedFn(@TypeOf(data)), +) Route { + return inner_route(.PUT, self, data, handler_fn); +} + +pub fn delete( + self: Route, + data: anytype, + handler_fn: Handler.TypedFn(@TypeOf(data)), +) Route { + return inner_route(.DELETE, self, data, handler_fn); +} + +pub fn connect( + self: Route, + data: anytype, + handler_fn: Handler.TypedFn(@TypeOf(data)), +) Route { + return inner_route(.CONNECT, self, data, handler_fn); +} + +pub fn options( + self: Route, + data: anytype, + handler_fn: Handler.TypedFn(@TypeOf(data)), +) Route { + return inner_route(.OPTIONS, self, data, handler_fn); +} + +pub fn trace( + self: Route, + data: anytype, + handler_fn: Handler.TypedFn(@TypeOf(data)), +) Route { + return inner_route(.TRACE, self, data, handler_fn); +} + +pub fn patch( + self: Route, + data: anytype, + handler_fn: Handler.TypedFn(@TypeOf(data)), +) Route { + return inner_route(.PATCH, self, data, handler_fn); +} + +const ServeEmbeddedOptions = struct { + /// If you are serving a compressed file, please + /// set the correct encoding type. + encoding: ?http.Encoding = null, + mime: ?http.Mime = null, +}; + +/// Define a GET handler to serve an embedded file. +pub fn embed_file( + self: *const Route, + comptime opts: ServeEmbeddedOptions, + comptime bytes: []const u8, +) Route { + return self.get({}, struct { + fn handler_fn(ctx: *const http.Context, _: void) !http.Respond { + const response = ctx.response; + + const cache_control: []const u8 = if (comptime builtin.mode == .Debug) + "no-cache" + else + comptime std.fmt.comptimePrint( + "max-age={d}", + .{std.time.s_per_day * 30}, + ); + + try response.headers.put("Cache-Control", cache_control); + + // If our static item is greater than 1KB, + // it might be more beneficial to using caching. + if (comptime bytes.len > 1024) { + @setEvalBranchQuota(1_000_000); + const etag = comptime std.fmt.comptimePrint( + "\"{d}\"", + .{std.hash.Wyhash.hash(0, bytes)}, + ); + try response.headers.put("ETag", etag[0..]); + + if (ctx.request.headers.get("If-None-Match")) |match| { + if (std.mem.eql(u8, etag, match)) { + return response.apply(.{ + .status = .@"Not Modified", + .mime = .HTML, + }); + } + } + } + + if (opts.encoding) |encoding| + try response.headers.put("Content-Encoding", @tagName(encoding)); + + return response.apply(.{ + .status = .OK, + .mime = opts.mime orelse .BIN, + .body = bytes, + }); + } + }.handler_fn); +} + +pub const Handler = struct { + pub const Fn = *const fn (*const http.Context, usize) anyerror!http.Respond; + pub const WithData = struct { + handler: Handler.Fn, + middlewares: []const Middleware.WithData, + data: usize, + }; + pub fn TypedFn(comptime T: type) type { + return *const fn (*const http.Context, T) anyerror!http.Respond; + } +}; + +const log = std.log.scoped(.@"zzz/http/route"); + +const std = @import("std"); +const assert = std.debug.assert; +const builtin = @import("builtin"); + +const zzz = @import("../../root.zig"); +const http = zzz.http; +const wrapping = zzz.core.wrapping; +const Middleware = @import("Middleware.zig"); diff --git a/src/http/router/Trie.zig b/src/http/router/Trie.zig new file mode 100644 index 0000000..250b9d8 --- /dev/null +++ b/src/http/router/Trie.zig @@ -0,0 +1,580 @@ +// This RoutingTrie is deleteless. It only can create new routes or update existing ones +pub const Trie = @This(); +root: Node, +middlewares: std.ArrayList(Middleware.WithData), + +/// Initialize the routing tree with the given routes. +pub fn init(allocator: mem.Allocator, layers: []const Middleware.Layer) !Trie { + var self: Trie = .{ + .root = .init( + allocator, + .{ .fragment = "" }, + null, + ), + .middlewares = .empty, + }; + + for (layers) |layer| { + switch (layer) { + .route => |route| { + var current = &self.root; + var iter = mem.tokenizeScalar( + u8, + route.path, + '/', + ); + + while (iter.next()) |chunk| { + const token: Token = .parse_chunk(chunk); + if (current.children.getPtr(token)) |child| { + current = child; + } else { + try current.children.put(token, Node.init( + allocator, + token, + null, + )); + current = current.children.getPtr(token).?; + } + } + + const r: *Route = if (current.route) |*inner| inner else blk: { + current.route = route; + break :blk ¤t.route.?; + }; + + for (route.handlers, 0..) |handler, i| if (handler) |h| { + r.handlers[i] = .{ + .handler = h.handler, + .middlewares = self.middlewares.items, + .data = h.data, + }; + }; + }, + .middleware => |mw| try self.middlewares.append(allocator, mw), + } + } + + return self; +} + +pub fn deinit(self: *Trie, allocator: mem.Allocator) void { + self.root.deinit(); + self.middlewares.deinit(allocator); +} + +pub fn get_bundle( + self: Trie, + allocator: mem.Allocator, + path: []const u8, + captures: []Capture, + queries: *string_map.AnyCase, +) !?Bundle { + var capture_idx: usize = 0; + const query_pos = mem.indexOfScalar(u8, path, '?'); + var iter = mem.tokenizeScalar( + u8, + path[0..(query_pos orelse path.len)], + '/', + ); + + var current = self.root; + + slash_loop: while (iter.next()) |chunk| { + var child_iter = current.children.iterator(); + child_loop: while (child_iter.next()) |entry| { + const token = entry.key_ptr.*; + const child = entry.value_ptr.*; + + switch (token) { + .fragment => |inner| if (mem.eql( + u8, + inner, + chunk, + )) { + current = child; + continue :slash_loop; + }, + .match => |kind| { + switch (kind) { + .signed => if (fmt.parseInt( + i64, + chunk, + 10, + )) |value| { + captures[capture_idx] = .{ .signed = value }; + } else |_| continue :child_loop, + .unsigned => if (fmt.parseInt( + u64, + chunk, + 10, + )) |value| { + captures[capture_idx] = .{ + .unsigned = value, + }; + } else |_| continue :child_loop, + // Float types MUST have a '.' to differentiate them. + .float => if (mem.indexOfScalar( + u8, + chunk, + '.', + )) |_| { + if (fmt.parseFloat(f64, chunk)) |value| { + captures[capture_idx] = .{ + .float = value, + }; + } else |_| continue :child_loop; + } else continue :child_loop, + .string => captures[capture_idx] = .{ + .string = chunk, + }, + .remaining => { + const rest = iter.buffer[(iter.index - chunk.len)..]; + captures[capture_idx] = .{ + .remaining = rest, + }; + + current = child; + capture_idx += 1; + + break :slash_loop; + }, + } + + current = child; + capture_idx += 1; + if (capture_idx > captures.len) return error.TooManyCaptures; + continue :slash_loop; + }, + } + } + + // If we failed to match, this is an invalid route. + return null; + } + + var duped: std.ArrayList([]const u8) = .empty; + defer duped.deinit(allocator); + errdefer for (duped.items) |d| allocator.free(d); + + if (query_pos) |pos| { + if (path.len > pos + 1) { + var query_iter = mem.tokenizeScalar( + u8, + path[pos + 1 ..], + '&', + ); + + while (query_iter.next()) |chunk| { + const field_idx = mem.indexOfScalar( + u8, + chunk, + '=', + ) orelse return error.MissingValue; + if (chunk.len < field_idx + 2) return error.MissingValue; + + const key = chunk[0..field_idx]; + const value = chunk[(field_idx + 1)..]; + + if (mem.indexOfScalar(u8, value, '=') != null) + return error.MalformedPair; + + const decoded_key = try form.decode_alloc( + allocator, + key, + ); + try duped.append(allocator, decoded_key); + + const decoded_value = try form.decode_alloc( + allocator, + value, + ); + try duped.append(allocator, decoded_value); + + // Later values will clobber earlier ones. + try queries.put(decoded_key, decoded_value); + } + } + } + + return .{ + .route = current.route orelse return null, + .captures = captures[0..capture_idx], + .queries = queries, + .duped = try duped.toOwnedSlice(allocator), + }; +} + +fn TokenHashMap(comptime V: type) type { + return std.HashMap(Token, V, struct { + pub fn hash(self: @This(), input: Token) u64 { + _ = self; + + const bytes: []const u8 = blk: { + switch (input) { + .fragment => |inner| break :blk inner, + .match => |inner| break :blk @tagName(inner), + } + }; + + return std.hash.Wyhash.hash(0, bytes); + } + + pub fn eql(self: @This(), first: Token, second: Token) bool { + _ = self; + + const result = blk: { + switch (first) { + .fragment => |f_inner| { + switch (second) { + .fragment => |s_inner| break :blk mem.eql( + u8, + f_inner, + s_inner, + ), + else => break :blk false, + } + }, + .match => |f_inner| { + switch (second) { + .match => |s_inner| break :blk f_inner == s_inner, + else => break :blk false, + } + }, + } + }; + + return result; + } + }, 80); +} + +/// Structure of a node of the trie. +pub const Node = struct { + token: Token, + route: ?Route = null, + children: TokenHashMap(Node), + + /// Initialize a new empty node. + pub fn init(allocator: mem.Allocator, token: Token, route: ?Route) Node { + return .{ + .token = token, + .route = route, + .children = .init(allocator), + }; + } + + pub fn deinit(self: *Node) void { + var iter = self.children.valueIterator(); + + while (iter.next()) |node| { + node.deinit(); + } + + self.children.deinit(); + } +}; +/// Structure of a matched route. +pub const Bundle = struct { + route: Route, + captures: []Capture, + queries: *string_map.AnyCase, + duped: []const []const u8, +}; + +pub const Capture = union(Token.Match) { + unsigned: Token.Match.unsigned.as_type(), + signed: Token.Match.signed.as_type(), + float: Token.Match.float.as_type(), + string: Token.Match.string.as_type(), + remaining: Token.Match.remaining.as_type(), +}; + +test "Constructing Routing from Path" { + var s: Trie = try .init(testing.allocator, &.{ + Route.init("/item").layer(), + Route.init("/item/%i/description").layer(), + Route.init("/item/%i/hello").layer(), + Route.init("/item/%f/price_float").layer(), + Route.init("/item/name/%s").layer(), + Route.init("/item/list").layer(), + }); + defer s.deinit(testing.allocator); + + try testing.expectEqual(1, s.root.children.count()); +} + +test "Routing with Paths" { + var s: Trie = try .init(testing.allocator, &.{ + Route.init("/item").layer(), + Route.init("/item/%i/description").layer(), + Route.init("/item/%i/hello").layer(), + Route.init("/item/%f/price_float").layer(), + Route.init("/item/name/%s").layer(), + Route.init("/item/list").layer(), + }); + defer s.deinit(testing.allocator); + + var q: string_map.AnyCase = .init(testing.allocator); + defer q.deinit(); + + var captures: [8]Capture = @splat(undefined); + + try testing.expectEqual(null, try s.get_bundle( + testing.allocator, + "/item/name", + captures[0..], + &q, + )); + + { + const captured = (try s.get_bundle( + testing.allocator, + "/item/name/HELLO", + captures[0..], + &q, + )).?; + + try testing.expectEqual( + Route.init("/item/name/%s"), + captured.route, + ); + try testing.expectEqualStrings( + "HELLO", + captured.captures[0].string, + ); + } + + { + const captured = (try s.get_bundle( + testing.allocator, + "/item/2112.22121/price_float", + captures[0..], + &q, + )).?; + + try testing.expectEqual( + Route.init("/item/%f/price_float"), + captured.route, + ); + try testing.expectEqual( + 2112.22121, + captured.captures[0].float, + ); + } +} + +test "Routing with Remaining" { + var s: Trie = try .init(testing.allocator, &.{ + Route.init("/item").layer(), + Route.init("/item/%f/price_float").layer(), + Route.init("/item/name/%r").layer(), + Route.init("/item/%i/price/%f").layer(), + }); + defer s.deinit(testing.allocator); + + var q: string_map.AnyCase = .init(testing.allocator); + defer q.deinit(); + + var captures: [8]Capture = @splat(undefined); + + try testing.expectEqual( + null, + try s.get_bundle( + testing.allocator, + "/item/name", + captures[0..], + &q, + ), + ); + + { + const captured = (try s.get_bundle( + testing.allocator, + "/item/name/HELLO", + captures[0..], + &q, + )).?; + try testing.expectEqual( + Route.init("/item/name/%r"), + captured.route, + ); + try testing.expectEqualStrings( + "HELLO", + captured.captures[0].remaining, + ); + } + { + const captured = (try s.get_bundle( + testing.allocator, + "/item/name/THIS/IS/A/FILE/SYSTEM/PATH.html", + captures[0..], + &q, + )).?; + try testing.expectEqual( + Route.init("/item/name/%r"), + captured.route, + ); + try testing.expectEqualStrings( + "THIS/IS/A/FILE/SYSTEM/PATH.html", + captured.captures[0].remaining, + ); + } + + { + const captured = (try s.get_bundle( + testing.allocator, + "/item/2112.22121/price_float", + captures[0..], + &q, + )).?; + try testing.expectEqual( + Route.init("/item/%f/price_float"), + captured.route, + ); + try testing.expectEqual(2112.22121, captured.captures[0].float); + } + + { + const captured = (try s.get_bundle( + testing.allocator, + "/item/100/price/283.21", + captures[0..], + &q, + )).?; + try testing.expectEqual( + Route.init("/item/%i/price/%f"), + captured.route, + ); + try testing.expectEqual(100, captured.captures[0].signed); + try testing.expectEqual(283.21, captured.captures[1].float); + } +} + +test "Routing with Queries" { + var s: Trie = try .init(testing.allocator, &.{ + Route.init("/item").layer(), + Route.init("/item/%f/price_float").layer(), + Route.init("/item/name/%r").layer(), + Route.init("/item/%i/price/%f").layer(), + }); + defer s.deinit(testing.allocator); + + var q: string_map.AnyCase = .init(testing.allocator); + defer q.deinit(); + + var captures: [8]Capture = @splat(undefined); + + try testing.expectEqual(null, try s.get_bundle( + testing.allocator, + "/item/name", + captures[0..], + &q, + )); + + { + q.clearRetainingCapacity(); + const captured = (try s.get_bundle( + testing.allocator, + "/item/name/HELLO?name=muki&food=waffle", + captures[0..], + &q, + )).?; + defer testing.allocator.free(captured.duped); + defer for (captured.duped) |dupe| testing.allocator.free(dupe); + + try testing.expectEqual( + Route.init("/item/name/%r"), + captured.route, + ); + try testing.expectEqualStrings( + "HELLO", + captured.captures[0].remaining, + ); + try testing.expectEqual(2, q.count()); + try testing.expectEqualStrings("muki", q.get("name").?); + try testing.expectEqualStrings("waffle", q.get("food").?); + } + + { + q.clearRetainingCapacity(); + // Purposefully bad format with no keys or values. + const captured = (try s.get_bundle( + testing.allocator, + "/item/2112.22121/price_float?", + captures[0..], + &q, + )).?; + defer testing.allocator.free(captured.duped); + defer for (captured.duped) |dupe| testing.allocator.free(dupe); + + try testing.expectEqual( + Route.init("/item/%f/price_float"), + captured.route, + ); + try testing.expectEqual(2112.22121, captured.captures[0].float); + try testing.expectEqual(0, q.count()); + } + + { + q.clearRetainingCapacity(); + // Purposefully bad format with incomplete key/value pair. + const captured = s.get_bundle( + testing.allocator, + "/item/100/price/283.21?help", + captures[0..], + &q, + ); + try testing.expectError( + error.MissingValue, + captured, + ); + } + + { + q.clearRetainingCapacity(); + // Purposefully bad format with incomplete key/value pair. + const captured = s.get_bundle( + testing.allocator, + "/item/100/price/283.21?help=", + captures[0..], + &q, + ); + try testing.expectError( + error.MissingValue, + captured, + ); + } + + { + q.clearRetainingCapacity(); + // Purposefully bad format with invalid charactes. + const captured = s.get_bundle( + testing.allocator, + "/item/999/price/100.221?page_count=pages=2020&abc=200", + captures[0..], + &q, + ); + try testing.expectError( + error.MalformedPair, + captured, + ); + } +} + +const std = @import("std"); +const mem = std.mem; +const fmt = std.fmt; +const debug = std.debug; +const testing = std.testing; + +const zzz = @import("../../root.zig"); +const http = zzz.http; +const string_map = zzz.core.string_map; +const form = zzz.http.form; + +const Middleware = @import("Middleware.zig"); +const Route = @import("Route.zig"); +const Token = @import("token.zig").Token; + +const log = std.log.scoped(.@"zzz/http/routing_trie"); diff --git a/src/http/router/fs_dir.zig b/src/http/router/fs_dir.zig deleted file mode 100644 index 699db43..0000000 --- a/src/http/router/fs_dir.zig +++ /dev/null @@ -1,111 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; - -const tardy = @import("tardy"); -const Dir = tardy.fs.Dir; - -const Context = @import("../context.zig").Context; -const Mime = @import("../mime.zig").Mime; -const Respond = @import("../response.zig").Respond; -const Layer = @import("middleware.zig").Layer; -const Route = @import("route.zig").Route; - -const log = std.log.scoped(.@"zzz/http/router"); - -pub const FsDir = struct { - fn fs_dir_handler(ctx: *const Context, dir: Dir) !Respond { - if (ctx.captures.len == 0) return ctx.response.apply(.{ - .status = .@"Not Found", - .mime = .HTML, - }); - - const response = ctx.response; - - // Resolving the requested file. - const search_path = ctx.captures[0].remaining; - const file_path_z = try ctx.allocator.dupeSentinel(u8, search_path, 0x0); - - // TODO: check that the path is valid. - - const extension_start = std.mem.lastIndexOfScalar(u8, search_path, '.'); - const mime: Mime = blk: { - if (extension_start) |start| { - if (search_path.len - start == 0) break :blk Mime.BIN; - break :blk Mime.from_extension(search_path[start + 1 ..]); - } else { - break :blk Mime.BIN; - } - }; - - const file = dir.open_file(ctx.runtime, file_path_z, .{ .mode = .read }) catch |e| switch (e) { - error.NotFound => { - return ctx.response.apply(.{ - .status = .@"Not Found", - .mime = .HTML, - }); - }, - else => return e, - }; - const stat = try file.stat(ctx.runtime); - - var hash: std.hash.Wyhash = .init(0); - hash.update(std.mem.asBytes(&stat.size)); - if (stat.modified) |modified| { - hash.update(std.mem.asBytes(&modified.nanoseconds)); - } - const etag_hash = hash.final(); - - const calc_etag = try std.fmt.allocPrint(ctx.allocator, "\"{d}\"", .{etag_hash}); - try response.headers.put("ETag", calc_etag); - - // If we have an ETag on the request... - if (ctx.request.headers.get("If-None-Match")) |etag| { - if (std.mem.eql(u8, etag, calc_etag)) { - // If the ETag matches. - return ctx.response.apply(.{ - .status = .@"Not Modified", - .mime = .HTML, - }); - } - } - - // apply the fields. - response.status = .OK; - response.mime = mime; - - response.headers_into_writer(ctx.header_writer, stat.size) catch |err| switch (err) { - error.WriteFailed => return error.ExceededMaxHttpHeaderSize, - else => unreachable, - }; - const headers = ctx.header_writer.buffered(); - const length = try ctx.socket.send_all(ctx.runtime, headers); - if (headers.len != length) return error.SendingHeadersFailed; - - // Reuse the header_buffer for file read/send - var buffer = ctx.header_writer.buffer[0..]; - while (true) { - const read_count = file.read(ctx.runtime, buffer, null) catch |e| switch (e) { - error.EndOfFile => break, - else => return e, - }; - - _ = ctx.socket.send(ctx.runtime, buffer[0..read_count]) catch |e| switch (e) { - error.Closed => break, - else => return e, - }; - } - - return .responded; - } - - /// Serve a Filesystem Directory as a Layer. - pub fn serve(comptime url_path: []const u8, dir: Dir) Layer { - const url_with_match_all = comptime std.fmt.comptimePrint( - "{s}/%r", - .{std.mem.trimEnd(u8, url_path, "/")}, - ); - log.debug("url with match {s}", .{url_with_match_all}); - - return Route.init(url_with_match_all).get(dir, fs_dir_handler).layer(); - } -}; diff --git a/src/http/router/middleware.zig b/src/http/router/middleware.zig deleted file mode 100644 index 79e21f0..0000000 --- a/src/http/router/middleware.zig +++ /dev/null @@ -1,63 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; - -const Runtime = @import("tardy").Runtime; - -const Pseudoslice = @import("../../core/pseudoslice.zig").Pseudoslice; -const wrap = @import("../../core/wrapping.zig").wrap; -const Context = @import("../context.zig").Context; -const Mime = @import("../mime.zig").Mime; -const Respond = @import("../response.zig").Respond; -const Server = @import("../server.zig").Server; -const HandlerWithData = @import("route.zig").HandlerWithData; -const Route = @import("route.zig").Route; - -const log = std.log.scoped(.@"zzz/router/middleware"); - -pub const Layer = union(enum) { - /// Route - route: Route, - /// Middleware - middleware: MiddlewareWithData, -}; - -pub const Next = struct { - context: *const Context, - middlewares: []const MiddlewareWithData, - handler: HandlerWithData, - - pub fn run(self: *Next) !Respond { - if (self.middlewares.len > 0) { - const middleware = self.middlewares[0]; - self.middlewares = self.middlewares[1..]; - return try middleware.func(self, middleware.data); - } else return try self.handler.handler(self.context, self.handler.data); - } -}; - -pub const MiddlewareFn = *const fn (*Next, usize) anyerror!Respond; -pub fn TypedMiddlewareFn(comptime T: type) type { - return *const fn (*Next, T) anyerror!Respond; -} - -pub const MiddlewareWithData = struct { - func: MiddlewareFn, - data: usize, -}; - -pub const Middleware = struct { - inner: MiddlewareWithData, - - pub fn init(data: anytype, func: TypedMiddlewareFn(@TypeOf(data))) Middleware { - return .{ - .inner = .{ - .func = @ptrCast(func), - .data = wrap(usize, data), - }, - }; - } - - pub fn layer(self: Middleware) Layer { - return .{ .middleware = self.inner }; - } -}; diff --git a/src/http/router/route.zig b/src/http/router/route.zig deleted file mode 100644 index 867b38a..0000000 --- a/src/http/router/route.zig +++ /dev/null @@ -1,218 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const builtin = @import("builtin"); - -const wrap = @import("../../core/wrapping.zig").wrap; -const Context = @import("../context.zig").Context; -const Encoding = @import("../encoding.zig").Encoding; -const Method = @import("../method.zig").Method; -const Mime = @import("../mime.zig").Mime; -const Request = @import("../request.zig").Request; -const Response = @import("../response.zig").Response; -const Respond = @import("../response.zig").Respond; -const FsDir = @import("fs_dir.zig").FsDir; -const Layer = @import("middleware.zig").Layer; -const MiddlewareWithData = @import("middleware.zig").MiddlewareWithData; - -const log = std.log.scoped(.@"zzz/http/route"); - -pub const HandlerFn = *const fn (*const Context, usize) anyerror!Respond; - -pub fn TypedHandlerFn(comptime T: type) type { - return *const fn (*const Context, T) anyerror!Respond; -} - -pub const HandlerWithData = struct { - handler: HandlerFn, - middlewares: []const MiddlewareWithData, - data: usize, -}; - -/// Structure of a server route definition. -pub const Route = struct { - /// Defined route path. - path: []const u8, - - /// Route Handlers. - handlers: [9]?HandlerWithData = @splat(null), - - /// Initialize a route for the given path. - pub fn init(path: []const u8) Route { - return .{ .path = path }; - } - - /// Returns a comma delinated list of allowed Methods for this route. This - /// is meant to be used as the value for the 'Allow' header in the Response. - pub fn get_allowed(self: Route, allocator: std.mem.Allocator) ![]const u8 { - // This gets allocated within the context of the connection's arena. - const allowed_size = comptime blk: { - var size = 0; - for (std.meta.tags(Method)) |method| { - size += @tagName(method).len + 1; - } - break :blk size; - }; - - const buffer = try allocator.alloc(u8, allowed_size); - - var current: []u8 = ""; - inline for (std.meta.tags(Method)) |method| { - if (self.handlers[@intFromEnum(method)] != null) { - current = std.fmt.bufPrint( - buffer, - "{s},{s}", - .{ @tagName(method), current }, - ) catch unreachable; - } - } - - if (current.len == 0) { - return current; - } else { - return current[0 .. current.len - 1]; - } - } - - /// Get a defined request handler for the provided method. - /// Return NULL if no handler is defined for this method. - pub fn get_handler(self: Route, method: Method) ?HandlerWithData { - return self.handlers[@intFromEnum(method)]; - } - - pub fn layer(self: Route) Layer { - return .{ .route = self }; - } - - /// Set a handler function for the provided method. - inline fn inner_route( - comptime method: Method, - self: Route, - data: anytype, - handler_fn: TypedHandlerFn(@TypeOf(data)), - ) Route { - const wrapped = wrap(usize, data); - var new_handlers = self.handlers; - new_handlers[comptime @intFromEnum(method)] = .{ - .handler = @ptrCast(handler_fn), - .middlewares = &.{}, - .data = wrapped, - }; - - return .{ - .path = self.path, - .handlers = new_handlers, - }; - } - - /// Set a handler function for all methods. - pub fn all(self: Route, data: anytype, handler_fn: TypedHandlerFn(@TypeOf(data))) Route { - const wrapped = wrap(usize, data); - var new_handlers = self.handlers; - - for (&new_handlers) |*new_handler| { - new_handler.* = .{ - .handler = @ptrCast(handler_fn), - .middlewares = &.{}, - .data = wrapped, - }; - } - - return .{ - .path = self.path, - .handlers = new_handlers, - }; - } - - pub fn get(self: Route, data: anytype, handler_fn: TypedHandlerFn(@TypeOf(data))) Route { - return inner_route(.GET, self, data, handler_fn); - } - - pub fn head(self: Route, data: anytype, handler_fn: TypedHandlerFn(@TypeOf(data))) Route { - return inner_route(.HEAD, self, data, handler_fn); - } - - pub fn post(self: Route, data: anytype, handler_fn: TypedHandlerFn(@TypeOf(data))) Route { - return inner_route(.POST, self, data, handler_fn); - } - - pub fn put(self: Route, data: anytype, handler_fn: TypedHandlerFn(@TypeOf(data))) Route { - return inner_route(.PUT, self, data, handler_fn); - } - - pub fn delete(self: Route, data: anytype, handler_fn: TypedHandlerFn(@TypeOf(data))) Route { - return inner_route(.DELETE, self, data, handler_fn); - } - - pub fn connect(self: Route, data: anytype, handler_fn: TypedHandlerFn(@TypeOf(data))) Route { - return inner_route(.CONNECT, self, data, handler_fn); - } - - pub fn options(self: Route, data: anytype, handler_fn: TypedHandlerFn(@TypeOf(data))) Route { - return inner_route(.OPTIONS, self, data, handler_fn); - } - - pub fn trace(self: Route, data: anytype, handler_fn: TypedHandlerFn(@TypeOf(data))) Route { - return inner_route(.TRACE, self, data, handler_fn); - } - - pub fn patch(self: Route, data: anytype, handler_fn: TypedHandlerFn(@TypeOf(data))) Route { - return inner_route(.PATCH, self, data, handler_fn); - } - - const ServeEmbeddedOptions = struct { - /// If you are serving a compressed file, please - /// set the correct encoding type. - encoding: ?Encoding = null, - mime: ?Mime = null, - }; - - /// Define a GET handler to serve an embedded file. - pub fn embed_file( - self: *const Route, - comptime opts: ServeEmbeddedOptions, - comptime bytes: []const u8, - ) Route { - return self.get({}, struct { - fn handler_fn(ctx: *const Context, _: void) !Respond { - const response = ctx.response; - - const cache_control: []const u8 = if (comptime builtin.mode == .Debug) - "no-cache" - else - comptime std.fmt.comptimePrint( - "max-age={d}", - .{std.time.s_per_day * 30}, - ); - - try response.headers.put("Cache-Control", cache_control); - - // If our static item is greater than 1KB, - // it might be more beneficial to using caching. - if (comptime bytes.len > 1024) { - @setEvalBranchQuota(1_000_000); - const etag = comptime std.fmt.comptimePrint( - "\"{d}\"", - .{std.hash.Wyhash.hash(0, bytes)}, - ); - try response.headers.put("ETag", etag[0..]); - - if (ctx.request.headers.get("If-None-Match")) |match| { - if (std.mem.eql(u8, etag, match)) { - return response.apply(.{ - .status = .@"Not Modified", - .mime = Mime.HTML, - }); - } - } - } - - if (opts.encoding) |encoding| try response.headers.put("Content-Encoding", @tagName(encoding)); - return response.apply(.{ - .status = .OK, - .mime = opts.mime orelse Mime.BIN, - .body = bytes, - }); - } - }.handler_fn); - } -}; diff --git a/src/http/router/routing_trie.zig b/src/http/router/routing_trie.zig deleted file mode 100644 index d54495d..0000000 --- a/src/http/router/routing_trie.zig +++ /dev/null @@ -1,543 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const testing = std.testing; - -const AnyCaseStringMap = @import("../../core/any_case_string_map.zig").AnyCaseStringMap; -const decode_alloc = @import("../form.zig").decode_alloc; -const Context = @import("../lib.zig").Context; -const Respond = @import("../response.zig").Respond; -const HandlerWithData = @import("route.zig").HandlerWithData; -const Layer = @import("middleware.zig").Layer; -const MiddlewareWithData = @import("middleware.zig").MiddlewareWithData; -const Route = @import("route.zig").Route; - -const log = std.log.scoped(.@"zzz/http/routing_trie"); - -fn TokenHashMap(comptime V: type) type { - return std.HashMap(Token, V, struct { - pub fn hash(self: @This(), input: Token) u64 { - _ = self; - - const bytes = blk: { - switch (input) { - .fragment => |inner| break :blk inner, - .match => |inner| break :blk @tagName(inner), - } - }; - - return std.hash.Wyhash.hash(0, bytes); - } - - pub fn eql(self: @This(), first: Token, second: Token) bool { - _ = self; - - const result = blk: { - switch (first) { - .fragment => |f_inner| { - switch (second) { - .fragment => |s_inner| break :blk std.mem.eql(u8, f_inner, s_inner), - else => break :blk false, - } - }, - .match => |f_inner| { - switch (second) { - .match => |s_inner| break :blk f_inner == s_inner, - else => break :blk false, - } - }, - } - }; - - return result; - } - }, 80); -} - -const TokenEnum = enum(u8) { - fragment = 0, - match = 1, -}; - -pub const TokenMatch = enum { - unsigned, - signed, - float, - string, - remaining, - - pub fn as_type(match: TokenMatch) type { - switch (match) { - .unsigned => return u64, - .signed => return i64, - .float => return f64, - .string => return []const u8, - .remaining => return []const u8, - } - } -}; - -pub const Token = union(TokenEnum) { - fragment: []const u8, - match: TokenMatch, - - pub fn parse_chunk(chunk: []const u8) Token { - if (std.mem.startsWith(u8, chunk, "%")) { - // Needs to be only % and an identifier. - assert(chunk.len == 2); - - switch (chunk[1]) { - 'i', 'd' => return .{ .match = .signed }, - 'u' => return .{ .match = .unsigned }, - 'f' => return .{ .match = .float }, - 's' => return .{ .match = .string }, - 'r' => return .{ .match = .remaining }, - else => @panic("Unsupported Match!"), - } - } else { - return .{ .fragment = chunk }; - } - } -}; - -pub const Query = struct { - key: []const u8, - value: []const u8, -}; - -pub const Capture = union(TokenMatch) { - unsigned: TokenMatch.unsigned.as_type(), - signed: TokenMatch.signed.as_type(), - float: TokenMatch.float.as_type(), - string: TokenMatch.string.as_type(), - remaining: TokenMatch.remaining.as_type(), -}; - -/// Structure of a matched route. -pub const Bundle = struct { - route: Route, - captures: []Capture, - queries: *AnyCaseStringMap, - duped: []const []const u8, -}; - -// This RoutingTrie is deleteless. It only can create new routes or update existing ones. -pub const RoutingTrie = struct { - const Self = @This(); - - /// Structure of a node of the trie. - pub const Node = struct { - token: Token, - route: ?Route = null, - children: TokenHashMap(Node), - - /// Initialize a new empty node. - pub fn init(allocator: std.mem.Allocator, token: Token, route: ?Route) Node { - return .{ - .token = token, - .route = route, - .children = .init(allocator), - }; - } - - pub fn deinit(self: *Node) void { - var iter = self.children.valueIterator(); - - while (iter.next()) |node| { - node.deinit(); - } - - self.children.deinit(); - } - }; - - root: Node, - middlewares: std.ArrayList(MiddlewareWithData), - - /// Initialize the routing tree with the given routes. - pub fn init(allocator: std.mem.Allocator, layers: []const Layer) !Self { - var self: Self = .{ - .root = .init(allocator, .{ .fragment = "" }, null), - .middlewares = .empty, - }; - - for (layers) |layer| { - switch (layer) { - .route => |route| { - var current = &self.root; - var iter = std.mem.tokenizeScalar(u8, route.path, '/'); - - while (iter.next()) |chunk| { - const token: Token = .parse_chunk(chunk); - if (current.children.getPtr(token)) |child| { - current = child; - } else { - try current.children.put(token, Node.init(allocator, token, null)); - current = current.children.getPtr(token).?; - } - } - - const r: *Route = if (current.route) |*inner| inner else blk: { - current.route = route; - break :blk ¤t.route.?; - }; - - for (route.handlers, 0..) |handler, i| if (handler) |h| { - r.handlers[i] = .{ - .handler = h.handler, - .middlewares = self.middlewares.items, - .data = h.data, - }; - }; - }, - .middleware => |mw| try self.middlewares.append(allocator, mw), - } - } - - return self; - } - - pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { - self.root.deinit(); - self.middlewares.deinit(allocator); - } - - pub fn get_bundle( - self: Self, - allocator: std.mem.Allocator, - path: []const u8, - captures: []Capture, - queries: *AnyCaseStringMap, - ) !?Bundle { - var capture_idx: usize = 0; - const query_pos = std.mem.indexOfScalar(u8, path, '?'); - var iter = std.mem.tokenizeScalar(u8, path[0..(query_pos orelse path.len)], '/'); - - var current = self.root; - - slash_loop: while (iter.next()) |chunk| { - var child_iter = current.children.iterator(); - child_loop: while (child_iter.next()) |entry| { - const token = entry.key_ptr.*; - const child = entry.value_ptr.*; - - switch (token) { - .fragment => |inner| if (std.mem.eql(u8, inner, chunk)) { - current = child; - continue :slash_loop; - }, - .match => |kind| { - switch (kind) { - .signed => if (std.fmt.parseInt(i64, chunk, 10)) |value| { - captures[capture_idx] = .{ .signed = value }; - } else |_| continue :child_loop, - .unsigned => if (std.fmt.parseInt(u64, chunk, 10)) |value| { - captures[capture_idx] = .{ .unsigned = value }; - } else |_| continue :child_loop, - // Float types MUST have a '.' to differentiate them. - .float => if (std.mem.indexOfScalar(u8, chunk, '.')) |_| { - if (std.fmt.parseFloat(f64, chunk)) |value| { - captures[capture_idx] = .{ .float = value }; - } else |_| continue :child_loop; - } else continue :child_loop, - .string => captures[capture_idx] = .{ .string = chunk }, - .remaining => { - const rest = iter.buffer[(iter.index - chunk.len)..]; - captures[capture_idx] = .{ .remaining = rest }; - - current = child; - capture_idx += 1; - - break :slash_loop; - }, - } - - current = child; - capture_idx += 1; - if (capture_idx > captures.len) return error.TooManyCaptures; - continue :slash_loop; - }, - } - } - - // If we failed to match, this is an invalid route. - return null; - } - - var duped: std.ArrayList([]const u8) = .empty; - defer duped.deinit(allocator); - errdefer for (duped.items) |d| allocator.free(d); - - if (query_pos) |pos| { - if (path.len > pos + 1) { - var query_iter = std.mem.tokenizeScalar(u8, path[pos + 1 ..], '&'); - - while (query_iter.next()) |chunk| { - const field_idx = std.mem.indexOfScalar(u8, chunk, '=') orelse return error.MissingValue; - if (chunk.len < field_idx + 2) return error.MissingValue; - - const key = chunk[0..field_idx]; - const value = chunk[(field_idx + 1)..]; - - if (std.mem.indexOfScalar(u8, value, '=') != null) return error.MalformedPair; - - const decoded_key = try decode_alloc(allocator, key); - try duped.append(allocator, decoded_key); - - const decoded_value = try decode_alloc(allocator, value); - try duped.append(allocator, decoded_value); - - // Later values will clobber earlier ones. - try queries.put(decoded_key, decoded_value); - } - } - } - - return .{ - .route = current.route orelse return null, - .captures = captures[0..capture_idx], - .queries = queries, - .duped = try duped.toOwnedSlice(allocator), - }; - } -}; - -test "Chunk Parsing (Fragment)" { - const chunk = "thisIsAFragment"; - const token: Token = .parse_chunk(chunk); - - switch (token) { - .fragment => |inner| try testing.expectEqualStrings(chunk, inner), - .match => return error.IncorrectTokenParsing, - } -} - -test "Chunk Parsing (Match)" { - const chunks: [5][]const u8 = .{ - "%i", - "%d", - "%u", - "%f", - "%s", - }; - - const matches: [5]TokenMatch = .{ - TokenMatch.signed, - TokenMatch.signed, - TokenMatch.unsigned, - TokenMatch.float, - TokenMatch.string, - }; - - for (chunks, matches) |chunk, match| { - const token: Token = .parse_chunk(chunk); - - switch (token) { - .fragment => return error.IncorrectTokenParsing, - .match => |inner| try testing.expectEqual(match, inner), - } - } -} - -test "Path Parsing (Mixed)" { - const path = "/item/%i/description"; - - const parsed: [3]Token = .{ - .{ .fragment = "item" }, - .{ .match = .signed }, - .{ .fragment = "description" }, - }; - - var iter = std.mem.tokenizeScalar(u8, path, '/'); - - for (parsed) |expected| { - const token: Token = .parse_chunk(iter.next().?); - switch (token) { - .fragment => |inner| try testing.expectEqualStrings(expected.fragment, inner), - .match => |inner| try testing.expectEqual(expected.match, inner), - } - } -} - -test "Constructing Routing from Path" { - var s: RoutingTrie = try .init(testing.allocator, &.{ - Route.init("/item").layer(), - Route.init("/item/%i/description").layer(), - Route.init("/item/%i/hello").layer(), - Route.init("/item/%f/price_float").layer(), - Route.init("/item/name/%s").layer(), - Route.init("/item/list").layer(), - }); - defer s.deinit(testing.allocator); - - try testing.expectEqual(1, s.root.children.count()); -} - -test "Routing with Paths" { - var s: RoutingTrie = try .init(testing.allocator, &.{ - Route.init("/item").layer(), - Route.init("/item/%i/description").layer(), - Route.init("/item/%i/hello").layer(), - Route.init("/item/%f/price_float").layer(), - Route.init("/item/name/%s").layer(), - Route.init("/item/list").layer(), - }); - defer s.deinit(testing.allocator); - - var q: AnyCaseStringMap = .init(testing.allocator); - defer q.deinit(); - - var captures: [8]Capture = @splat(undefined); - - try testing.expectEqual(null, try s.get_bundle(testing.allocator, "/item/name", captures[0..], &q)); - - { - const captured = (try s.get_bundle(testing.allocator, "/item/name/HELLO", captures[0..], &q)).?; - - try testing.expectEqual(Route.init("/item/name/%s"), captured.route); - try testing.expectEqualStrings("HELLO", captured.captures[0].string); - } - - { - const captured = (try s.get_bundle(testing.allocator, "/item/2112.22121/price_float", captures[0..], &q)).?; - - try testing.expectEqual(Route.init("/item/%f/price_float"), captured.route); - try testing.expectEqual(2112.22121, captured.captures[0].float); - } -} - -test "Routing with Remaining" { - var s: RoutingTrie = try .init(testing.allocator, &.{ - Route.init("/item").layer(), - Route.init("/item/%f/price_float").layer(), - Route.init("/item/name/%r").layer(), - Route.init("/item/%i/price/%f").layer(), - }); - defer s.deinit(testing.allocator); - - var q: AnyCaseStringMap = .init(testing.allocator); - defer q.deinit(); - - var captures: [8]Capture = @splat(undefined); - - try testing.expectEqual(null, try s.get_bundle(testing.allocator, "/item/name", captures[0..], &q)); - - { - const captured = (try s.get_bundle(testing.allocator, "/item/name/HELLO", captures[0..], &q)).?; - try testing.expectEqual(Route.init("/item/name/%r"), captured.route); - try testing.expectEqualStrings("HELLO", captured.captures[0].remaining); - } - { - const captured = (try s.get_bundle( - testing.allocator, - "/item/name/THIS/IS/A/FILE/SYSTEM/PATH.html", - captures[0..], - &q, - )).?; - try testing.expectEqual(Route.init("/item/name/%r"), captured.route); - try testing.expectEqualStrings("THIS/IS/A/FILE/SYSTEM/PATH.html", captured.captures[0].remaining); - } - - { - const captured = (try s.get_bundle( - testing.allocator, - "/item/2112.22121/price_float", - captures[0..], - &q, - )).?; - try testing.expectEqual(Route.init("/item/%f/price_float"), captured.route); - try testing.expectEqual(2112.22121, captured.captures[0].float); - } - - { - const captured = (try s.get_bundle( - testing.allocator, - "/item/100/price/283.21", - captures[0..], - &q, - )).?; - try testing.expectEqual(Route.init("/item/%i/price/%f"), captured.route); - try testing.expectEqual(100, captured.captures[0].signed); - try testing.expectEqual(283.21, captured.captures[1].float); - } -} - -test "Routing with Queries" { - var s: RoutingTrie = try .init(testing.allocator, &.{ - Route.init("/item").layer(), - Route.init("/item/%f/price_float").layer(), - Route.init("/item/name/%r").layer(), - Route.init("/item/%i/price/%f").layer(), - }); - defer s.deinit(testing.allocator); - - var q: AnyCaseStringMap = .init(testing.allocator); - defer q.deinit(); - - var captures: [8]Capture = @splat(undefined); - - try testing.expectEqual(null, try s.get_bundle( - testing.allocator, - "/item/name", - captures[0..], - &q, - )); - - { - q.clearRetainingCapacity(); - const captured = (try s.get_bundle( - testing.allocator, - "/item/name/HELLO?name=muki&food=waffle", - captures[0..], - &q, - )).?; - defer testing.allocator.free(captured.duped); - defer for (captured.duped) |dupe| testing.allocator.free(dupe); - try testing.expectEqual(Route.init("/item/name/%r"), captured.route); - try testing.expectEqualStrings("HELLO", captured.captures[0].remaining); - try testing.expectEqual(2, q.count()); - try testing.expectEqualStrings("muki", q.get("name").?); - try testing.expectEqualStrings("waffle", q.get("food").?); - } - - { - q.clearRetainingCapacity(); - // Purposefully bad format with no keys or values. - const captured = (try s.get_bundle( - testing.allocator, - "/item/2112.22121/price_float?", - captures[0..], - &q, - )).?; - defer testing.allocator.free(captured.duped); - defer for (captured.duped) |dupe| testing.allocator.free(dupe); - try testing.expectEqual(Route.init("/item/%f/price_float"), captured.route); - try testing.expectEqual(2112.22121, captured.captures[0].float); - try testing.expectEqual(0, q.count()); - } - - { - q.clearRetainingCapacity(); - // Purposefully bad format with incomplete key/value pair. - const captured = s.get_bundle(testing.allocator, "/item/100/price/283.21?help", captures[0..], &q); - try testing.expectError(error.MissingValue, captured); - } - - { - q.clearRetainingCapacity(); - // Purposefully bad format with incomplete key/value pair. - const captured = s.get_bundle(testing.allocator, "/item/100/price/283.21?help=", captures[0..], &q); - try testing.expectError(error.MissingValue, captured); - } - - { - q.clearRetainingCapacity(); - // Purposefully bad format with invalid charactes. - const captured = s.get_bundle( - testing.allocator, - "/item/999/price/100.221?page_count=pages=2020&abc=200", - captures[0..], - &q, - ); - try testing.expectError(error.MalformedPair, captured); - } -} diff --git a/src/http/router/token.zig b/src/http/router/token.zig new file mode 100644 index 0000000..58fe691 --- /dev/null +++ b/src/http/router/token.zig @@ -0,0 +1,119 @@ +pub const Token = union(Enum) { + fragment: []const u8, + match: Match, + + pub const Match = enum { + unsigned, + signed, + float, + string, + remaining, + + pub fn as_type(match: Match) type { + switch (match) { + .unsigned => return u64, + .signed => return i64, + .float => return f64, + .string => return []const u8, + .remaining => return []const u8, + } + } + }; + + const Enum = enum(u8) { + fragment = 0, + match = 1, + }; + + pub fn parse_chunk(chunk: []const u8) Token { + if (mem.startsWith(u8, chunk, "%")) { + // Needs to be only % and an identifier. + debug.assert(chunk.len == 2); + + switch (chunk[1]) { + 'i', 'd' => return .{ .match = .signed }, + 'u' => return .{ .match = .unsigned }, + 'f' => return .{ .match = .float }, + 's' => return .{ .match = .string }, + 'r' => return .{ .match = .remaining }, + else => @panic("Unsupported Match!"), + } + } else { + return .{ .fragment = chunk }; + } + } +}; + +test "Chunk Parsing (Fragment)" { + const chunk = "thisIsAFragment"; + const token: Token = .parse_chunk(chunk); + + switch (token) { + .fragment => |inner| try testing.expectEqualStrings( + chunk, + inner, + ), + .match => return error.IncorrectTokenParsing, + } +} + +test "Chunk Parsing (Match)" { + const chunks: [5][]const u8 = .{ + "%i", + "%d", + "%u", + "%f", + "%s", + }; + + const matches: [5]Token.Match = .{ + .signed, + .signed, + .unsigned, + .float, + .string, + }; + + for (chunks, matches) |chunk, match| { + const token: Token = .parse_chunk(chunk); + + switch (token) { + .fragment => return error.IncorrectTokenParsing, + .match => |inner| try testing.expectEqual( + match, + inner, + ), + } + } +} + +test "Path Parsing (Mixed)" { + const path = "/item/%i/description"; + + const parsed: [3]Token = .{ + .{ .fragment = "item" }, + .{ .match = .signed }, + .{ .fragment = "description" }, + }; + + var iter = mem.tokenizeScalar(u8, path, '/'); + + for (parsed) |expected| { + const token: Token = .parse_chunk(iter.next().?); + switch (token) { + .fragment => |inner| try testing.expectEqualStrings( + expected.fragment, + inner, + ), + .match => |inner| try testing.expectEqual( + expected.match, + inner, + ), + } + } +} + +const std = @import("std"); +const mem = std.mem; +const debug = std.debug; +const testing = std.testing; diff --git a/src/http/server.zig b/src/http/server.zig deleted file mode 100644 index f09d02f..0000000 --- a/src/http/server.zig +++ /dev/null @@ -1,557 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Io = std.Io; -const builtin = @import("builtin"); -const tag = builtin.os.tag; - -const tardy = @import("tardy"); -const Coroutine = tardy.Coroutine; -const core = tardy.core; -const cross = tardy.cross; -const Pool = core.pool.Pool; -const PoolKind = core.pool.Kind; -const Runtime = tardy.Runtime; -const secsock = @import("secsock"); -const SecureSocket = secsock.SecureSocket; -const Socket = tardy.net.Socket; -pub const Task = Runtime.Task; -const ZeroCopy = core.ZeroCopy; - -const AnyCaseStringMap = @import("../core/any_case_string_map.zig").AnyCaseStringMap; -const Pseudoslice = @import("../core/pseudoslice.zig").Pseudoslice; -const TypedStorage = @import("../core/typed_storage.zig").TypedStorage; -const Context = @import("context.zig").Context; -const Mime = @import("mime.zig").Mime; -const Request = @import("request.zig").Request; -const Respond = @import("response.zig").Respond; -const Response = @import("response.zig").Response; -const Router = @import("router.zig").Router; -const Next = @import("router/middleware.zig").Next; -const HandlerWithData = @import("router/route.zig").HandlerWithData; -const Capture = @import("router/routing_trie.zig").Capture; - -const log = std.log.scoped(.@"zzz/http/server"); - -pub const TLSFileOptions = union(enum) { - buffer: []const u8, - file: struct { - path: []const u8, - size_buffer_max: u32 = 1024 * 1024, - }, -}; - -/// These are various general configuration -/// options that are important for the actual framework. -/// -/// This includes various different options and limits -/// for interacting with the underlying network. -pub const ServerConfig = struct { - /// Stack Size - /// - /// If you have a large number of middlewares or - /// create a LOT of stack memory, you may want to increase this. - /// - /// P.S: A lot of functions in the standard library do end up allocating - /// a lot on the stack (such as std.log). - /// - /// Default: 1MB - stack_size: Coroutine.Stack = .@"1MiB", - /// Number of Maximum Concurrent Connections. - /// - /// This is applied PER runtime. - /// zzz will drop/close any connections greater - /// than this. - /// - /// You can set this to `null` to have no maximum. - /// - /// Default: `null` - connection_count_max: ?u32 = null, - /// Number of times a Request-Response can happen with keep-alive. - /// - /// Setting this to `null` will set no limit. - /// - /// Default: `null` - keepalive_count_max: ?u16 = null, - /// Amount of allocated memory retained - /// after an arena is cleared. - /// - /// A higher value will increase memory usage but - /// should make allocators faster. - /// - /// A lower value will reduce memory usage but - /// will make allocators slower. - /// - /// Default: 1KB - connection_arena_bytes_retain: u32 = 1024, - /// Amount of space on the `recv_buffer` retained - /// after every send. - /// - /// Default: 1KB - list_recv_bytes_retain: u32 = 1024, - /// Maximum size (in bytes) of the Recv buffer. - /// This is mainly a concern when you are reading in - /// large requests before responding. - /// - /// Default: 2MB - list_recv_bytes_max: u32 = 1024 * 1024 * 2, - /// Size of the buffer (in bytes) used for - /// interacting with the socket. - /// - /// Default: 1 KB - socket_buffer_bytes: u32 = 1024, - /// Maximum number of Captures in a Route - /// - /// Default: 8 - capture_count_max: u16 = 8, - /// Maximum size (in bytes) of the Request. - /// - /// Default: 2MB - request_bytes_max: u32 = 1024 * 1024 * 2, - /// Maximum size (in bytes) of the Request URI. - /// - /// Default: 2KB - request_uri_bytes_max: u32 = 1024 * 2, -}; - -pub const Provision = struct { - initalized: bool = false, - recv_slice: []u8, - zc_recv_buffer: ZeroCopy(u8), - header_writer: Io.Writer, - arena: std.heap.ArenaAllocator, - storage: TypedStorage, - captures: []Capture, - queries: AnyCaseStringMap, - request: Request, - response: Response, -}; - -pub const Server = struct { - const Self = @This(); - config: ServerConfig, - - pub fn init(config: ServerConfig) Self { - return Self{ .config = config }; - } - - pub fn deinit(self: *const Self) void { - if (self.tls_ctx) |tls| { - tls.deinit(); - } - } - - const RequestBodyState = struct { - content_length: usize, - current_length: usize, - }; - - const RequestState = union(enum) { - header, - body: RequestBodyState, - }; - - const State = union(enum) { - request: RequestState, - handler, - respond, - }; - - fn prepare_new_request(state: ?*State, provision: *Provision, config: ServerConfig) !void { - assert(provision.initalized); - provision.request.clear(); - provision.response.clear(); - provision.storage.clear(); - provision.zc_recv_buffer.clear_retaining_capacity(); - _ = provision.header_writer.consumeAll(); - _ = provision.arena.reset(.{ .retain_with_limit = config.connection_arena_bytes_retain }); - provision.recv_slice = try provision.zc_recv_buffer.get_write_area(config.socket_buffer_bytes); - - if (state) |s| s.* = .{ .request = .header }; - } - - pub fn main_frame( - rt: *Runtime, - config: ServerConfig, - router: *const Router, - server_socket: SecureSocket, - provisions: *Pool(Provision), - connection_count: *usize, - accept_queued: *bool, - ) !void { - accept_queued.* = false; - const secure = server_socket.accept(rt) catch |e| { - if (!accept_queued.*) { - try rt.spawn( - main_frame, - .{ - rt, - config, - router, - server_socket, - provisions, - connection_count, - accept_queued, - }, - config.stack_size, - ); - accept_queued.* = true; - } - return e; - }; - defer secure.socket.close_blocking(); - defer secure.deinit(); - - connection_count.* += 1; - defer connection_count.* -= 1; - - if (secure.socket.addr.family() != .unix) { - try cross.socket.disable_nagle(secure.socket.handle); - } - - if (config.connection_count_max) |max| if (connection_count.* > max) { - log.debug("over connection max, closing", .{}); - return; - }; - - log.debug("queuing up a new accept request", .{}); - try rt.spawn( - main_frame, - .{ - rt, - config, - router, - server_socket, - provisions, - connection_count, - accept_queued, - }, - config.stack_size, - ); - accept_queued.* = true; - - const index = try provisions.borrow(); - defer provisions.release(index); - const provision = provisions.get_ptr(index); - - // if we are growing, we can handle a newly allocated provision here. - // otherwise, it should be initalized. - if (!provision.initalized) { - log.debug("initalizing new provision", .{}); - provision.zc_recv_buffer = ZeroCopy(u8).init(rt.allocator, config.socket_buffer_bytes) catch { - @panic("attempting to allocate more memory than available. (ZeroCopyBuffer)"); - }; - provision.arena = .init(rt.allocator); - // TODO: use a server config option - provision.header_writer = .fixed(try provision.arena.allocator().alloc(u8, 8 * 1024)); - provision.captures = rt.allocator.alloc(Capture, config.capture_count_max) catch { - @panic("attempting to allocate more memory than available. (Captures)"); - }; - provision.queries = .init(rt.allocator); - provision.storage = .init(rt.allocator); - provision.request = .init(rt.allocator); - provision.response = .init(rt.allocator); - provision.initalized = true; - } - defer prepare_new_request(null, provision, config) catch unreachable; - - var state: State = .{ .request = .header }; - const buffer = try provision.zc_recv_buffer.get_write_area(config.socket_buffer_bytes); - _ = buffer; - provision.recv_slice = try provision.zc_recv_buffer.get_write_area(config.socket_buffer_bytes); - - var keepalive_count: u16 = 0; - - http_loop: while (true) switch (state) { - .request => |*kind| switch (kind.*) { - .header => { - const recv_count = secure.recv(rt, provision.recv_slice) catch |e| switch (e) { - error.Closed => break, - else => { - log.debug("recv failed on socket | {}", .{e}); - break; - }, - }; - - provision.zc_recv_buffer.mark_written(recv_count); - provision.recv_slice = try provision.zc_recv_buffer.get_write_area(config.socket_buffer_bytes); - if (provision.zc_recv_buffer.len > config.request_bytes_max) break; - const search_area_start = (provision.zc_recv_buffer.len - recv_count) -| 4; - - if (std.mem.indexOf( - u8, - // Minimize the search area. - provision.zc_recv_buffer.subslice(.{ .start = search_area_start }), - "\r\n\r\n", - )) |header_end| { - const real_header_end = header_end + 4; - try provision.request.parse_headers( - // Add 4 to account for the actual header end sequence. - provision.zc_recv_buffer.subslice(.{ .end = real_header_end }), - .{ - .request_bytes_max = config.request_bytes_max, - .request_uri_bytes_max = config.request_uri_bytes_max, - }, - ); - - log.info("rt{d} - \"{s} {s}\" {s} ({f})", .{ - rt.id, - @tagName(provision.request.method.?), - provision.request.uri.?, - provision.request.headers.get("User-Agent") orelse "N/A", - secure.socket.addr, - }); - - const content_length_str = provision.request.headers.get("Content-Length") orelse "0"; - const content_length = try std.fmt.parseUnsigned(usize, content_length_str, 10); - log.debug("content length={d}", .{content_length}); - - if (provision.request.expect_body() and content_length != 0) { - state = .{ - .request = .{ - .body = .{ - .current_length = provision.zc_recv_buffer.len - real_header_end, - .content_length = content_length, - }, - }, - }; - } else state = .handler; - } - }, - .body => |*info| { - if (info.current_length == info.content_length) { - provision.request.body = provision.zc_recv_buffer.subslice( - .{ .start = provision.zc_recv_buffer.len - info.content_length }, - ); - state = .handler; - continue; - } - - const recv_count = secure.recv(rt, provision.recv_slice) catch |e| switch (e) { - error.Closed => break, - else => { - log.debug("recv failed on socket | {}", .{e}); - break; - }, - }; - - provision.zc_recv_buffer.mark_written(recv_count); - provision.recv_slice = try provision.zc_recv_buffer.get_write_area(config.socket_buffer_bytes); - if (provision.zc_recv_buffer.len > config.request_bytes_max) break; - - info.current_length += recv_count; - assert(info.current_length <= info.content_length); - }, - }, - .handler => { - const found = try router.get_bundle_from_host( - rt.allocator, - provision.request.uri.?, - provision.captures, - &provision.queries, - ); - defer rt.allocator.free(found.duped); - defer for (found.duped) |dupe| rt.allocator.free(dupe); - - const h_with_data: HandlerWithData = found.route.get_handler( - provision.request.method.?, - ) orelse { - provision.response.headers.clearRetainingCapacity(); - provision.response.status = .@"Method Not Allowed"; - provision.response.mime = Mime.TEXT; - provision.response.body = ""; - - state = .respond; - continue; - }; - - const context: Context = .{ - .runtime = rt, - .allocator = provision.arena.allocator(), - .header_writer = &provision.header_writer, - .request = &provision.request, - .response = &provision.response, - .storage = &provision.storage, - .socket = secure, - .captures = found.captures, - .queries = found.queries, - }; - - var next: Next = .{ - .context = &context, - .middlewares = h_with_data.middlewares, - .handler = h_with_data, - }; - - const next_respond: Respond = next.run() catch |e| blk: { - log.warn("rt{d} - \"{s} {s}\" {} ({f})", .{ - rt.id, - @tagName(provision.request.method.?), - provision.request.uri.?, - e, - secure.socket.addr, - }); - - // If in Debug Mode, we will return the error name. In other modes, - // we won't to avoid leaking implemenation details. - const body = if (comptime builtin.mode == .Debug) @errorName(e) else ""; - - break :blk try provision.response.apply(.{ - .status = .@"Internal Server Error", - .mime = .TEXT, - .body = body, - }); - }; - - switch (next_respond) { - .standard => { - // applies the respond onto the response - //try provision.response.apply(respond); - state = .respond; - }, - .responded => { - const connection = provision.request.headers.get("Connection") orelse "keep-alive"; - if (std.mem.eql(u8, connection, "close")) break :http_loop; - if (config.keepalive_count_max) |max| { - if (keepalive_count > max) { - log.debug("closing connection, exceeded keepalive max", .{}); - break :http_loop; - } - - keepalive_count += 1; - } - - try prepare_new_request(&state, provision, config); - }, - .close => break :http_loop, - } - }, - .respond => { - const body = provision.response.body orelse ""; - const content_length = body.len; - - try provision.response.headers_into_writer(&provision.header_writer, content_length); - const headers = provision.header_writer.buffered(); - - var sent: usize = 0; - const pseudo: Pseudoslice = .init(headers, body, provision.recv_slice); - - while (sent < pseudo.len) { - const send_slice = pseudo.get(sent, sent + provision.recv_slice.len); - - const sent_length = secure.send_all(rt, send_slice) catch |e| { - log.debug("send failed on socket | {}", .{e}); - break; - }; - if (sent_length != send_slice.len) break :http_loop; - sent += sent_length; - } - - const connection = provision.request.headers.get("Connection") orelse "keep-alive"; - if (std.mem.eql(u8, connection, "close")) break; - if (config.keepalive_count_max) |max| { - if (keepalive_count > max) { - log.debug("closing connection, exceeded keepalive max", .{}); - break; - } - - keepalive_count += 1; - } - - try prepare_new_request(&state, provision, config); - }, - }; - - log.info("connection ({f}) closed", .{secure.socket.addr}); - - if (!accept_queued.*) { - try rt.spawn( - main_frame, - .{ - rt, - config, - router, - server_socket, - provisions, - connection_count, - accept_queued, - }, - config.stack_size, - ); - accept_queued.* = true; - } - } - - const SocketKind = union(enum) { - normal: Socket, - secure: SecureSocket, - }; - - /// Serve an HTTP server. - pub fn serve(self: *Self, rt: *Runtime, router: *const Router, sock: SocketKind) !void { - log.info("security mode: {s}", .{@tagName(sock)}); - - const secure: SecureSocket = switch (sock) { - .normal => |s| .unsecured(s), - .secure => |sec| sec, - }; - - const count = self.config.connection_count_max orelse 1024; - const pooling: PoolKind = if (self.config.connection_count_max == null) .grow else .static; - - const provision_pool = try rt.allocator.create(Pool(Provision)); - provision_pool.* = try .init(rt.allocator, count, pooling); - errdefer rt.allocator.destroy(provision_pool); - - const connection_count = try rt.allocator.create(usize); - errdefer rt.allocator.destroy(connection_count); - connection_count.* = 0; - - const accept_queued = try rt.allocator.create(bool); - errdefer rt.allocator.destroy(accept_queued); - accept_queued.* = true; - - // Use a Max Header Size of 8KiB same as Nginx, Tomcat and Httpd but - // consider making this configurable - // https://stackoverflow.com/questions/686217/maximum-on-http-header-values - const max_http_header_size = 1024 * 8; - const pool_header_buffer: []u8 = try rt.allocator.alloc(u8, count * max_http_header_size); - errdefer rt.allocator.free(pool_header_buffer); - var next_header_buffer_index: usize = 0; - - // initialize first batch of provisions :) - for (provision_pool.items) |*provision| { - provision.initalized = true; - provision.zc_recv_buffer = ZeroCopy(u8).init( - rt.allocator, - self.config.socket_buffer_bytes, - ) catch { - @panic("attempting to allocate more memory than available. (ZeroCopy)"); - }; - provision.header_writer = .fixed(pool_header_buffer[next_header_buffer_index..][0..max_http_header_size]); - next_header_buffer_index += max_http_header_size; - - provision.arena = .init(rt.allocator); - provision.captures = rt.allocator.alloc(Capture, self.config.capture_count_max) catch { - @panic("attempting to allocate more memory than available. (Captures)"); - }; - provision.queries = .init(rt.allocator); - provision.storage = .init(rt.allocator); - provision.request = .init(rt.allocator); - provision.response = .init(rt.allocator); - } - - try rt.spawn( - main_frame, - .{ - rt, - self.config, - router, - secure, - provision_pool, - connection_count, - accept_queued, - }, - self.config.stack_size, - ); - } -}; diff --git a/src/http/sse.zig b/src/http/sse.zig deleted file mode 100644 index 44c54e1..0000000 --- a/src/http/sse.zig +++ /dev/null @@ -1,70 +0,0 @@ -const std = @import("std"); -const Writer = std.Io.Writer; - -const Runtime = @import("tardy").Runtime; -const secsock = @import("secsock"); -const SecureSocket = secsock.SecureSocket; - -const Pseudoslice = @import("../core/pseudoslice.zig").Pseudoslice; -const Context = @import("context.zig").Context; -const Mime = @import("mime.zig").Mime; -const Provision = @import("server.zig").Provision; - -const log = std.log.scoped(.@"zzz/http/sse"); - -const SSEMessage = struct { - id: ?[]const u8 = null, - event: ?[]const u8 = null, - data: ?[]const u8 = null, - retry: ?u64 = null, -}; - -pub const SSE = struct { - socket: SecureSocket, - writer: Writer.Allocating, - runtime: *Runtime, - - pub fn init(ctx: *const Context) !SSE { - const response = ctx.response; - response.status = .OK; - response.mime = .{ - .content_type = .{ .single = "text/event-stream" }, - .extension = .{ .single = "" }, - .description = "SSE", - }; - - var writer: Writer.Allocating = .init(ctx.allocator); - errdefer writer.deinit(); - - try ctx.response.headers_into_writer(ctx.header_writer, null); - const headers = ctx.header_writer.buffered(); - - const sent = try ctx.socket.send_all(ctx.runtime, headers); - if (sent != headers.len) return error.Closed; - - return .{ - .socket = ctx.socket, - .writer = writer, - .runtime = ctx.runtime, - }; - } - - pub fn send(self: *SSE, message: SSEMessage) !void { - var aw = &self.writer; - defer aw.clearRetainingCapacity(); // reuse the writer - const writer = &aw.writer; - - if (message.id) |id| try writer.print("id: {s}\n", .{id}); - if (message.event) |event| try writer.print("event: {s}\n", .{event}); - if (message.data) |data| { - var iter = std.mem.splitScalar(u8, data, '\n'); - while (iter.next()) |line| try writer.print("data: {s}\n", .{line}); - } - if (message.retry) |retry| try writer.print("retry: {d}\n", .{retry}); - try writer.writeByte('\n'); - - const written = aw.written(); - const sent = try self.socket.send_all(self.runtime, written); - if (sent != written.len) return error.Closed; - } -}; From 2ca8406ee76d79d2b4ea7ded98e928b4693ca8ab Mon Sep 17 00:00:00 2001 From: Bernard Assan Date: Wed, 15 Jul 2026 22:58:19 +0000 Subject: [PATCH 5/8] bump zig version to 0.17.0-dev.1413+addc3c3b8 check if it fixes the note: unable to create file 'src/runtime/timer.zig': PathAlreadyExists error Signed-off-by: Bernard Assan --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 93519ca..b9205c6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v6 - uses: mlugg/setup-zig@v2 with: - version: 0.17.0-dev.956+2dca73595 + version: 0.17.0-dev.1413+addc3c3b8 - name: Build all examples run: zig build @@ -46,6 +46,6 @@ jobs: - uses: actions/checkout@v6 - uses: mlugg/setup-zig@v2 with: - version: 0.17.0-dev.956+2dca73595 + version: 0.17.0-dev.1413+addc3c3b8 - name: Build all examples run: zig build test --summary all From dbe1e68f13a19711c82ac8a7179cdf9bf816dd2e Mon Sep 17 00:00:00 2001 From: Bernard Assan Date: Sat, 18 Jul 2026 23:53:55 +0000 Subject: [PATCH 6/8] update tardy and secsock dependencies to fix a CI error ```elvish Run zig build D:\a\zzz\zzz\build.zig.zon:8:20: error: unable to unpack packfile .url = "git+https://github.com/tardy-org/tardy?ref=main#1436624746f06f34ebb3a49fe8a931af8b316fbe", ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ note: unable to create file 'src\runtime\timer.zig': PathAlreadyExists Error: Process completed with exit code 1. ``` with the latest tardy, examples can be built without the error `undefined symbol: tardy_swap_frame` when `.use_llvm = true` isn't set, becuase tardy no longer uses global assembly for its context switching. This localizes the `.use_llvm` to only tardy Also `.link_libc` isn't needed for the examples anymore Signed-off-by: Bernard Assan --- build.zig | 16 +--------------- build.zig.zon | 8 ++++---- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/build.zig b/build.zig index a8d2e26..6f25f38 100644 --- a/build.zig +++ b/build.zig @@ -31,19 +31,10 @@ pub fn build(b: *std.Build) void { "fs", "middleware", "sse", + "tls", }) |name| add_example( b, name, - false, - target, - optimize, - zzz, - ); - - add_example( - b, - "tls", - true, target, optimize, zzz, @@ -52,7 +43,6 @@ pub fn build(b: *std.Build) void { if (target.result.os.tag != .windows) add_example( b, "unix", - false, target, optimize, zzz, @@ -79,7 +69,6 @@ pub fn build(b: *std.Build) void { fn add_example( b: *std.Build, name: []const u8, - link_libc: bool, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, zzz_module: *std.Build.Module, @@ -89,15 +78,12 @@ fn add_example( .optimize = optimize, .target = target, .strip = false, - .link_libc = link_libc, }); mod.addImport("zzz", zzz_module); const example = b.addExecutable(.{ .name = name, .root_module = mod, - // without llvm leads to error: undefined symbol: tardy_swap_frame - .use_llvm = true, }); const install_artifact = b.addInstallArtifact(example, .{}); diff --git a/build.zig.zon b/build.zig.zon index c791d65..a001775 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,12 +5,12 @@ .minimum_zig_version = "0.17.0-dev.956+2dca73595", .dependencies = .{ .tardy = .{ - .url = "git+https://github.com/tardy-org/tardy?ref=main#1436624746f06f34ebb3a49fe8a931af8b316fbe", - .hash = "tardy-0.3.3-dev-69wrgt2fBQA2dlJcIbEeI2l5oAHY91FoOmA12EOjfizY", + .url = "git+https://github.com/tardy-org/tardy?ref=main#c03d2a4444ee39b78ca7592aa3b370c7198a05a6", + .hash = "tardy-0.3.3-dev-69wrgkq4BQBC0mpPpV5AWskM0aCChG7ZtMe4feSFSw3R", }, .secsock = .{ - .url = "git+https://github.com/tardy-org/secsock?ref=main#0fc315b11d86e34bb67c8bef41c01db1c41103d1", - .hash = "secsock-0.1.2-dev-p0qurcdLAQAOPCKieuk0ZVgHGoPBY9G4iqJvHDqw2OAV", + .url = "git+https://github.com/tardy-org/secsock?ref=main#035796324681e783f1e37c186dd11760e183f7f2", + .hash = "secsock-0.1.2-dev-p0qurS5LAQCO-FNsaCaYYNdK45kFD12UZEtUAVupTeoC", }, }, .paths = .{ From 469599c6b48dbaabbabfd654544e4342c469dbf8 Mon Sep 17 00:00:00 2001 From: Bernard Assan Date: Sun, 19 Jul 2026 00:21:11 +0000 Subject: [PATCH 7/8] Update test to use the new zzz structure Signed-off-by: Bernard Assan --- src/tests.zig | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/tests.zig b/src/tests.zig index 83f9176..1c414f8 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -1,26 +1,27 @@ -const std = @import("std"); -const testing = std.testing; - test "zzz unit tests" { // Core - testing.refAllDecls(@import("./core/any_case_string_map.zig")); - testing.refAllDecls(@import("./core/pseudoslice.zig")); - testing.refAllDecls(@import("./core/typed_storage.zig")); + _ = core.string_map.AnyCase; + _ = core.Pseudoslice; + _ = core.TypedStorage; // HTTP - testing.refAllDecls(@import("./http/context.zig")); - testing.refAllDecls(@import("./http/date.zig")); - testing.refAllDecls(@import("./http/method.zig")); - testing.refAllDecls(@import("./http/mime.zig")); - testing.refAllDecls(@import("./http/request.zig")); - testing.refAllDecls(@import("./http/response.zig")); - testing.refAllDecls(@import("./http/server.zig")); - testing.refAllDecls(@import("./http/sse.zig")); - testing.refAllDecls(@import("./http/status.zig")); - testing.refAllDecls(@import("./http/form.zig")); + _ = http.Context; + _ = http.Date; + _ = http.Method; + _ = http.Mime; + _ = http.Request; + _ = http.Response; + _ = http.Server; + _ = http.SSE; + _ = http.Status; + _ = http.form; // Router - testing.refAllDecls(@import("./http/router.zig")); - testing.refAllDecls(@import("./http/router/route.zig")); - testing.refAllDecls(@import("./http/router/routing_trie.zig")); + _ = http.Router; + _ = http.Router.Route; + _ = http.Router.Trie; } + +const zzz = @import("root.zig"); +const core = zzz.core; +const http = zzz.http; From 36fd4379853ca97b4df42dc75fa677672c92642f Mon Sep 17 00:00:00 2001 From: Bernard Assan Date: Sun, 19 Jul 2026 00:31:06 +0000 Subject: [PATCH 8/8] test step don't need to import tardy or secsock this was leading to unnecessary compiling of tardy and secsock for tests bump minimum supported zig version to 0.17.0-dev.1413+addc3c3b8 Signed-off-by: Bernard Assan --- README.md | 2 +- build.zig | 2 -- build.zig.zon | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c79c7e7..61e2534 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Discord](https://img.shields.io/discord/1294761432922980392?logo=discord)](https://discord.gg/HNEszT7qSR) ## Installing -For in development zzz which uses [tardy/main](https://github.com/tardy-org/tardy/tree/main/), [secsock/main](https://github.com/tardy-org/secsock/tree/main/) and Zig `0.17.0-dev.956+2dca73595` +For in development zzz which uses [tardy/main](https://github.com/tardy-org/tardy/tree/main/), [secsock/main](https://github.com/tardy-org/secsock/tree/main/) and Zig `0.17.0-dev.1413+addc3c3b8` ```elvish zig fetch --save 'git+https://github.com/tardy-org/zzz?ref=main#commit_hash' diff --git a/build.zig b/build.zig index 6f25f38..d619003 100644 --- a/build.zig +++ b/build.zig @@ -56,8 +56,6 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }), }); - tests.root_module.addImport("tardy", tardy); - tests.root_module.addImport("secsock", secsock); const run_test = b.addRunArtifact(tests); run_test.step.dependOn(&tests.step); diff --git a/build.zig.zon b/build.zig.zon index a001775..92dc25e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,7 +2,7 @@ .name = .zzz, .fingerprint = 0xc3273dca261a7ae0, .version = "0.3.2-dev", - .minimum_zig_version = "0.17.0-dev.956+2dca73595", + .minimum_zig_version = "0.17.0-dev.1413+addc3c3b8", .dependencies = .{ .tardy = .{ .url = "git+https://github.com/tardy-org/tardy?ref=main#c03d2a4444ee39b78ca7592aa3b370c7198a05a6",