diff --git a/README.md b/README.md index f9c08ce..98355b8 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ _Jetzig_ is a web framework written in 100% pure [Zig](https://ziglang.org) :lizard: for _Linux_, _OS X_, _Windows_, and any _OS_ that can compile _Zig_ code. -The framework is under active development and is currently in an alpha state. - Official website: [jetzig.dev](https://www.jetzig.dev/) _Jetzig_ aims to provide a rich set of user-friendly tools for building modern web applications quickly. See the checklist below. @@ -13,7 +11,7 @@ Join us on Discord ! [https://discord.gg/eufqssz7X6](https://discord.gg/eufqssz7 If you are interested in _Jetzig_ you will probably find these tools interesting too: * [Zap](https://github.com/zigzap/zap) -* [http.zig](https://github.com/karlseguin/http.zig) +* [http.zig](https://github.com/karlseguin/http.zig) (_Jetzig_'s backend) * [tokamak](https://github.com/cztomsik/tokamak) * [zig-router](https://github.com/Cloudef/zig-router) * [zig-webui](https://github.com/webui-dev/zig-webui/) diff --git a/build.zig b/build.zig index 19456fa..05a642e 100644 --- a/build.zig +++ b/build.zig @@ -57,6 +57,7 @@ pub fn build(b: *std.Build) !void { const jetkv_dep = b.dependency("jetkv", .{ .target = target, .optimize = optimize }); const zmd_dep = b.dependency("zmd", .{ .target = target, .optimize = optimize }); + const httpz_dep = b.dependency("httpz", .{ .target = target, .optimize = optimize }); // This is the way to make it look nice in the zig build script // If we would do it the other way around, we would have to do @@ -75,6 +76,7 @@ pub fn build(b: *std.Build) !void { jetzig_module.addImport("zmd", zmd_dep.module("zmd")); jetzig_module.addImport("jetkv", jetkv_dep.module("jetkv")); jetzig_module.addImport("smtp", smtp_client_dep.module("smtp_client")); + jetzig_module.addImport("httpz", httpz_dep.module("httpz")); const main_tests = b.addTest(.{ .root_source_file = .{ .path = "src/tests.zig" }, @@ -93,6 +95,7 @@ pub fn build(b: *std.Build) !void { main_tests.root_module.addImport("zmpl", zmpl_dep.module("zmpl")); main_tests.root_module.addImport("jetkv", jetkv_dep.module("jetkv")); + main_tests.root_module.addImport("httpz", httpz_dep.module("httpz")); const run_main_tests = b.addRunArtifact(main_tests); const test_step = b.step("test", "Run library tests"); diff --git a/build.zig.zon b/build.zig.zon index 56d2735..70f44e3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,20 +7,24 @@ .hash = "1220482f07f2bbaef335f20d6890c15a1e14739950b784232bc69182423520e058a5", }, .zmpl = .{ - .url = "https://github.com/jetzig-framework/zmpl/archive/c14683521ca48c1de0a9b2d61dfb145e1bc4dac1.tar.gz", - .hash = "122093b741ef4aff151e916fc6005cb0c2aed747a34b77c0d4b45099ea2b561df9c7", + .url = "https://github.com/jetzig-framework/zmpl/archive/ef04bf3579e176f9fa3a02effc4ffcbbb5d080d8.tar.gz", + .hash = "12209bd490ef2c841d607a6260be9cc40e20dc76786cb99d0fcd72cfef4a253a840d", }, .jetkv = .{ - .url = "https://github.com/jetzig-framework/jetkv/archive/50016e13c89e86c89b7f7ae93d4f0a31d3be303b.tar.gz", - .hash = "122090b828d2cdd4915d242cb3761fe9142b145e49a2341f8b29343839945d6ab256", + .url = "https://github.com/jetzig-framework/jetkv/archive/6fc375b1ece563ae6d16849bb7c0441ff2883a04.tar.gz", + .hash = "122079edca9ea46ebb5ce8f05ea2c58ee957cf2d73fcfd9a0fd6a50f65879f3bf88f", }, .args = .{ .url = "https://github.com/MasterQ32/zig-args/archive/01d72b9a0128c474aeeb9019edd48605fa6d95f7.tar.gz", .hash = "12208a1de366740d11de525db7289345949f5fd46527db3f89eecc7bb49b012c0732", }, .smtp_client = .{ - .url = "https://github.com/bobf/smtp_client.zig/archive/86315adf5527e6add304fea3f1ff110613126283.tar.gz", - .hash = "12203ccdd3f7145f305c6f88b18ecd407d27b36051a523f8f579f0099db6a17cd757", + .url = "https://github.com/karlseguin/smtp_client.zig/archive/964152ad4e19dc1d22f6def6f659c86df60e7832.tar.gz", + .hash = "1220d4f1c2472769b0d689ea878f41f0a66cb07f28569a138aea2c0a648a5c90dd4e", + }, + .httpz = .{ + .url = "https://github.com/karlseguin/http.zig/archive/34f1aa8a1486478414e876f65364a501d73c8a76.tar.gz", + .hash = "12205404dd8bdd98c659e844385154eb28116c1be073103fc94739bc99fa912323e8", }, }, diff --git a/demo/src/app/middleware/DemoMiddleware.zig b/demo/src/app/middleware/DemoMiddleware.zig index a6758d2..d390535 100644 --- a/demo/src/app/middleware/DemoMiddleware.zig +++ b/demo/src/app/middleware/DemoMiddleware.zig @@ -20,11 +20,11 @@ const jetzig = @import("jetzig"); /// can also be modified. my_custom_value: []const u8, -const Self = @This(); +const DemoMiddleware = @This(); /// Initialize middleware. -pub fn init(request: *jetzig.http.Request) !*Self { - var middleware = try request.allocator.create(Self); +pub fn init(request: *jetzig.http.Request) !*DemoMiddleware { + var middleware = try request.allocator.create(DemoMiddleware); middleware.my_custom_value = "initial value"; return middleware; } @@ -32,7 +32,7 @@ pub fn init(request: *jetzig.http.Request) !*Self { /// Invoked immediately after the request is received but before it has started processing. /// Any calls to `request.render` or `request.redirect` will prevent further processing of the /// request, including any other middleware in the chain. -pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { +pub fn afterRequest(self: *DemoMiddleware, request: *jetzig.http.Request) !void { try request.server.logger.DEBUG( "[DemoMiddleware:afterRequest] my_custom_value: {s}", .{self.my_custom_value}, @@ -42,7 +42,11 @@ pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { /// Invoked immediately before the response renders to the client. /// The response can be modified here if needed. -pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { +pub fn beforeResponse( + self: *DemoMiddleware, + request: *jetzig.http.Request, + response: *jetzig.http.Response, +) !void { try request.server.logger.DEBUG( "[DemoMiddleware:beforeResponse] my_custom_value: {s}, response status: {s}", .{ self.my_custom_value, @tagName(response.status_code) }, @@ -51,7 +55,11 @@ pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jet /// Invoked immediately after the response has been finalized and sent to the client. /// Response data can be accessed for logging, but any modifications will have no impact. -pub fn afterResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { +pub fn afterResponse( + self: *DemoMiddleware, + request: *jetzig.http.Request, + response: *jetzig.http.Response, +) !void { _ = self; _ = response; try request.server.logger.DEBUG("[DemoMiddleware:afterResponse] response completed", .{}); @@ -60,6 +68,6 @@ pub fn afterResponse(self: *Self, request: *jetzig.http.Request, response: *jetz /// Invoked after `afterResponse` is called. Use this function to do any clean-up. /// Note that `request.allocator` is an arena allocator, so any allocations are automatically /// freed before the next request starts processing. -pub fn deinit(self: *Self, request: *jetzig.http.Request) void { +pub fn deinit(self: *DemoMiddleware, request: *jetzig.http.Request) void { request.allocator.destroy(self); } diff --git a/demo/src/app/views/basic.zig b/demo/src/app/views/basic.zig new file mode 100644 index 0000000..4fac587 --- /dev/null +++ b/demo/src/app/views/basic.zig @@ -0,0 +1,7 @@ +const std = @import("std"); +const jetzig = @import("jetzig"); + +pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { + _ = data; + return request.render(.ok); +} diff --git a/demo/src/app/views/basic/index.zmpl b/demo/src/app/views/basic/index.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/demo/src/app/views/basic/index.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/demo/src/app/views/mail.zig b/demo/src/app/views/mail.zig index 24b93ce..5e8a2f3 100644 --- a/demo/src/app/views/mail.zig +++ b/demo/src/app/views/mail.zig @@ -10,7 +10,7 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { // * `src/app/mailers/welcome/html.zmpl` // * `src/app/mailers/welcome/text.zmpl` // All mailer templates have access to the same template data as a view template. - const mail = request.mail("welcome", .{ .to = &.{"hello.dev"} }); + const mail = request.mail("welcome", .{ .to = &.{"hello@jetzig.dev"} }); // Deliver the email asynchronously via a built-in mail Job. Use `.now` to send the email // synchronously (i.e. before the request has returned). diff --git a/demo/src/app/views/redirect.zig b/demo/src/app/views/redirect.zig index 7b951c4..2330df0 100644 --- a/demo/src/app/views/redirect.zig +++ b/demo/src/app/views/redirect.zig @@ -16,5 +16,6 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { } } + try request.response.headers.append("foobar", "hello"); return request.render(.ok); } diff --git a/demo/src/app/views/session.zig b/demo/src/app/views/session.zig index 75676af..ee85a1c 100644 --- a/demo/src/app/views/session.zig +++ b/demo/src/app/views/session.zig @@ -4,7 +4,9 @@ const jetzig = @import("jetzig"); pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { var root = try data.object(); - if (try request.session.get("message")) |message| { + const session = try request.session(); + + if (try session.get("message")) |message| { try root.put("message", message); } else { try root.put("message", data.string("No message saved yet")); @@ -16,9 +18,10 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { _ = data; const params = try request.params(); + var session = try request.session(); if (params.get("message")) |message| { - try request.session.put("message", message); + try session.put("message", message); } return request.redirect("/session", .moved_permanently); diff --git a/demo/src/main.zig b/demo/src/main.zig index 08213d1..e89b27a 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -15,7 +15,7 @@ pub const jetzig_options = struct { jetzig.middleware.HtmxMiddleware, // Demo middleware included with new projects. Remove once you are familiar with Jetzig's // middleware system. - @import("app/middleware/DemoMiddleware.zig"), + // @import("app/middleware/DemoMiddleware.zig"), }; // Maximum bytes to allow in request body. @@ -27,6 +27,22 @@ pub const jetzig_options = struct { // Maximum filesize for `static/` content (applies only to apps using `jetzig.http.StaticRequest`). // pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 18); + // Maximum length of a header name. There is no limit imposed by the HTTP specification but + // AWS load balancers reference 40 as a limit so we use that as a baseline: + // https://docs.aws.amazon.com/elasticloadbalancing/latest/APIReference/API_HttpHeaderConditionConfig.html + // This can be increased if needed. + // pub const max_bytes_header_name: u16 = 40; + + // Log message buffer size. Log messages exceeding this size spill to heap with degraded + // performance. Log messages should aim to fit in the message buffer. + // pub const log_message_buffer_len: usize = 4096; + + // Maximum log pool size. When a log buffer is no longer required it is returned to a pool + // for recycling. When logging i/o is slow, a high volume of requests will result in this + // pool growing. When the pool size reaches the maximum value defined here, log events are + // freed instead of recycled. + // pub const max_log_pool_len: usize = 256; + // Path relative to cwd() to serve public content from. Symlinks are not followed. // pub const public_content_path = "public"; diff --git a/src/jetzig.zig b/src/jetzig.zig index 4d18546..fc5026d 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -78,6 +78,22 @@ pub const config = struct { /// Maximum filesize for `static/` content (applies only to apps using `jetzig.http.StaticRequest`). pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 18); + /// Maximum length of a header name. There is no limit imposed by the HTTP specification but + /// AWS load balancers reference 40 as a limit so we use that as a baseline: + /// https://docs.aws.amazon.com/elasticloadbalancing/latest/APIReference/API_HttpHeaderConditionConfig.html + /// This can be increased if needed. + pub const max_bytes_header_name: u16 = 40; + + /// Log message buffer size. Log messages exceeding this size spill to heap with degraded + /// performance. Log messages should aim to fit in the message buffer. + pub const log_message_buffer_len: usize = 4096; + + /// Maximum log pool size. When a log buffer is no longer required it is returned to a pool + /// for recycling. When logging i/o is slow, a high volume of requests will result in this + /// pool growing. When the pool size reaches the maximum value defined here, log events are + /// freed instead of recycled. + pub const max_log_pool_len: usize = 256; + /// Path relative to cwd() to serve public content from. Symlinks are not followed. pub const public_content_path = "public"; diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index 59af1df..4e209cd 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -79,6 +79,13 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void { defer self.allocator.free(server_options.bind); defer self.allocator.free(server_options.secret); + var log_thread = try std.Thread.spawn( + .{ .allocator = self.allocator }, + jetzig.loggers.LogQueue.Reader.publish, + .{ &server_options.log_queue.reader, .{} }, + ); + defer log_thread.join(); + if (server_options.detach) { const argv = try std.process.argsAlloc(self.allocator); defer std.process.argsFree(self.allocator, argv); @@ -105,7 +112,6 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void { &job_queue, &cache, ); - defer server.deinit(); var mutex = std.Thread.Mutex{}; var worker_pool = jetzig.jobs.Pool.init( diff --git a/src/jetzig/Environment.zig b/src/jetzig/Environment.zig index 47e8b1f..ce458a8 100644 --- a/src/jetzig/Environment.zig +++ b/src/jetzig/Environment.zig @@ -63,6 +63,13 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { const options = try args.parseForCurrentProcess(Options, self.allocator, .print); defer options.deinit(); + const log_queue = try self.allocator.create(jetzig.loggers.LogQueue); + log_queue.* = jetzig.loggers.LogQueue.init(self.allocator); + try log_queue.setFiles( + try getLogFile(.stdout, options.options), + try getLogFile(.stderr, options.options), + ); + if (options.options.help) { const writer = std.io.getStdErr().writer(); try args.printHelp(Options, options.executable_name orelse "", writer); @@ -76,16 +83,14 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { .development_logger = jetzig.loggers.DevelopmentLogger.init( self.allocator, resolveLogLevel(options.options.@"log-level", environment), - try getLogFile(.stdout, options.options), - try getLogFile(.stderr, options.options), + log_queue, ), }, .json => jetzig.loggers.Logger{ .json_logger = jetzig.loggers.JsonLogger.init( self.allocator, resolveLogLevel(options.options.@"log-level", environment), - try getLogFile(.stdout, options.options), - try getLogFile(.stderr, options.options), + log_queue, ), }, }; @@ -111,6 +116,7 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { .port = options.options.port, .detach = options.options.detach, .environment = environment, + .log_queue = log_queue, }; } diff --git a/src/jetzig/colors.zig b/src/jetzig/colors.zig index 36e4af0..5d74026 100644 --- a/src/jetzig/colors.zig +++ b/src/jetzig/colors.zig @@ -4,37 +4,83 @@ const builtin = @import("builtin"); const types = @import("types.zig"); +// Must be consistent with `std.io.tty.Color` for Windows compatibility. const codes = .{ - .escape = "\x1B[", - .reset = "0;0", - .black = "0;30", - .red = "0;31", - .green = "0;32", - .yellow = "0;33", - .blue = "0;34", - .purple = "0;35", - .cyan = "0;36", - .white = "0;37", + .escape = "\x1b[", + .black = "30m", + .red = "31m", + .green = "32m", + .yellow = "33m", + .blue = "34m", + .magenta = "35m", + .cyan = "36m", + .white = "37m", + .bright_black = "90m", + .bright_red = "91m", + .bright_green = "92m", + .bright_yellow = "93m", + .bright_blue = "94m", + .bright_magenta = "95m", + .bright_cyan = "96m", + .bright_white = "97m", + .bold = "1m", + .dim = "2m", + .reset = "0m", }; +/// Map color codes generated by `std.io.tty.Config.setColor` back to `std.io.tty.Color`. Used by +/// `jetzig.loggers.LogQueue.writeWindows` to parse escape codes so they can be passed to +/// `std.io.tty.Config.setColor` (using Windows API to set console color mode). +pub const codes_map = std.StaticStringMap(std.io.tty.Color).initComptime(.{ + .{ "30", .black }, + .{ "31", .red }, + .{ "32", .green }, + .{ "33", .yellow }, + .{ "34", .blue }, + .{ "35", .magenta }, + .{ "36", .cyan }, + .{ "37", .white }, + .{ "90", .bright_black }, + .{ "91", .bright_red }, + .{ "92", .bright_green }, + .{ "93", .bright_yellow }, + .{ "94", .bright_blue }, + .{ "95", .bright_magenta }, + .{ "96", .bright_cyan }, + .{ "97", .bright_white }, + .{ "1", .bold }, + .{ "2", .dim }, + .{ "0", .reset }, +}); + +/// Colorize a log message. Note that we force `.escape_codes` when we are a TTY even on Windows. +/// `jetzig.loggers.LogQueue` parses the ANSI codes and uses `std.io.tty.Config.setColor` to +/// invoke the appropriate Windows API call to set the terminal color before writing each token. +/// We must do it this way because Windows colors are set by API calls at the time of write, not +/// encoded into the message string. +pub fn colorize(color: std.io.tty.Color, buf: []u8, input: []const u8, is_colorized: bool) ![]const u8 { + if (!is_colorized) return input; + + const config: std.io.tty.Config = .escape_codes; + var stream = std.io.fixedBufferStream(buf); + const writer = stream.writer(); + try config.setColor(writer, color); + try writer.writeAll(input); + try config.setColor(writer, .reset); + + return stream.getWritten(); +} + fn wrap(comptime attribute: []const u8, comptime message: []const u8) []const u8 { - if (builtin.os.tag == .windows) { - return message; - } else { - return codes.escape ++ attribute ++ "m" ++ message ++ codes.escape ++ codes.reset ++ "m"; - } + return codes.escape ++ attribute ++ message ++ codes.escape ++ codes.reset; } fn runtimeWrap(allocator: std.mem.Allocator, attribute: []const u8, message: []const u8) ![]const u8 { - if (builtin.os.tag == .windows) { - return try allocator.dupe(u8, message); - } else { - return try std.mem.join( - allocator, - "", - &[_][]const u8{ codes.escape, attribute, "m", message, codes.escape, codes.reset, "m" }, - ); - } + return try std.mem.join( + allocator, + "", + &[_][]const u8{ codes.escape, attribute, message, codes.escape, codes.reset }, + ); } pub fn black(comptime message: []const u8) []const u8 { @@ -77,12 +123,12 @@ pub fn runtimeBlue(allocator: std.mem.Allocator, message: []const u8) ![]const u return try runtimeWrap(allocator, codes.blue, message); } -pub fn purple(comptime message: []const u8) []const u8 { - return wrap(codes.purple, message); +pub fn magenta(comptime message: []const u8) []const u8 { + return wrap(codes.magenta, message); } -pub fn runtimePurple(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { - return try runtimeWrap(allocator, codes.purple, message); +pub fn runtimeMagenta(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.magenta, message); } pub fn cyan(comptime message: []const u8) []const u8 { @@ -101,15 +147,26 @@ pub fn runtimeWhite(allocator: std.mem.Allocator, message: []const u8) ![]const return try runtimeWrap(allocator, codes.white, message); } -pub fn duration(allocator: std.mem.Allocator, delta: i64) ![]const u8 { - var buf: [1024]u8 = undefined; - const formatted_duration = try std.fmt.bufPrint(&buf, "{}", .{std.fmt.fmtDurationSigned(delta)}); - - if (delta < 1000000) { - return try runtimeGreen(allocator, formatted_duration); - } else if (delta < 5000000) { - return try runtimeYellow(allocator, formatted_duration); - } else { - return try runtimeRed(allocator, formatted_duration); +pub fn duration(buf: *[256]u8, delta: i64, is_colorized: bool) ![]const u8 { + if (!is_colorized) { + return try std.fmt.bufPrint( + buf, + "{}", + .{std.fmt.fmtDurationSigned(delta)}, + ); } + + const color: std.io.tty.Color = if (delta < 1000000) + .green + else if (delta < 5000000) + .yellow + else + .red; + var duration_buf: [256]u8 = undefined; + const formatted_duration = try std.fmt.bufPrint( + &duration_buf, + "{}", + .{std.fmt.fmtDurationSigned(delta)}, + ); + return try colorize(color, buf, formatted_duration, true); } diff --git a/src/jetzig/http.zig b/src/jetzig/http.zig index 5151d26..e35f325 100644 --- a/src/jetzig/http.zig +++ b/src/jetzig/http.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); pub const Server = @import("http/Server.zig"); pub const Request = @import("http/Request.zig"); diff --git a/src/jetzig/http/Cookies.zig b/src/jetzig/http/Cookies.zig index b5bd3a0..171ebf6 100644 --- a/src/jetzig/http/Cookies.zig +++ b/src/jetzig/http/Cookies.zig @@ -6,6 +6,7 @@ allocator: std.mem.Allocator, cookie_string: []const u8, buf: std.ArrayList(u8), cookies: std.StringArrayHashMap(*Cookie), +modified: bool = false, const Self = @This(); @@ -38,6 +39,8 @@ pub fn get(self: *Self, key: []const u8) ?*Cookie { } pub fn put(self: *Self, key: []const u8, value: Cookie) !void { + self.modified = true; + if (self.cookies.fetchSwapRemove(key)) |entry| { self.allocator.free(entry.key); self.allocator.free(entry.value.value); diff --git a/src/jetzig/http/Headers.zig b/src/jetzig/http/Headers.zig index 0e2388a..8db8c56 100644 --- a/src/jetzig/http/Headers.zig +++ b/src/jetzig/http/Headers.zig @@ -1,120 +1,102 @@ const std = @import("std"); + +const httpz = @import("httpz"); + const jetzig = @import("../../jetzig.zig"); allocator: std.mem.Allocator, -headers: HeadersArray, +httpz_headers: *httpz.key_value.KeyValue, +new_headers: std.ArrayList(Header), const Headers = @This(); -pub const max_headers = 25; -const HeadersArray = std.ArrayListUnmanaged(std.http.Header); +const Header = struct { name: []const u8, value: []const u8 }; +const max_bytes_header_name = jetzig.config.get(u8, "max_bytes_header_name"); -pub fn init(allocator: std.mem.Allocator) Headers { +pub fn init(allocator: std.mem.Allocator, httpz_headers: *httpz.key_value.KeyValue) Headers { return .{ .allocator = allocator, - .headers = HeadersArray.initCapacity(allocator, max_headers) catch @panic("OOM"), + .httpz_headers = httpz_headers, + .new_headers = std.ArrayList(Header).init(allocator), }; } pub fn deinit(self: *Headers) void { - self.headers.deinit(self.allocator); -} + self.httpz_headers.deinit(self.allocator); -/// Gets the first value for a given header identified by `name`. Names are case insensitive. -pub fn get(self: Headers, name: []const u8) ?[]const u8 { - for (self.headers.items) |header| { - if (jetzig.util.equalStringsCaseInsensitive(name, header.name)) return header.value; + for (self.new_headers.items) |header| { + self.allocator.free(header.name); + self.allocator.free(header.value); } - return null; + + self.new_headers.deinit(); } -/// Gets the first value for a given header identified by `name`. Names are case insensitive. +/// Get the first value for a given header identified by `name`. Names are case insensitive. +pub fn get(self: Headers, name: []const u8) ?[]const u8 { + std.debug.assert(name.len <= max_bytes_header_name); + + var buf: [max_bytes_header_name]u8 = undefined; + const lower = std.ascii.lowerString(&buf, name); + + return self.httpz_headers.get(lower); +} + +/// Get all values for a given header identified by `name`. Names are case insensitive. pub fn getAll(self: Headers, name: []const u8) []const []const u8 { var headers = std.ArrayList([]const u8).init(self.allocator); - for (self.headers.items) |header| { - if (jetzig.util.equalStringsCaseInsensitive(name, header.name)) { - headers.append(header.value) catch @panic("OOM"); - } + for (self.httpz_headers.keys, 0..) |key, index| { + var buf: [max_bytes_header_name]u8 = undefined; + const lower = std.ascii.lowerString(&buf, name); + + if (std.mem.eql(u8, lower, key)) headers.append(self.httpz_headers.values[index]) catch @panic("OOM"); } return headers.toOwnedSlice() catch @panic("OOM"); } -// Deprecated +/// Deprecated pub fn getFirstValue(self: *const Headers, name: []const u8) ?[]const u8 { return self.get(name); } -/// Appends `name` and `value` to headers. +/// Add `name` and `value` to headers. pub fn append(self: *Headers, name: []const u8, value: []const u8) !void { - if (self.headers.items.len >= 25) return error.JetzigTooManyHeaders; + if (self.httpz_headers.len >= self.httpz_headers.keys.len) return error.JetzigTooManyHeaders; - self.headers.appendAssumeCapacity(.{ .name = name, .value = value }); -} + var buf: [max_bytes_header_name]u8 = undefined; + const lower = std.ascii.lowerString(&buf, name); -/// Removes **all** header entries matching `name`. Names are case-insensitive. -pub fn remove(self: *Headers, name: []const u8) void { - if (self.headers.items.len == 0) return; - - var index: usize = self.headers.items.len; - - while (index > 0) { - index -= 1; - if (jetzig.util.equalStringsCaseInsensitive(name, self.headers.items[index].name)) { - _ = self.headers.orderedRemove(index); - } - } -} - -/// Returns an iterator which implements `next()` returning each name/value of the stored headers. -pub fn iterator(self: Headers) Iterator { - return Iterator{ .headers = self.headers }; -} - -/// Returns an array of `std.http.Header`, can be used to set response headers directly. -/// Caller owns memory. -pub fn stdHeaders(self: *Headers) !std.ArrayListUnmanaged(std.http.Header) { - var array = try std.ArrayListUnmanaged(std.http.Header).initCapacity(self.allocator, max_headers); - - var it = self.iterator(); - while (it.next()) |header| { - array.appendAssumeCapacity(.{ .name = header.name, .value = header.value }); - } - return array; -} - -/// Iterates through stored headers yielidng a `Header` on each call to `next()` -const Iterator = struct { - headers: HeadersArray, - index: usize = 0, - - const Header = struct { - name: []const u8, - value: []const u8, + const header = .{ + .name = try self.allocator.dupe(u8, lower), + .value = try self.allocator.dupe(u8, value), }; - /// Returns the next item in the current iteration of headers. - pub fn next(self: *Iterator) ?Header { - if (self.headers.items.len > self.index) { - const std_header = self.headers.items[self.index]; - self.index += 1; - return .{ .name = std_header.name, .value = std_header.value }; - } else { - return null; - } - } -}; + try self.new_headers.append(header); + self.httpz_headers.add(header.name, header.value); +} -test "append" { +test "append (deprecated)" { const allocator = std.testing.allocator; - var headers = Headers.init(allocator); + var httpz_headers = try httpz.key_value.KeyValue.init(allocator, 10); + var headers = Headers.init(allocator, &httpz_headers); defer headers.deinit(); try headers.append("foo", "bar"); - try std.testing.expectEqualStrings(headers.getFirstValue("foo").?, "bar"); + try std.testing.expectEqualStrings(headers.get("foo").?, "bar"); +} + +test "add" { + const allocator = std.testing.allocator; + var httpz_headers = try httpz.key_value.KeyValue.init(allocator, 10); + var headers = Headers.init(allocator, &httpz_headers); + defer headers.deinit(); + try headers.append("foo", "bar"); + try std.testing.expectEqualStrings(headers.get("foo").?, "bar"); } test "get with multiple headers (bugfix regression test)" { const allocator = std.testing.allocator; - var headers = Headers.init(allocator); + var httpz_headers = try httpz.key_value.KeyValue.init(allocator, 10); + var headers = Headers.init(allocator, &httpz_headers); defer headers.deinit(); try headers.append("foo", "bar"); try headers.append("bar", "baz"); @@ -123,75 +105,32 @@ test "get with multiple headers (bugfix regression test)" { test "getAll" { const allocator = std.testing.allocator; - var headers = Headers.init(allocator); + var httpz_headers = try httpz.key_value.KeyValue.init(allocator, 10); + var headers = Headers.init(allocator, &httpz_headers); defer headers.deinit(); try headers.append("foo", "bar"); try headers.append("foo", "baz"); try headers.append("bar", "qux"); const all = headers.getAll("foo"); defer allocator.free(all); - try std.testing.expectEqualSlices([]const u8, all, &[_][]const u8{ "bar", "baz" }); + try std.testing.expectEqualDeep(all, &[_][]const u8{ "bar", "baz" }); } -test "append too many headers" { +test "add too many headers" { const allocator = std.testing.allocator; - var headers = Headers.init(allocator); + var httpz_headers = try httpz.key_value.KeyValue.init(allocator, 10); + var headers = Headers.init(allocator, &httpz_headers); defer headers.deinit(); - for (0..25) |_| try headers.append("foo", "bar"); + for (0..10) |_| try headers.append("foo", "bar"); try std.testing.expectError(error.JetzigTooManyHeaders, headers.append("foo", "bar")); } test "case-insensitive matching" { const allocator = std.testing.allocator; - var headers = Headers.init(allocator); + var httpz_headers = try httpz.key_value.KeyValue.init(allocator, 10); + var headers = Headers.init(allocator, &httpz_headers); defer headers.deinit(); try headers.append("Content-Type", "bar"); - try std.testing.expectEqualStrings(headers.getFirstValue("content-type").?, "bar"); -} - -test "iterator" { - const allocator = std.testing.allocator; - var headers = Headers.init(allocator); - defer headers.deinit(); - - try headers.append("foo", "bar"); - - var it = headers.iterator(); - while (it.next()) |header| { - try std.testing.expectEqualStrings("foo", header.name); - try std.testing.expectEqualStrings("bar", header.value); - break; - } else { - try std.testing.expect(false); - } -} - -test "remove" { - const allocator = std.testing.allocator; - var headers = Headers.init(allocator); - defer headers.deinit(); - try headers.append("foo", "baz"); - try headers.append("foo", "qux"); - try headers.append("bar", "quux"); - headers.remove("Foo"); // Headers are case-insensitive. - try std.testing.expect(headers.getFirstValue("foo") == null); - try std.testing.expectEqualStrings(headers.getFirstValue("bar").?, "quux"); -} - -test "stdHeaders" { - const allocator = std.testing.allocator; - var headers = Headers.init(allocator); - defer headers.deinit(); - - try headers.append("foo", "bar"); - try headers.append("baz", "qux"); - - var std_headers = try headers.stdHeaders(); - defer std_headers.deinit(allocator); - - try std.testing.expectEqualStrings("foo", std_headers.items[0].name); - try std.testing.expectEqualStrings("bar", std_headers.items[0].value); - try std.testing.expectEqualStrings("baz", std_headers.items[1].name); - try std.testing.expectEqualStrings("qux", std_headers.items[1].value); + try std.testing.expectEqualStrings(headers.get("content-type").?, "bar"); } diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index e260a06..35de773 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -1,5 +1,7 @@ const std = @import("std"); +const httpz = @import("httpz"); + const jetzig = @import("../../jetzig.zig"); const Request = @This(); @@ -14,14 +16,15 @@ path: jetzig.http.Path, method: Method, headers: jetzig.http.Headers, server: *jetzig.http.Server, -std_http_request: std.http.Server.Request, +httpz_request: *httpz.Request, +httpz_response: *httpz.Response, response: *jetzig.http.Response, status_code: jetzig.http.status_codes.StatusCode = .not_found, response_data: *jetzig.data.Data, query_params: ?*jetzig.http.Query = null, query_body: ?*jetzig.http.Query = null, -cookies: *jetzig.http.Cookies = undefined, -session: *jetzig.http.Session = undefined, +_cookies: ?*jetzig.http.Cookies = null, +_session: ?*jetzig.http.Session = null, body: []const u8 = undefined, processed: bool = false, layout: ?[]const u8 = null, @@ -90,20 +93,18 @@ pub fn init( allocator: std.mem.Allocator, server: *jetzig.http.Server, start_time: i128, - std_http_request: std.http.Server.Request, + httpz_request: *httpz.Request, + httpz_response: *httpz.Response, response: *jetzig.http.Response, ) !Request { - const method = switch (std_http_request.head.method) { + const method = switch (httpz_request.method) { .DELETE => Method.DELETE, .GET => Method.GET, .PATCH => Method.PATCH, .POST => Method.POST, .HEAD => Method.HEAD, .PUT => Method.PUT, - .CONNECT => Method.CONNECT, .OPTIONS => Method.OPTIONS, - .TRACE => Method.TRACE, - _ => return error.JetzigUnsupportedHttpMethod, }; const response_data = try allocator.create(jetzig.data.Data); @@ -111,13 +112,14 @@ pub fn init( return .{ .allocator = allocator, - .path = jetzig.http.Path.init(std_http_request.head.target), + .path = jetzig.http.Path.init(httpz_request.url.raw), .method = method, - .headers = jetzig.http.Headers.init(allocator), + .headers = jetzig.http.Headers.init(allocator, &httpz_request.headers), .server = server, .response = response, .response_data = response_data, - .std_http_request = std_http_request, + .httpz_request = httpz_request, + .httpz_response = httpz_response, .start_time = start_time, .store = .{ .store = server.store, .allocator = allocator }, .cache = .{ .store = server.cache, .allocator = allocator }, @@ -132,63 +134,37 @@ pub fn deinit(self: *Request) void { if (self.processed) self.allocator.free(self.body); } -/// Process request, read body if present, parse headers (TODO) +/// Process request, read body if present. pub fn process(self: *Request) !void { - var headers_it = self.std_http_request.iterateHeaders(); - var cookie: ?[]const u8 = null; - - while (headers_it.next()) |header| { - try self.headers.append(header.name, header.value); - if (std.mem.eql(u8, header.name, "Cookie")) cookie = header.value; - } - - self.cookies = try self.allocator.create(jetzig.http.Cookies); - self.cookies.* = jetzig.http.Cookies.init( - self.allocator, - cookie orelse "", - ); - try self.cookies.parse(); - - self.session = try self.allocator.create(jetzig.http.Session); - self.session.* = jetzig.http.Session.init(self.allocator, self.cookies, self.server.options.secret); - self.session.parse() catch |err| { - switch (err) { - error.JetzigInvalidSessionCookie => { - try self.server.logger.DEBUG("Invalid session cookie detected. Resetting session.", .{}); - try self.session.reset(); - }, - else => return err, - } - }; - - const reader = try self.std_http_request.reader(); - self.body = try reader.readAllAlloc(self.allocator, jetzig.config.get(usize, "max_bytes_request_body")); + self.body = self.httpz_request.body() orelse ""; self.processed = true; } +pub const CallbackState = struct { + arena: *std.heap.ArenaAllocator, + allocator: std.mem.Allocator, +}; + +pub fn responseCompleteCallback(ptr: *anyopaque) void { + var state: *CallbackState = @ptrCast(@alignCast(ptr)); + state.arena.deinit(); + state.allocator.destroy(state.arena); + state.allocator.destroy(state); +} + /// Set response headers, write response payload, and finalize the response. -pub fn respond(self: *Request) !void { +pub fn respond( + self: *Request, + state: *CallbackState, +) !void { if (!self.processed) unreachable; - var cookie_it = self.cookies.headerIterator(); - while (try cookie_it.next()) |header| { - // FIXME: Skip setting cookies that are already present ? - try self.response.headers.append("Set-Cookie", header); - } + try self.setCookieHeaders(); - var std_response_headers = try self.response.headers.stdHeaders(); - defer std_response_headers.deinit(self.allocator); - - try self.std_http_request.respond( - self.response.content, - .{ - .keep_alive = false, - .status = switch (self.response.status_code) { - inline else => |tag| @field(std.http.Status, @tagName(tag)), - }, - .extra_headers = std_response_headers.items, - }, - ); + const status = jetzig.http.status_codes.get(self.response.status_code); + self.httpz_response.status = try status.getCodeInt(); + self.httpz_response.body = self.response.content; + self.httpz_response.callback(responseCompleteCallback, @ptrCast(state)); } /// Render a response. This function can only be called once per request (repeat calls will @@ -226,8 +202,15 @@ pub fn redirect( self.response_data.reset(); - self.response.headers.remove("Location"); - self.response.headers.append("Location", location) catch @panic("OOM"); + self.response.headers.append("Location", location) catch |err| { + switch (err) { + error.JetzigTooManyHeaders => std.debug.print( + "Header limit reached. Unable to add redirect header.\n", + .{}, + ), + else => @panic("OOM"), + } + }; self.rendered_view = .{ .data = self.response_data, .status_code = status_code }; return self.rendered_view.?; @@ -315,7 +298,7 @@ pub fn queryParams(self: *Request) !*jetzig.data.Value { return self.query_params.?.data.value.?; } -// Parses request body as params if present, otherwise delegates to `queryParams`. +// Parse request body as params if present, otherwise delegate to `queryParams`. fn parseQuery(self: *Request) !*jetzig.data.Value { if (self.body.len == 0) return try self.queryParams(); if (self.query_body) |parsed| return parsed.data.value.?; @@ -332,7 +315,46 @@ fn parseQuery(self: *Request) !*jetzig.data.Value { return self.query_body.?.data.value.?; } -/// Creates a new Job. Receives a job name which must resolve to `src/app/jobs/.zig` +/// Parse `Cookie` header into separate cookies. +pub fn cookies(self: *Request) !*jetzig.http.Cookies { + if (self._cookies) |capture| return capture; + + const cookie = self.httpz_request.headers.get("cookie"); + + const local_cookies = try self.allocator.create(jetzig.http.Cookies); + local_cookies.* = jetzig.http.Cookies.init( + self.allocator, + cookie orelse "", + ); + try local_cookies.parse(); + + self._cookies = local_cookies; + + return local_cookies; +} + +/// Parse cookies, decrypt Jetzig cookie (`jetzig.http.Session.cookie_name`) and return a mutable +/// `jetzig.http.Session`. +pub fn session(self: *Request) !*jetzig.http.Session { + if (self._session) |capture| return capture; + + const local_session = try self.allocator.create(jetzig.http.Session); + local_session.* = jetzig.http.Session.init(self.allocator, try self.cookies(), self.server.options.secret); + local_session.parse() catch |err| { + switch (err) { + error.JetzigInvalidSessionCookie => { + try self.server.logger.DEBUG("Invalid session cookie detected. Resetting session.", .{}); + try local_session.reset(); + }, + else => return err, + } + }; + + self._session = local_session; + return local_session; +} + +/// Create a new Job. Receives a job name which must resolve to `src/app/jobs/.zig` /// Call `Job.put(...)` to set job params. /// Call `Job.background()` to run the job outside of the request/response flow. /// e.g.: @@ -415,6 +437,14 @@ const RequestMail = struct { } }; +/// Create a new email from the mailer named `name` (`app/mailers/.zig`). Pass delivery +/// params to override defaults defined my mailer (`to`, `from`, `subject`, etc.). +/// Must call `deliver` on the returned `RequestMail` to send the email. +/// Example: +/// ```zig +/// const mail = request.mail("welcome", .{ .to = &.{"hello@jetzig.dev"} }); +/// try mail.deliver(.background, .{}); +/// ``` pub fn mail(self: *Request, name: []const u8, mail_params: jetzig.mail.MailParams) RequestMail { return .{ .request = self, @@ -435,35 +465,23 @@ fn extensionFormat(self: *const Request) ?jetzig.http.Request.Format { } pub fn acceptHeaderFormat(self: *const Request) ?jetzig.http.Request.Format { - const acceptHeader = self.getHeader("Accept"); - - if (acceptHeader) |item| { - if (std.mem.eql(u8, item, "text/html")) return .HTML; - if (std.mem.eql(u8, item, "application/json")) return .JSON; + if (self.httpz_request.headers.get("accept")) |value| { + if (std.mem.eql(u8, value, "text/html")) return .HTML; + if (std.mem.eql(u8, value, "application/json")) return .JSON; } return null; } pub fn contentTypeHeaderFormat(self: *const Request) ?jetzig.http.Request.Format { - const acceptHeader = self.getHeader("content-type"); - - if (acceptHeader) |item| { - if (std.mem.eql(u8, item, "text/html")) return .HTML; - if (std.mem.eql(u8, item, "application/json")) return .JSON; + if (self.httpz_request.headers.get("content-type")) |value| { + if (std.mem.eql(u8, value, "text/html")) return .HTML; + if (std.mem.eql(u8, value, "application/json")) return .JSON; } return null; } -pub fn hash(self: *Request) ![]const u8 { - return try std.fmt.allocPrint( - self.allocator, - "{s}-{s}-{s}", - .{ @tagName(self.method), self.path, @tagName(self.requestFormat()) }, - ); -} - pub fn fmtMethod(self: *const Request, colorized: bool) []const u8 { if (!colorized) return @tagName(self.method); @@ -508,6 +526,17 @@ pub fn setResponse( }; } +fn setCookieHeaders(self: *Request) !void { + const local_cookies = self._cookies orelse return; + if (!local_cookies.modified) return; + + var cookie_it = local_cookies.headerIterator(); + while (try cookie_it.next()) |header| { + // FIXME: Skip setting cookies that are already present ? + try self.response.headers.append("Set-Cookie", header); + } +} + // Determine if a given route matches the current request. pub fn match(self: *Request, route: jetzig.views.Route) !bool { return switch (self.method) { diff --git a/src/jetzig/http/Response.zig b/src/jetzig/http/Response.zig index 3f2bb0b..c848e0a 100644 --- a/src/jetzig/http/Response.zig +++ b/src/jetzig/http/Response.zig @@ -1,27 +1,28 @@ const std = @import("std"); + +const httpz = @import("httpz"); + const jetzig = @import("../../jetzig.zig"); const http = @import("../http.zig"); const Self = @This(); allocator: std.mem.Allocator, -headers: *jetzig.http.Headers, +headers: jetzig.http.Headers, content: []const u8, status_code: http.status_codes.StatusCode, content_type: []const u8, pub fn init( allocator: std.mem.Allocator, + httpz_response: *httpz.Response, ) !Self { - const headers = try allocator.create(jetzig.http.Headers); - headers.* = jetzig.http.Headers.init(allocator); - return .{ .allocator = allocator, .status_code = .no_content, .content_type = "application/octet-stream", .content = "", - .headers = headers, + .headers = jetzig.http.Headers.init(allocator, &httpz_response.headers), }; } diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index 9434bd6..879ee0b 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -1,8 +1,10 @@ const std = @import("std"); +const builtin = @import("builtin"); const jetzig = @import("../../jetzig.zig"); const zmpl = @import("zmpl"); const zmd = @import("zmd"); +const httpz = @import("httpz"); pub const ServerOptions = struct { logger: jetzig.loggers.Logger, @@ -11,6 +13,7 @@ pub const ServerOptions = struct { secret: []const u8, detach: bool, environment: jetzig.Environment.EnvironmentName, + log_queue: *jetzig.loggers.LogQueue, }; allocator: std.mem.Allocator, @@ -59,64 +62,86 @@ pub fn deinit(self: *Server) void { self.allocator.free(self.options.bind); } -pub fn listen(self: *Server) !void { - const address = try std.net.Address.parseIp(self.options.bind, self.options.port); - self.std_net_server = try address.listen(.{ .reuse_port = true }); +const Dispatcher = struct { + server: *Server, - self.initialized = true; + pub fn handle(self: Dispatcher, request: *httpz.Request, response: *httpz.Response) void { + self.server.processNextRequest(request, response) catch |err| { + self.server.errorHandlerFn(request, response, err); + }; + } +}; + +pub fn listen(self: *Server) !void { + var httpz_server = try httpz.ServerCtx(Dispatcher, Dispatcher).init( + self.allocator, + .{ + .port = self.options.port, + .address = self.options.bind, + .thread_pool = .{ .count = @intCast(try std.Thread.getCpuCount()) }, + }, + Dispatcher{ .server = self }, + ); + defer httpz_server.deinit(); try self.logger.INFO("Listening on http://{s}:{} [{s}]", .{ self.options.bind, self.options.port, @tagName(self.options.environment), }); - try self.processRequests(); + + self.initialized = true; + + return try httpz_server.listen(); } -fn processRequests(self: *Server) !void { - // TODO: Keepalive - while (true) { - var arena = std.heap.ArenaAllocator.init(self.allocator); - errdefer arena.deinit(); - const allocator = arena.allocator(); +pub fn errorHandlerFn(self: *Server, request: *httpz.Request, response: *httpz.Response, err: anyerror) void { + if (isBadHttpError(err)) return; - const connection = try self.std_net_server.accept(); - - var buf: [jetzig.config.get(usize, "http_buffer_size")]u8 = undefined; - var std_http_server = std.http.Server.init(connection, &buf); - errdefer std_http_server.connection.stream.close(); - - self.processNextRequest(allocator, &std_http_server) catch |err| { - if (isBadHttpError(err)) { - std_http_server.connection.stream.close(); - continue; - } else return err; - }; - - std_http_server.connection.stream.close(); - arena.deinit(); - } + self.logger.ERROR("Encountered error: {s} {s}", .{ @errorName(err), request.url.raw }) catch {}; + response.body = "500 Internal Server Error"; } -fn processNextRequest(self: *Server, allocator: std.mem.Allocator, std_http_server: *std.http.Server) !void { +fn processNextRequest( + self: *Server, + httpz_request: *httpz.Request, + httpz_response: *httpz.Response, +) !void { const start_time = std.time.nanoTimestamp(); - const std_http_request = try std_http_server.receiveHead(); - if (std_http_server.state == .receiving_head) return error.JetzigParseHeadError; + const state = try self.allocator.create(jetzig.http.Request.CallbackState); + const arena = try self.allocator.create(std.heap.ArenaAllocator); + arena.* = std.heap.ArenaAllocator.init(self.allocator); + state.* = .{ + .arena = arena, + .allocator = self.allocator, + }; - var response = try jetzig.http.Response.init(allocator); - var request = try jetzig.http.Request.init(allocator, self, start_time, std_http_request, &response); + // Regular arena deinit occurs in jetzig.http.Request.responseCompletCallback + errdefer state.arena.deinit(); + + const allocator = state.arena.allocator(); + + var response = try jetzig.http.Response.init(allocator, httpz_response); + var request = try jetzig.http.Request.init( + allocator, + self, + start_time, + httpz_request, + httpz_response, + &response, + ); try request.process(); var middleware_data = try jetzig.http.middleware.afterRequest(&request); try self.renderResponse(&request); - try request.response.headers.append("content-type", response.content_type); + try request.response.headers.append("Content-Type", response.content_type); try jetzig.http.middleware.beforeResponse(&middleware_data, &request); - try request.respond(); + try request.respond(state); try jetzig.http.middleware.afterResponse(&middleware_data, &request); jetzig.http.middleware.deinit(&middleware_data, &request); @@ -168,7 +193,17 @@ fn renderHTML( }; return request.setResponse(rendered, .{}); } else { - return request.setResponse(try self.renderNotFound(request), .{}); + // Try rendering without a template to see if we get a redirect. + const rendered = self.renderView(matched_route, request, null) catch |err| { + if (isUnhandledError(err)) return err; + const rendered_error = try self.renderInternalServerError(request, err); + return request.setResponse(rendered_error, .{}); + }; + + return if (request.redirected) + request.setResponse(rendered, .{}) + else + request.setResponse(try self.renderNotFound(request), .{}); } } else { if (try self.renderMarkdown(request)) |rendered| { diff --git a/src/jetzig/http/Session.zig b/src/jetzig/http/Session.zig index 7adfbc7..0d0a36b 100644 --- a/src/jetzig/http/Session.zig +++ b/src/jetzig/http/Session.zig @@ -28,6 +28,7 @@ pub fn init( }; } +/// Parse session cookie. pub fn parse(self: *Self) !void { if (self.cookies.get(cookie_name)) |cookie| { try self.parseSessionCookie(cookie.value); @@ -36,6 +37,7 @@ pub fn parse(self: *Self) !void { } } +/// Reset session to an empty state. pub fn reset(self: *Self) !void { self.data.reset(); _ = try self.data.object(); @@ -43,12 +45,14 @@ pub fn reset(self: *Self) !void { try self.save(); } +/// Free allocated memory. pub fn deinit(self: *Self) void { if (self.state != .parsed) return; self.data.deinit(); } +/// Get a value from the session. pub fn get(self: *Self, key: []const u8) !?*jetzig.data.Value { if (self.state != .parsed) return error.UnparsedSessionCookie; @@ -58,6 +62,7 @@ pub fn get(self: *Self, key: []const u8) !?*jetzig.data.Value { }; } +/// Put a value into the session. pub fn put(self: *Self, key: []const u8, value: *jetzig.data.Value) !void { if (self.state != .parsed) return error.UnparsedSessionCookie; diff --git a/src/jetzig/http/status_codes.zig b/src/jetzig/http/status_codes.zig index 611cc08..2987a8d 100644 --- a/src/jetzig/http/status_codes.zig +++ b/src/jetzig/http/status_codes.zig @@ -178,6 +178,12 @@ pub const TaggedStatusCode = union(StatusCode) { }; } + pub fn getCodeInt(self: Self) !u16 { + return switch (self) { + inline else => |capture| try std.fmt.parseInt(u16, capture.code, 10), + }; + } + pub fn getMessage(self: Self) []const u8 { return switch (self) { inline else => |capture| capture.message, diff --git a/src/jetzig/loggers.zig b/src/jetzig/loggers.zig index 84b354a..6ce6135 100644 --- a/src/jetzig/loggers.zig +++ b/src/jetzig/loggers.zig @@ -6,10 +6,18 @@ const Self = @This(); pub const DevelopmentLogger = @import("loggers/DevelopmentLogger.zig"); pub const JsonLogger = @import("loggers/JsonLogger.zig"); +pub const LogQueue = @import("loggers/LogQueue.zig"); pub const LogLevel = enum(u4) { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }; pub const LogFormat = enum { development, json }; +/// Infer a log target (stdout or stderr) from a given log level. +pub inline fn logTarget(comptime level: LogLevel) LogQueue.Target { + return switch (level) { + .TRACE, .DEBUG, .INFO => .stdout, + .WARN, .ERROR, .FATAL => .stderr, + }; +} pub const Logger = union(enum) { development_logger: DevelopmentLogger, json_logger: JsonLogger, diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig index 3108065..2f9135f 100644 --- a/src/jetzig/loggers/DevelopmentLogger.zig +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -8,28 +8,23 @@ const Timestamp = jetzig.types.Timestamp; const LogLevel = jetzig.loggers.LogLevel; allocator: std.mem.Allocator, -stdout: std.fs.File, -stderr: std.fs.File, stdout_colorized: bool, stderr_colorized: bool, level: LogLevel, -mutex: std.Thread.Mutex, +log_queue: *jetzig.loggers.LogQueue, /// Initialize a new Development Logger. pub fn init( allocator: std.mem.Allocator, level: LogLevel, - stdout: std.fs.File, - stderr: std.fs.File, + log_queue: *jetzig.loggers.LogQueue, ) DevelopmentLogger { return .{ .allocator = allocator, .level = level, - .stdout = stdout, - .stderr = stderr, - .stdout_colorized = stdout.isTty(), - .stderr_colorized = stderr.isTty(), - .mutex = std.Thread.Mutex{}, + .log_queue = log_queue, + .stdout_colorized = log_queue.stdout_is_tty, + .stderr_colorized = log_queue.stderr_is_tty, }; } @@ -45,40 +40,28 @@ pub fn log( const output = try std.fmt.allocPrint(self.allocator, message, args); defer self.allocator.free(output); - const timestamp = Timestamp.init(std.time.timestamp(), self.allocator); - const iso8601 = try timestamp.iso8601(); - defer self.allocator.free(iso8601); + const timestamp = Timestamp.init(std.time.timestamp()); + var timestamp_buf: [256]u8 = undefined; + const iso8601 = try timestamp.iso8601(×tamp_buf); - const colorized = switch (level) { - .TRACE, .DEBUG, .INFO => self.stdout_colorized, - .WARN, .ERROR, .FATAL => self.stderr_colorized, - }; - const file = switch (level) { - .TRACE, .DEBUG, .INFO => self.stdout, - .WARN, .ERROR, .FATAL => self.stderr, - }; - const writer = file.writer(); - const level_formatted = if (colorized) colorizedLogLevel(level) else @tagName(level); + const target = jetzig.loggers.logTarget(level); + const formatted_level = colorizedLogLevel(level); - @constCast(self).mutex.lock(); - defer @constCast(self).mutex.unlock(); - - try writer.print("{s: >5} [{s}] {s}\n", .{ level_formatted, iso8601, output }); - - if (!file.isTty()) try file.sync(); + try self.log_queue.print( + "{s: >5} [{s}] {s}\n", + .{ formatted_level, iso8601, output }, + target, + ); } /// Log a one-liner including response status code, path, method, duration, etc. pub fn logRequest(self: DevelopmentLogger, request: *const jetzig.http.Request) !void { - const formatted_duration = if (self.stdout_colorized) - try jetzig.colors.duration(self.allocator, jetzig.util.duration(request.start_time)) - else - try std.fmt.allocPrint( - self.allocator, - "{}", - .{std.fmt.fmtDurationSigned(jetzig.util.duration(request.start_time))}, - ); - defer self.allocator.free(formatted_duration); + var duration_buf: [256]u8 = undefined; + const formatted_duration = try jetzig.colors.duration( + &duration_buf, + jetzig.util.duration(request.start_time), + self.stdout_colorized, + ); const status: jetzig.http.status_codes.TaggedStatusCode = switch (request.response.status_code) { inline else => |status_code| @unionInit( @@ -93,17 +76,23 @@ pub fn logRequest(self: DevelopmentLogger, request: *const jetzig.http.Request) else status.getFormatted(.{}); - const message = try std.fmt.allocPrint(self.allocator, "[{s}/{s}/{s}] {s}", .{ + const timestamp = Timestamp.init(std.time.timestamp()); + var timestamp_buf: [256]u8 = undefined; + const iso8601 = try timestamp.iso8601(×tamp_buf); + + const formatted_level = if (self.stdout_colorized) colorizedLogLevel(.INFO) else @tagName(.INFO); + + try self.log_queue.print("{s: >5} [{s}] [{s}/{s}/{s}] {s}\n", .{ + formatted_level, + iso8601, formatted_duration, request.fmtMethod(self.stdout_colorized), formatted_status, request.path.path, - }); - defer self.allocator.free(message); - try self.log(.INFO, "{s}", .{message}); + }, .stdout); } -fn colorizedLogLevel(comptime level: LogLevel) []const u8 { +inline fn colorizedLogLevel(comptime level: LogLevel) []const u8 { return switch (level) { .TRACE => jetzig.colors.white(@tagName(level)), .DEBUG => jetzig.colors.cyan(@tagName(level)), diff --git a/src/jetzig/loggers/JsonLogger.zig b/src/jetzig/loggers/JsonLogger.zig index 0399a77..5beb3e3 100644 --- a/src/jetzig/loggers/JsonLogger.zig +++ b/src/jetzig/loggers/JsonLogger.zig @@ -11,6 +11,7 @@ const LogMessage = struct { timestamp: []const u8, message: []const u8, }; + const RequestLogMessage = struct { level: []const u8, timestamp: []const u8, @@ -21,24 +22,19 @@ const RequestLogMessage = struct { }; allocator: std.mem.Allocator, -stdout: std.fs.File, -stderr: std.fs.File, +log_queue: *jetzig.loggers.LogQueue, level: LogLevel, -mutex: std.Thread.Mutex, /// Initialize a new JSON Logger. pub fn init( allocator: std.mem.Allocator, level: LogLevel, - stdout: std.fs.File, - stderr: std.fs.File, + log_queue: *jetzig.loggers.LogQueue, ) JsonLogger { return .{ .allocator = allocator, .level = level, - .stdout = stdout, - .stderr = stderr, - .mutex = std.Thread.Mutex{}, + .log_queue = log_queue, }; } @@ -54,24 +50,16 @@ pub fn log( const output = try std.fmt.allocPrint(self.allocator, message, args); defer self.allocator.free(output); - const timestamp = Timestamp.init(std.time.timestamp(), self.allocator); - const iso8601 = try timestamp.iso8601(); - defer self.allocator.free(iso8601); + const timestamp = Timestamp.init(std.time.timestamp()); + var timestamp_buf: [256]u8 = undefined; + const iso8601 = try timestamp.iso8601(×tamp_buf); - const file = self.getFile(level); - const writer = file.writer(); const log_message = LogMessage{ .level = @tagName(level), .timestamp = iso8601, .message = output }; const json = try std.json.stringifyAlloc(self.allocator, log_message, .{ .whitespace = .minified }); defer self.allocator.free(json); - @constCast(self).mutex.lock(); - defer @constCast(self).mutex.unlock(); - - try writer.writeAll(json); - try writer.writeByte('\n'); - - if (!file.isTty()) try file.sync(); // Make configurable ? + try self.log_queue.print("{s}\n", .{json}, jetzig.loggers.logTarget(level)); } /// Log a one-liner including response status code, path, method, duration, etc. @@ -80,9 +68,9 @@ pub fn logRequest(self: *const JsonLogger, request: *const jetzig.http.Request) const duration = jetzig.util.duration(request.start_time); - const timestamp = Timestamp.init(std.time.timestamp(), self.allocator); - const iso8601 = try timestamp.iso8601(); - defer self.allocator.free(iso8601); + const timestamp = Timestamp.init(std.time.timestamp()); + var timestamp_buf: [256]u8 = undefined; + const iso8601 = try timestamp.iso8601(×tamp_buf); const status = switch (request.response.status_code) { inline else => |status_code| @unionInit( @@ -91,6 +79,7 @@ pub fn logRequest(self: *const JsonLogger, request: *const jetzig.http.Request) .{}, ), }; + const message = RequestLogMessage{ .level = @tagName(level), .timestamp = iso8601, @@ -99,19 +88,17 @@ pub fn logRequest(self: *const JsonLogger, request: *const jetzig.http.Request) .path = request.path.path, .duration = duration, }; - const json = try std.json.stringifyAlloc(self.allocator, message, .{ .whitespace = .minified }); - defer self.allocator.free(json); - const file = self.getFile(level); - const writer = file.writer(); + var buf: [4096]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + std.json.stringify(message, .{ .whitespace = .minified }, stream.writer()) catch |err| { + switch (err) { + error.NoSpaceLeft => {}, // TODO: Spill to heap + else => return err, + } + }; - @constCast(self).mutex.lock(); - defer @constCast(self).mutex.unlock(); - - try writer.writeAll(json); - try writer.writeByte('\n'); - - if (!file.isTty()) try file.sync(); // Make configurable ? + try self.log_queue.print("{s}\n", .{stream.getWritten()}, .stdout); } fn getFile(self: JsonLogger, level: LogLevel) std.fs.File { diff --git a/src/jetzig/loggers/LogQueue.zig b/src/jetzig/loggers/LogQueue.zig new file mode 100644 index 0000000..b49edd6 --- /dev/null +++ b/src/jetzig/loggers/LogQueue.zig @@ -0,0 +1,370 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const jetzig = @import("../../jetzig.zig"); + +const buffer_size = jetzig.config.get(usize, "log_message_buffer_len"); +const max_pool_len = jetzig.config.get(usize, "max_log_pool_len"); + +const List = std.DoublyLinkedList(Event); +const Buffer = [buffer_size]u8; + +allocator: std.mem.Allocator, +node_allocator: std.heap.MemoryPool(List.Node), +buffer_allocator: std.heap.MemoryPool(Buffer), +list: List, +read_write_mutex: std.Thread.Mutex, +condition: std.Thread.Condition, +condition_mutex: std.Thread.Mutex, +writer: Writer = undefined, +reader: Reader = undefined, +node_pool: std.ArrayList(*List.Node), +buffer_pool: std.ArrayList(*Buffer), +position: usize, +stdout_is_tty: bool = undefined, +stderr_is_tty: bool = undefined, +stdout_colorize: bool = undefined, +stderr_colorize: bool = undefined, +state: enum { pending, ready } = .pending, + +const LogQueue = @This(); + +pub const Target = enum { stdout, stderr }; + +const Event = struct { + message: *Buffer, + len: usize, + target: Target, + ptr: ?[]const u8, +}; + +/// Create a new `LogQueue`. +pub fn init(allocator: std.mem.Allocator) LogQueue { + return .{ + .allocator = allocator, + .node_allocator = initPool(allocator, List.Node), + .buffer_allocator = initPool(allocator, Buffer), + .list = List{}, + .condition = std.Thread.Condition{}, + .condition_mutex = std.Thread.Mutex{}, + .read_write_mutex = std.Thread.Mutex{}, + .node_pool = std.ArrayList(*List.Node).init(allocator), + .buffer_pool = std.ArrayList(*Buffer).init(allocator), + .position = 0, + }; +} + +/// Free allocated resources and return to `pending` state. +pub fn deinit(self: *LogQueue) void { + self.node_pool.deinit(); + self.buffer_pool.deinit(); + + self.buffer_allocator.deinit(); + self.node_allocator.deinit(); + + self.state = .pending; +} + +/// Set the stdout and stderr outputs. Must be called before `print`. +pub fn setFiles(self: *LogQueue, stdout_file: std.fs.File, stderr_file: std.fs.File) !void { + self.writer = Writer{ + .queue = self, + .mutex = std.Thread.Mutex{}, + }; + self.reader = Reader{ + .stdout_file = stdout_file, + .stderr_file = stderr_file, + .queue = self, + }; + self.stdout_is_tty = stdout_file.isTty(); + self.stderr_is_tty = stderr_file.isTty(); + + self.stdout_colorize = std.io.tty.detectConfig(stdout_file) != .no_color; + self.stderr_colorize = std.io.tty.detectConfig(stderr_file) != .no_color; + + self.state = .ready; +} + +pub fn print(self: *LogQueue, comptime message: []const u8, args: anytype, target: Target) !void { + std.debug.assert(self.state == .ready); + + try self.writer.print(message, args, target); +} + +/// Writer for `LogQueue`. Receives log events and publishes to the queue. +pub const Writer = struct { + queue: *LogQueue, + position: usize = 0, + mutex: std.Thread.Mutex, + + /// Print a log event. Messages longer than `jetzig.config.get(usize, "log_message_buffer_len")` + /// spill to heap with degraded performance. Adjust buffer length or limit long entries to + /// ensure fast logging performance. + /// `target` must be `.stdout` or `.stderr`. + pub fn print( + self: *Writer, + comptime message: []const u8, + args: anytype, + target: Target, + ) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + + const buf = try self.getBuffer(); + self.position += 1; + var ptr: ?[]const u8 = null; + + const result = std.fmt.bufPrint(buf, message, args) catch |err| switch (err) { + error.NoSpaceLeft => blk: { + ptr = try std.fmt.allocPrint(self.queue.allocator, message, args); + self.position -= 1; + break :blk null; + }, + }; + + try self.queue.append(.{ + .message = buf, + .target = target, + .len = if (ptr) |capture| capture.len else result.?.len, + .ptr = ptr, + }); + } + + fn getBuffer(self: *Writer) !*Buffer { + const buffer = if (self.position >= self.queue.buffer_pool.items.len) + try self.queue.buffer_allocator.create() + else + self.queue.buffer_pool.items[self.position]; + + return buffer; + } +}; + +/// Reader for `LogQueue`. Reads log events from the queue and writes them to the designated +/// target (stdout or stderr). +pub const Reader = struct { + stdout_file: std.fs.File, + stderr_file: std.fs.File, + queue: *LogQueue, + + /// Publish log events from the queue. Invoke from a dedicated thread. Sleeps when log queue + /// is empty, wakes up when a new event is published. + pub fn publish(self: *Reader, options: struct { oneshot: bool = false }) !void { + std.debug.assert(self.queue.state == .ready); + + const stdout_writer = self.stdout_file.writer(); + const stderr_writer = self.stderr_file.writer(); + + while (true) { + self.queue.condition_mutex.lock(); + defer self.queue.condition_mutex.unlock(); + + if (!options.oneshot) self.queue.condition.wait(&self.queue.condition_mutex); + + var stdout_written = false; + var stderr_written = false; + var file: std.fs.File = undefined; + var colorize = false; + + while (try self.queue.popFirst()) |event| { + self.queue.writer.mutex.lock(); + defer self.queue.writer.mutex.unlock(); + + const writer = switch (event.target) { + .stdout => blk: { + stdout_written = true; + if (builtin.os.tag == .windows) { + file = self.stdout_file; + colorize = self.queue.stdout_colorize; + } + break :blk stdout_writer; + }, + .stderr => blk: { + stderr_written = true; + if (builtin.os.tag == .windows) { + file = self.stderr_file; + colorize = self.queue.stderr_colorize; + } + break :blk stderr_writer; + }, + }; + + if (event.ptr) |ptr| { + // Log message spilled to heap + defer self.queue.allocator.free(ptr); + try writer.writeAll(ptr); + continue; + } + + if (builtin.os.tag == .windows and colorize) { + try writeWindows(file, writer, event); + } else { + try writer.writeAll(event.message[0..event.len]); + } + + self.queue.writer.position -= 1; + + if (self.queue.writer.position < self.queue.buffer_pool.items.len) { + self.queue.buffer_pool.items[self.queue.writer.position] = event.message; + } else { + if (self.queue.buffer_pool.items.len >= max_pool_len) { + self.queue.buffer_allocator.destroy(@alignCast(event.message)); + self.queue.writer.position += 1; + } else { + try self.queue.buffer_pool.append(event.message); + } + } + } + + if (stdout_written and !self.queue.stdout_is_tty) try self.stdout_file.sync(); + if (stderr_written and !self.queue.stderr_is_tty) try self.stderr_file.sync(); + + if (options.oneshot) break; + } + } +}; + +// Append a log event to the queue. Signal the publish loop thread to wake up. Recycle nodes if +// available in the pool, otherwise create a new one. +fn append(self: *LogQueue, event: Event) !void { + self.read_write_mutex.lock(); + defer self.read_write_mutex.unlock(); + + const node = if (self.position >= self.node_pool.items.len) + try self.node_allocator.create() + else + self.node_pool.items[self.position]; + + self.position += 1; + + node.* = .{ .data = event }; + self.list.append(node); + + self.condition.signal(); +} + +// Pop a log event from the queue. Return node to the pool for re-use. +fn popFirst(self: *LogQueue) !?Event { + self.read_write_mutex.lock(); + defer self.read_write_mutex.unlock(); + + if (self.list.popFirst()) |node| { + const value = node.data; + self.position -= 1; + if (self.position < self.node_pool.items.len) { + self.node_pool.items[self.position] = node; + } else { + if (self.node_pool.items.len >= max_pool_len) { + self.node_allocator.destroy(node); + self.position += 1; + } else { + try self.node_pool.append(node); + } + } + return value; + } else { + return null; + } +} + +fn initPool(allocator: std.mem.Allocator, T: type) std.heap.MemoryPool(T) { + return std.heap.MemoryPool(T).initPreheated(allocator, max_pool_len) catch @panic("OOM"); +} + +fn writeWindows(file: std.fs.File, writer: anytype, event: Event) !void { + var info: std.os.windows.CONSOLE_SCREEN_BUFFER_INFO = undefined; + const config: std.io.tty.Config = if (std.os.windows.kernel32.GetConsoleScreenBufferInfo( + file.handle, + &info, + ) != std.os.windows.TRUE) + .no_color + else + .{ .windows_api = .{ + .handle = file.handle, + .reset_attributes = info.wAttributes, + } }; + + var it = std.mem.tokenizeSequence(u8, event.message[0..event.len], "\x1b["); + while (it.next()) |token| { + if (std.mem.indexOfScalar(u8, token, 'm')) |index| { + if (index > 0 and index + 1 < token.len) { + if (jetzig.colors.codes_map.get(token[0..index])) |color| { + try config.setColor(writer, color); + try writer.writeAll(token[index + 1 ..]); + continue; + } + } + } + // Fallback + try writer.writeAll(token); + } +} + +test "print to stdout and stderr" { + var log_queue = LogQueue.init(std.testing.allocator); + defer log_queue.deinit(); + + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const stdout = try tmp_dir.dir.createFile("stdout.log", .{ .read = true }); + defer stdout.close(); + + const stderr = try tmp_dir.dir.createFile("stderr.log", .{ .read = true }); + defer stderr.close(); + + try log_queue.setFiles(stdout, stderr); + try log_queue.print("foo {s}\n", .{"bar"}, .stdout); + try log_queue.print("baz {s}\n", .{"qux"}, .stderr); + try log_queue.print("quux {s}\n", .{"corge"}, .stdout); + try log_queue.print("grault {s}\n", .{"garply"}, .stderr); + try log_queue.print("waldo {s}\n", .{"fred"}, .stderr); + try log_queue.print("plugh {s}\n", .{"zyzzy"}, .stdout); + + try log_queue.reader.publish(.{ .oneshot = true }); + + try stdout.seekTo(0); + var buf: [1024]u8 = undefined; + var len = try stdout.readAll(&buf); + + try std.testing.expectEqualStrings( + \\foo bar + \\quux corge + \\plugh zyzzy + \\ + , buf[0..len]); + + try stderr.seekTo(0); + len = try stderr.readAll(&buf); + try std.testing.expectEqualStrings( + \\baz qux + \\grault garply + \\waldo fred + \\ + , buf[0..len]); +} + +test "long messages" { + var log_queue = LogQueue.init(std.testing.allocator); + defer log_queue.deinit(); + + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const stdout = try tmp_dir.dir.createFile("stdout.log", .{ .read = true }); + defer stdout.close(); + + const stderr = try tmp_dir.dir.createFile("stderr.log", .{ .read = true }); + defer stderr.close(); + + try log_queue.setFiles(stdout, stderr); + try log_queue.print("foo" ** buffer_size, .{}, .stdout); + + try log_queue.reader.publish(.{ .oneshot = true }); + + try stdout.seekTo(0); + var buf: [buffer_size * 3]u8 = undefined; + const len = try stdout.readAll(&buf); + + try std.testing.expectEqualStrings("foo" ** buffer_size, buf[0..len]); +} diff --git a/src/jetzig/middleware/HtmxMiddleware.zig b/src/jetzig/middleware/HtmxMiddleware.zig index ebedcfe..8c56431 100644 --- a/src/jetzig/middleware/HtmxMiddleware.zig +++ b/src/jetzig/middleware/HtmxMiddleware.zig @@ -15,7 +15,7 @@ pub fn init(request: *jetzig.http.Request) !*Self { /// content rendered directly by the view function. pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { _ = self; - if (request.getHeader("HX-Target")) |target| { + if (request.headers.get("HX-Target")) |target| { try request.server.logger.DEBUG( "[middleware-htmx] htmx request detected, disabling layout. (#{s})", .{target}, @@ -29,10 +29,9 @@ pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { _ = self; if (response.status_code != .moved_permanently and response.status_code != .found) return; - if (request.headers.getFirstValue("HX-Request") == null) return; + if (request.headers.get("HX-Request") == null) return; - if (response.headers.getFirstValue("Location")) |location| { - response.headers.remove("Location"); + if (response.headers.get("Location")) |location| { response.status_code = .ok; request.response_data.reset(); try response.headers.append("HX-Redirect", location); diff --git a/src/jetzig/types/Timestamp.zig b/src/jetzig/types/Timestamp.zig index 58c7720..cce387f 100644 --- a/src/jetzig/types/Timestamp.zig +++ b/src/jetzig/types/Timestamp.zig @@ -3,7 +3,6 @@ const std = @import("std"); const Self = @This(); timestamp: i64, -allocator: std.mem.Allocator, const constants = struct { pub const seconds_in_day: i64 = 60 * 60 * 24; @@ -12,18 +11,18 @@ const constants = struct { pub const epoch_year: i64 = 1970; }; -pub fn init(timestamp: i64, allocator: std.mem.Allocator) Self { - return .{ .allocator = allocator, .timestamp = timestamp }; +pub fn init(timestamp: i64) Self { + return .{ .timestamp = timestamp }; } -pub fn iso8601(self: *const Self) ![]const u8 { +pub fn iso8601(self: *const Self, buf: *[256]u8) ![]const u8 { const u32_year: u32 = @intCast(self.year()); const u32_month: u32 = @intCast(self.month()); const u32_day_of_month: u32 = @intCast(self.dayOfMonth()); const u32_hour: u32 = @intCast(self.hour()); const u32_minute: u32 = @intCast(self.minute()); const u32_second: u32 = @intCast(self.second()); - return try std.fmt.allocPrint(self.allocator, "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}", .{ + return try std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}", .{ u32_year, u32_month, u32_day_of_month, diff --git a/src/tests.zig b/src/tests.zig index fbc9ce4..544934c 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -6,4 +6,5 @@ test { _ = @import("jetzig/http/Path.zig"); _ = @import("jetzig/jobs/Job.zig"); _ = @import("jetzig/mail/Mail.zig"); + _ = @import("jetzig/loggers/LogQueue.zig"); }