diff --git a/.gitignore b/.gitignore index 638a023..9284511 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ zig-out/ zig-cache/ *.core -static/ .jetzig .zig-cache/ diff --git a/build.zig b/build.zig index 282c6ea..d174ef4 100644 --- a/build.zig +++ b/build.zig @@ -201,11 +201,14 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn exe_static_routes.root_module.addImport("routes", routes_module); exe_static_routes.root_module.addImport("jetzig", jetzig_module); exe_static_routes.root_module.addImport("zmpl", zmpl_module); - exe_static_routes.root_module.addImport("jetzig_app", &exe.root_module); + // exe_static_routes.root_module.addImport("jetzig_app", &exe.root_module); const run_static_routes_cmd = b.addRunArtifact(exe_static_routes); + const static_outputs_path = run_static_routes_cmd.addOutputFileArg("static.zig"); + const static_module = b.createModule(.{ .root_source_file = static_outputs_path }); + exe.root_module.addImport("static", static_module); + run_static_routes_cmd.expectExitCode(0); - exe.step.dependOn(&run_static_routes_cmd.step); const exe_unit_tests = b.addTest(.{ .root_source_file = tests_file, @@ -214,10 +217,13 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn .test_runner = jetzig_dep.path("src/test_runner.zig"), }); exe_unit_tests.root_module.addImport("jetzig", jetzig_module); + exe_unit_tests.root_module.addImport("static", static_module); exe_unit_tests.root_module.addImport("__jetzig_project", &exe.root_module); var it = exe.root_module.import_table.iterator(); while (it.next()) |import| { + if (std.mem.eql(u8, import.key_ptr.*, "static")) continue; + routes_module.addImport(import.key_ptr.*, import.value_ptr.*); exe_static_routes.root_module.addImport(import.key_ptr.*, import.value_ptr.*); exe_unit_tests.root_module.addImport(import.key_ptr.*, import.value_ptr.*); @@ -226,14 +232,14 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); const test_step = b.step("jetzig:test", "Run tests"); - test_step.dependOn(&run_exe_unit_tests.step); test_step.dependOn(&run_static_routes_cmd.step); + test_step.dependOn(&run_exe_unit_tests.step); exe_unit_tests.root_module.addImport("routes", routes_module); const routes_step = b.step("jetzig:routes", "List all routes in your app"); const exe_routes = b.addExecutable(.{ .name = "routes", - .root_source_file = jetzig_dep.path("src/routes.zig"), + .root_source_file = jetzig_dep.path("src/routes_exe.zig"), .target = target, .optimize = optimize, }); diff --git a/build.zig.zon b/build.zig.zon index 3d74cb5..4cb7c84 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,8 +7,8 @@ .hash = "12203b56c2e17a2fd62ea3d3d9be466f43921a3aef88b381cf58f41251815205fdb5", }, .zmpl = .{ - .url = "https://github.com/jetzig-framework/zmpl/archive/7dac63a9470cf12a2cbaf8e6e3fc3aeb9ea27658.tar.gz", - .hash = "1220215354adcea94d36d25c300e291981d3f48c543f7da6e48bb2793a5aed85e768", + .url = "https://github.com/jetzig-framework/zmpl/archive/676969d44fe1c3adabd73af983f33aefd8aed1b9.tar.gz", + .hash = "1220a6cce7678578e0176796c19d3810cdd3a9c1b0792a3731a8cd25d2aee96bd78a", }, .jetkv = .{ .url = "https://github.com/jetzig-framework/jetkv/archive/78bcdcc6b0cbd3ca808685c64554a15701f13250.tar.gz", diff --git a/demo/src/app/views/root.zig b/demo/src/app/views/root.zig index be8af82..f868587 100644 --- a/demo/src/app/views/root.zig +++ b/demo/src/app/views/root.zig @@ -7,9 +7,9 @@ pub const layout = "application"; pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { var root = try data.object(); - try root.put("message", data.string("Welcome to Jetzig!")); - try root.put("custom_number", data.integer(customFunction(100, 200, 300))); - try root.put("imported_number", data.integer(importedFunction(100, 200, 300))); + try root.put("message", "Welcome to Jetzig!"); + try root.put("custom_number", customFunction(100, 200, 300)); + try root.put("imported_number", importedFunction(100, 200, 300)); try request.response.headers.append("x-example-header", "example header value"); diff --git a/demo/src/app/views/static.zig b/demo/src/app/views/static.zig index fa29d85..c2b2ec7 100644 --- a/demo/src/app/views/static.zig +++ b/demo/src/app/views/static.zig @@ -37,8 +37,8 @@ pub const static_params = .{ .{ .params = .{ .foo = "hello", .bar = "goodbye" } }, }, .get = .{ - .{ .id = "1", .params = .{ .foo = "hi", .bar = "bye" } }, - .{ .id = "2", .params = .{ .foo = "hello", .bar = "goodbye" } }, + .{ .id = "123", .params = .{ .foo = "hi", .bar = "bye" } }, + .{ .id = "456", .params = .{ .foo = "hello", .bar = "goodbye" } }, }, }; @@ -58,8 +58,10 @@ pub fn get(id: []const u8, request: *jetzig.StaticRequest, data: *jetzig.Data) ! const params = try request.params(); - if (std.mem.eql(u8, id, "1")) { - try root.put("id", data.string("id is '1'")); + if (std.mem.eql(u8, id, "123")) { + try root.put("message", "id is '123'"); + } else { + try root.put("message", "id is not '123'"); } if (params.get("foo")) |foo| try root.put("foo", foo); @@ -89,12 +91,12 @@ test "get json" { const response = try app.request( .GET, - "/static/1.json", + "/static/123.json", .{ .json = .{ .foo = "hi", .bar = "bye" } }, ); try response.expectStatus(.ok); - try response.expectJson(".id", "id is '1'"); + try response.expectJson(".message", "id is '123'"); } test "index html" { @@ -108,7 +110,8 @@ test "index html" { ); try response.expectStatus(.ok); - try response.expectBodyContains("hello"); + try response.expectBodyContains("foo: hello"); + try response.expectBodyContains("bar: goodbye"); } test "get html" { @@ -117,7 +120,7 @@ test "get html" { const response = try app.request( .GET, - "/static/1.html", + "/static/123.html", .{ .params = .{ .foo = "hi", .bar = "bye" } }, ); diff --git a/demo/src/app/views/static/get.zmpl b/demo/src/app/views/static/get.zmpl index e977e38..7fa60eb 100644 --- a/demo/src/app/views/static/get.zmpl +++ b/demo/src/app/views/static/get.zmpl @@ -1,7 +1,7 @@ -
You made a request to /static/{.id} with:
-
foo: {.foo}
-
bar: {.bar}
+
{{.message}}
+
foo: {{.foo}}
+
bar: {{.bar}}
diff --git a/demo/src/main.zig b/demo/src/main.zig index 0d5fe94..d835b8c 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -5,6 +5,7 @@ const jetzig = @import("jetzig"); const zmd = @import("zmd"); pub const routes = @import("routes"); +pub const static = @import("static"); // Override default settings in `jetzig.config` here: pub const jetzig_options = struct { diff --git a/src/Routes.zig b/src/Routes.zig index 963f25c..eb76b4a 100644 --- a/src/Routes.zig +++ b/src/Routes.zig @@ -267,6 +267,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function) const output_template = \\ .{{ + \\ .id = "{9s}", \\ .name = "{0s}", \\ .action = .{1s}, \\ .view_name = "{2s}", @@ -296,6 +297,8 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function) std.mem.replaceScalar(u8, module_path, '\\', '/'); try self.module_paths.append(try self.allocator.dupe(u8, module_path)); + var buf: [32]u8 = undefined; + const id = jetzig.util.generateVariableName(&buf); const output = try std.fmt.allocPrint(self.allocator, output_template, .{ full_name, route.name, @@ -306,6 +309,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function) template, module_path, try std.mem.join(self.allocator, ", \n", route.params.items), + id, }); defer self.allocator.free(output); diff --git a/src/compile_static_routes.zig b/src/compile_static_routes.zig index 70197c0..da9bcec 100644 --- a/src/compile_static_routes.zig +++ b/src/compile_static_routes.zig @@ -2,7 +2,7 @@ const std = @import("std"); const jetzig = @import("jetzig"); const routes = @import("routes").routes; const zmpl = @import("zmpl"); -const jetzig_options = @import("jetzig_app").jetzig_options; +// const jetzig_options = @import("jetzig_app").jetzig_options; pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; @@ -13,14 +13,27 @@ pub fn main() !void { const allocator = arena.allocator(); defer arena.deinit(); - try compileStaticRoutes(allocator); + var it = try std.process.argsWithAllocator(allocator); + var index: usize = 0; + while (it.next()) |arg| : (index += 1) { + if (index == 0) continue; + const file = try std.fs.createFileAbsolute(arg, .{}); + const writer = file.writer(); + try compileStaticRoutes(allocator, writer); + file.close(); + break; + } } -fn compileStaticRoutes(allocator: std.mem.Allocator) !void { - std.fs.cwd().deleteTree("static") catch {}; - +fn compileStaticRoutes(allocator: std.mem.Allocator, writer: anytype) !void { var count: usize = 0; + try writer.writeAll( + \\const StaticOutput = struct { json: ?[]const u8 = null, html: ?[]const u8 = null, params: ?[]const u8 }; + \\const Compiled = struct { route_id: []const u8, output: StaticOutput }; + \\pub const compiled = [_]Compiled{ + \\ + ); for (routes) |route| { if (!route.static) continue; @@ -28,7 +41,7 @@ fn compileStaticRoutes(allocator: std.mem.Allocator) !void { for (route.json_params, 0..) |json, index| { var request = try jetzig.http.StaticRequest.init(allocator, json); defer request.deinit(); - try writeContent(allocator, route, &request, index, &count); + try writeContent(allocator, writer, route, &request, index, &count, json); } } @@ -38,20 +51,27 @@ fn compileStaticRoutes(allocator: std.mem.Allocator) !void { .index, .post => { var request = try jetzig.http.StaticRequest.init(allocator, "{}"); defer request.deinit(); - try writeContent(allocator, route, &request, null, &count); + try writeContent(allocator, writer, route, &request, null, &count, null); }, inline else => {}, } } + + try writer.writeAll( + \\}; + \\ + ); std.debug.print("[jetzig] Compiled {} static output(s)\n", .{count}); } fn writeContent( allocator: std.mem.Allocator, + writer: anytype, route: jetzig.views.Route, request: *jetzig.http.StaticRequest, index: ?usize, count: *usize, + params_json: ?[]const u8, ) !void { const index_suffix = if (index) |capture| try std.fmt.allocPrint(allocator, "_{}", .{capture}) @@ -62,35 +82,28 @@ fn writeContent( const view = try route.renderStatic(route, request); defer view.deinit(); - var dir = try std.fs.cwd().makeOpenPath("static", .{}); - defer dir.close(); - - const json_path = try std.mem.concat( - allocator, - u8, - &[_][]const u8{ route.name, index_suffix, ".json" }, - ); - defer allocator.free(json_path); - - const json_file = try dir.createFile(json_path, .{ .truncate = true }); - try json_file.writeAll(try view.data.toJson()); - defer json_file.close(); - count.* += 1; const html_content = try renderZmplTemplate(allocator, route, view) orelse try renderMarkdown(allocator, route, view) orelse null; - const html_path = try std.mem.concat( - allocator, - u8, - &[_][]const u8{ route.name, index_suffix, ".html" }, + + try writer.print( + \\.{{ .route_id = "{s}", .output = StaticOutput{{ .json = "{s}", .html = "{s}", .params = {s}{s}{s} }} }}, + \\ + \\ + , + .{ + route.id, + try zigEscape(allocator, try view.data.toJson()), + try zigEscape(allocator, html_content orelse ""), + if (params_json) |_| "\"" else "", + if (params_json) |params| try zigEscape(allocator, params) else "null", + if (params_json) |_| "\"" else "", + }, ); + if (html_content) |content| { - defer allocator.free(html_path); - const html_file = try dir.createFile(html_path, .{ .truncate = true }); - try html_file.writeAll(content); - defer html_file.close(); allocator.free(content); count.* += 1; } @@ -101,10 +114,7 @@ fn renderMarkdown( route: jetzig.views.Route, view: jetzig.views.View, ) !?[]const u8 { - const fragments = if (@hasDecl(jetzig_options, "markdown_fragments")) - jetzig_options.markdown_fragments - else - null; + const fragments = null; const path = try std.mem.join(allocator, "/", &[_][]const u8{ route.uri_path, @tagName(route.action) }); defer allocator.free(path); const content = try jetzig.markdown.render(allocator, path, fragments) orelse return null; @@ -153,3 +163,10 @@ fn renderZmplTemplate( } } else return null; } + +fn zigEscape(allocator: std.mem.Allocator, content: []const u8) ![]const u8 { + var buf = std.ArrayList(u8).init(allocator); + const writer = buf.writer(); + try std.zig.stringEscape(content, "", .{}, writer); + return try buf.toOwnedSlice(); +} diff --git a/src/jetzig.zig b/src/jetzig.zig index 70ab899..75cc24d 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -64,7 +64,7 @@ pub const MailerDefinition = mail.MailerDefinition; /// `ERROR`, etc.). Note that all log functions are CAPITALIZED. pub const Logger = loggers.Logger; -const root = @import("root"); +pub const root = @import("root"); /// Global configuration. Override these values by defining in `src/main.zig` with: /// ```zig diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index a71bb3a..89ac3dc 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -159,6 +159,7 @@ pub fn route( std.mem.replaceScalar(u8, &view_name, '.', '/'); self.custom_routes.append(.{ + .id = "custom", .name = member, .action = .custom, .method = method, @@ -196,6 +197,7 @@ pub fn createRoutes( for (comptime_routes) |const_route| { var var_route = try allocator.create(jetzig.views.Route); var_route.* = .{ + .id = const_route.id, .name = const_route.name, .action = const_route.action, .view_name = const_route.view_name, diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index c341dc1..3d437fb 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -29,6 +29,7 @@ initialized: bool = false, store: *jetzig.kv.Store, job_queue: *jetzig.kv.Store, cache: *jetzig.kv.Store, +decoded_static_route_params: []*jetzig.data.Value = &.{}, const Server = @This(); @@ -76,6 +77,8 @@ const Dispatcher = struct { }; pub fn listen(self: *Server) !void { + try self.decodeStaticParams(); + var httpz_server = try httpz.ServerCtx(Dispatcher, Dispatcher).init( self.allocator, .{ @@ -251,7 +254,7 @@ fn renderJSON( if (data.value) |_| {} else _ = try data.object(); rendered.content = if (self.options.environment == .development) - try data.toPrettyJson() + try data.toJsonOptions(.{ .pretty = true, .color = false }) else try data.toJson(); @@ -618,72 +621,72 @@ fn matchPublicContent(self: *Server, request: *jetzig.http.Request) !?StaticReso } fn matchStaticContent(self: *Server, request: *jetzig.http.Request) !?[]const u8 { - var static_dir = std.fs.cwd().openDir("static", .{}) catch |err| { - switch (err) { - error.FileNotFound => return null, - else => return err, - } - }; - defer static_dir.close(); - + const request_format = request.requestFormat(); const matched_route = try self.matchRoute(request, true); + const params = try request.params(); if (matched_route) |route| { - const static_path = try staticPath(request, route); + if (@hasDecl(jetzig.root, "static")) { + inline for (jetzig.root.static.compiled, 0..) |static_output, index| { + if (!@hasField(@TypeOf(static_output), "route_id")) continue; - if (static_path) |capture| { - return static_dir.readFileAlloc( - request.allocator, - capture, - jetzig.config.get(usize, "max_bytes_static_content"), - ) catch |err| { - switch (err) { - error.FileNotFound => return null, - else => return err, + if (std.mem.eql(u8, static_output.route_id, route.id)) { + if (index < self.decoded_static_route_params.len) { + if (matchStaticOutput( + self.decoded_static_route_params[index].getT(.string, "id"), + self.decoded_static_route_params[index].get("params"), + route, + request, + params, + )) return switch (request_format) { + .HTML, .UNKNOWN => static_output.output.html, + .JSON => static_output.output.json, + }; + } else { + return switch (request_format) { + .HTML, .UNKNOWN => static_output.output.html, + .JSON => static_output.output.json, + }; + } } - }; - } else return null; + } + } else { + return null; + } } return null; } -fn staticPath(request: *jetzig.http.Request, route: jetzig.views.Route) !?[]const u8 { - const params = try request.params(); - defer params.deinit(); - - const extension = switch (request.requestFormat()) { - .HTML, .UNKNOWN => ".html", - .JSON => ".json", - }; - - for (route.params.items, 0..) |static_params, index| { - const expected_params = static_params.get("params"); - switch (route.action) { - .index, .post => {}, - inline else => { - const id = static_params.getT(.string, "id") orelse return error.JetzigRouteError; - if (!std.mem.eql(u8, id, request.path.resource_id)) continue; - }, +pub fn decodeStaticParams(self: *Server) !void { + // Store decoded static params (i.e. declared in views) for faster comparison at request time. + var decoded = std.ArrayList(*jetzig.data.Value).init(self.allocator); + for (jetzig.root.static.compiled) |compiled| { + if (compiled.output.params) |params| { + const data = try self.allocator.create(jetzig.data.Data); + data.* = jetzig.data.Data.init(self.allocator); + try data.fromJson(params); + try decoded.append(data.value.?); } - if (expected_params != null and !expected_params.?.eql(params)) continue; - - const index_fmt = try std.fmt.allocPrint(request.allocator, "{}", .{index}); - defer request.allocator.free(index_fmt); - - return try std.mem.concat( - request.allocator, - u8, - &[_][]const u8{ route.name, "_", index_fmt, extension }, - ); } - switch (route.action) { - .index, .post => return try std.mem.concat( - request.allocator, - u8, - &[_][]const u8{ route.name, extension }, - ), - else => return null, - } + self.decoded_static_route_params = try decoded.toOwnedSlice(); +} + +fn matchStaticOutput( + maybe_id: ?[]const u8, + maybe_params: ?*jetzig.data.Value, + route: jetzig.views.Route, + request: *const jetzig.http.Request, + params: *jetzig.data.Value, +) bool { + return if (maybe_params) |expected_params| blk: { + break :blk switch (route.action) { + .index, .post => expected_params.count() == 0 or expected_params.eql(params), + inline else => if (maybe_id) |id| + std.mem.eql(u8, id, request.path.resource_id) and expected_params.eql(params) + else + false, + }; + } else if (maybe_id) |id| std.mem.eql(u8, id, request.path.resource_id) else maybe_params == null; } diff --git a/src/jetzig/testing.zig b/src/jetzig/testing.zig index ff6482d..d1c2459 100644 --- a/src/jetzig/testing.zig +++ b/src/jetzig/testing.zig @@ -12,11 +12,52 @@ const testing = @This(); /// Pre-built mime map, assigned by Jetzig test runner. pub var mime_map: *jetzig.http.mime.MimeMap = undefined; pub var state: enum { initial, ready } = .initial; +pub var logger: Logger = undefined; pub const secret = "secret-bytes-for-use-in-test-environment-only"; pub const app = App.init; +pub const Logger = struct { + allocator: std.mem.Allocator, + logs: std.AutoHashMap(usize, *LogCollection), + index: usize = 0, + + pub const LogEvent = struct { + level: std.log.Level, + output: []const u8, + }; + + const LogCollection = std.ArrayList(LogEvent); + + pub fn init(allocator: std.mem.Allocator) Logger { + return .{ + .allocator = allocator, + .logs = std.AutoHashMap(usize, *LogCollection).init(allocator), + }; + } + + pub fn log( + self: *Logger, + comptime message_level: std.log.Level, + comptime scope: @Type(.EnumLiteral), + comptime format: []const u8, + args: anytype, + ) void { + _ = scope; + const output = std.fmt.allocPrint(self.allocator, format, args) catch @panic("OOM"); + const log_event: LogEvent = .{ .level = message_level, .output = output }; + if (self.logs.get(self.index)) |*item| { + item.*.append(log_event) catch @panic("OOM"); + } else { + const array = self.allocator.create(LogCollection) catch @panic("OOM"); + array.* = LogCollection.init(self.allocator); + array.append(log_event) catch @panic("OOM"); + self.logs.put(self.index, array) catch @panic("OOM"); + } + } +}; + pub const TestResponse = struct { allocator: std.mem.Allocator, status: u16, @@ -56,15 +97,22 @@ pub fn expectStatus(comptime expected: jetzig.http.status_codes.StatusCode, resp const expected_code = try jetzig.http.status_codes.get(expected).getCodeInt(); if (response.status != expected_code) { - log("Expected status: `{}`, actual status: `{}`", .{ expected_code, response.status }); + logFailure( + "Expected status: " ++ jetzig.colors.green("{}") ++ ", actual status: " ++ jetzig.colors.red("{}"), + .{ expected_code, response.status }, + ); return error.JetzigExpectStatusError; } } pub fn expectBodyContains(expected: []const u8, response: TestResponse) !void { if (!std.mem.containsAtLeast(u8, response.body, 1, expected)) { - log( - "Expected content:\n========\n{s}\n========\n\nActual content:\n========\n{s}\n========", + logFailure( + "\nExpected content:\n" ++ + jetzig.colors.red("{s}") ++ + "\n\nActual content:\n" ++ + jetzig.colors.green("{s}") ++ + "\n", .{ expected, response.body }, ); return error.JetzigExpectBodyContainsError; @@ -94,14 +142,14 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: data.fromJson(response.body) catch |err| { switch (err) { error.SyntaxError => { - log("Expected JSON, encountered parser error.", .{}); + logFailure("Expected JSON, encountered parser error.", .{}); return error.JetzigExpectJsonError; }, else => return err, } }; - const json_banner = "\n======|json|======\n{s}\n======|/json|=====\n"; + const json_banner = "\n{s}"; if (try data.getValue(std.mem.trimLeft(u8, expected_path, &.{'.'}))) |value| { switch (value.*) { @@ -110,8 +158,8 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: if (std.mem.eql(u8, string.value, expected_value)) return; }, .Null => { - log( - "Expected null/non-existent value for `{s}`, found: `{s}`", + logFailure( + "Expected null/non-existent value for " ++ jetzig.colors.cyan("{s}") ++ ", found: " ++ jetzig.colors.cyan("{s}"), .{ expected_path, string.value }, ); return error.JetzigExpectJsonError; @@ -123,8 +171,8 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: if (integer.value == expected_value) return; }, .Null => { - log( - "Expected null/non-existent value for `{s}`, found: `{}`", + logFailure( + "Expected null/non-existent value for " ++ jetzig.colors.cyan("{s}") ++ ", found: " ++ jetzig.colors.green("{}"), .{ expected_path, integer.value }, ); return error.JetzigExpectJsonError; @@ -136,8 +184,8 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: if (float.value == expected_value) return; }, .Null => { - log( - "Expected null/non-existent value for `{s}`, found: `{}`", + logFailure( + "Expected null/non-existent value for " ++ jetzig.colors.cyan("{s}") ++ ", found: " ++ jetzig.colors.green("{}"), .{ expected_path, float.value }, ); return error.JetzigExpectJsonError; @@ -149,8 +197,8 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: if (boolean.value == expected_value) return; }, .Null => { - log( - "Expected null/non-existent value for `{s}`, found: `{}`", + logFailure( + "Expected null/non-existent value for " ++ jetzig.colors.cyan("{s}") ++ ", found: " ++ jetzig.colors.green("{}"), .{ expected_path, boolean.value }, ); return error.JetzigExpectJsonError; @@ -173,9 +221,9 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: .string => |string| { switch (@typeInfo(@TypeOf(expected_value))) { .Pointer, .Array => { - log( - "Expected `{s}` in `{s}`, found `{s}` in JSON:" ++ json_banner, - .{ expected_value, expected_path, string.value, response.body }, + logFailure( + "Expected \"" ++ jetzig.colors.red("{s}") ++ "\" in " ++ jetzig.colors.cyan("{s}") ++ ", found \"" ++ jetzig.colors.green("{s}") ++ "\nJSON:" ++ json_banner, + .{ expected_value, expected_path, string.value, try jsonPretty(response) }, ); }, else => unreachable, @@ -185,9 +233,10 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: => |integer| { switch (@typeInfo(@TypeOf(expected_value))) { .Int, .ComptimeInt => { - log( - "Expected `{}` in `{s}`, found `{}` in JSON:" ++ json_banner, - .{ expected_value, expected_path, integer.value, response.body }, + logFailure( + "Expected " ++ jetzig.colors.red("{}") ++ " in " ++ jetzig.colors.cyan("{s}") ++ ", found " ++ jetzig.colors.green("{}") ++ "\nJSON:" ++ json_banner, + + .{ expected_value, expected_path, integer.value, try jsonPretty(response) }, ); }, else => unreachable, @@ -196,9 +245,9 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: .float => |float| { switch (@typeInfo(@TypeOf(expected_value))) { .Float, .ComptimeFloat => { - log( - "Expected `{}` in `{s}`, found `{}` in JSON:" ++ json_banner, - .{ expected_value, expected_path, float.value, response.body }, + logFailure( + "Expected " ++ jetzig.colors.red("{}") ++ " in " ++ jetzig.colors.cyan("{s}") ++ ", found " ++ jetzig.colors.green("{}") ++ "\nJSON:" ++ json_banner, + .{ expected_value, expected_path, float.value, try jsonPretty(response) }, ); }, else => unreachable, @@ -207,26 +256,26 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: .boolean => |boolean| { switch (@typeInfo(@TypeOf(expected_value))) { .Bool => { - log( - "Expected `{}` in `{s}`, found `{}` in JSON:" ++ json_banner, - .{ expected_value, expected_path, boolean.value, response.body }, + logFailure( + "Expected " ++ jetzig.colors.red("{}") ++ " in " ++ jetzig.colors.cyan("{s}") ++ ", found " ++ jetzig.colors.green("{}") ++ "\nJSON:" ++ json_banner, + .{ expected_value, expected_path, boolean.value, try jsonPretty(response) }, ); }, else => unreachable, } }, .Null => { - log( - "Expected value in `{s}`, found `null` in JSON:" ++ json_banner, - .{ expected_path, response.body }, + logFailure( + "Expected value in " ++ jetzig.colors.cyan("{s}") ++ ", found " ++ jetzig.colors.green("null") ++ "\nJSON:" ++ json_banner, + .{ expected_path, try jsonPretty(response) }, ); }, else => unreachable, } } else { - log( - "Path not found: `{s}` in JSON: " ++ json_banner, - .{ expected_path, response.body }, + logFailure( + "Path not found: `{s}`\nJSON: " ++ json_banner, + .{ expected_path, try jsonPretty(response) }, ); } return error.JetzigExpectJsonError; @@ -244,6 +293,18 @@ pub fn expectJob(job_name: []const u8, job_params: anytype, response: TestRespon return error.JetzigExpectJobError; } -fn log(comptime message: []const u8, args: anytype) void { - std.debug.print("[jetzig.testing] " ++ message ++ "\n", args); +// fn log(comptime message: []const u8, args: anytype) void { +// std.log.info("[jetzig.testing] " ++ message ++ "\n", args); +// } + +fn logFailure(comptime message: []const u8, args: anytype) void { + std.log.err(message, args); +} + +fn jsonPretty(response: TestResponse) ![]const u8 { + var data = jetzig.data.Data.init(response.allocator); + defer data.deinit(); + + try data.fromJson(response.body); + return try data.toJsonOptions(.{ .pretty = true, .color = true }); } diff --git a/src/jetzig/testing/App.zig b/src/jetzig/testing/App.zig index 6acb53c..e6d8446 100644 --- a/src/jetzig/testing/App.zig +++ b/src/jetzig/testing/App.zig @@ -90,6 +90,8 @@ pub fn request( .job_queue = self.job_queue, }; + try server.decodeStaticParams(); + var buf: [1024]u8 = undefined; var httpz_request = try stubbedRequest(allocator, &buf, method, path, options); var httpz_response = try stubbedResponse(allocator); diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig index 94d1d52..b8d4efa 100644 --- a/src/jetzig/views/Route.zig +++ b/src/jetzig/views/Route.zig @@ -60,6 +60,7 @@ layout: ?[]const u8 = null, template: []const u8, json_params: []const []const u8, params: std.ArrayList(*jetzig.data.Data) = undefined, +id: []const u8, /// Initializes a route's static params on server launch. Converts static params (JSON strings) /// to `jetzig.data.Data` values. Memory is owned by caller (`App.start()`). diff --git a/src/routes.zig b/src/routes_exe.zig similarity index 100% rename from src/routes.zig rename to src/routes_exe.zig diff --git a/src/test_runner.zig b/src/test_runner.zig index 086762c..ddd3cf4 100644 --- a/src/test_runner.zig +++ b/src/test_runner.zig @@ -1,6 +1,20 @@ const std = @import("std"); const builtin = @import("builtin"); const jetzig = @import("jetzig"); +pub const static = @import("static"); + +pub const std_options = .{ + .logFn = log, +}; + +pub fn log( + comptime message_level: std.log.Level, + comptime scope: @Type(.EnumLiteral), + comptime format: []const u8, + args: anytype, +) void { + jetzig.testing.logger.log(message_level, scope, format, args); +} const Test = struct { name: []const u8, @@ -8,7 +22,7 @@ const Test = struct { module: ?[]const u8 = null, leaked: bool = false, result: Result = .success, - stack_trace_buf: [4096]u8 = undefined, + stack_trace_buf: [8192]u8 = undefined, duration: usize = 0, pub const TestFn = *const fn () anyerror!void; @@ -36,7 +50,7 @@ const Test = struct { .{ .function = test_fn.func, .name = test_fn.name }; } - pub fn run(self: *Test) !void { + pub fn run(self: *Test, allocator: std.mem.Allocator) !void { std.testing.allocator_instance = .{}; const start = std.time.nanoTimestamp(); @@ -45,7 +59,10 @@ const Test = struct { error.SkipZigTest => self.result = .skipped, else => self.result = .{ .failure = .{ .err = err, - .trace = try self.formatStackTrace(@errorReturnTrace()), + .trace = if (try self.formatStackTrace(@errorReturnTrace())) |trace| + try allocator.dupe(u8, trace) + else + null, } }, } }; @@ -68,13 +85,17 @@ const Test = struct { const writer = stream.writer(); switch (self.result) { - .success => try self.printPassed(writer), - .failure => |failure| try self.printFailure(failure, writer), + .success => { + try self.printPassed(writer); + if (self.leaked) try self.printLeaked(writer); + try self.printDuration(writer); + }, + .failure => |failure| { + try self.printFailure(failure, writer); + if (self.leaked) try self.printLeaked(writer); + }, .skipped => try self.printSkipped(writer), } - try self.printDuration(writer); - - if (self.leaked) try self.printLeaked(writer); try writer.writeByte('\n'); } @@ -91,9 +112,33 @@ const Test = struct { jetzig.colors.red("[FAIL] ") ++ name_template ++ jetzig.colors.yellow("({s})"), .{ self.module orelse "tests", self.name, @errorName(failure.err) }, ); + } + fn printFailureDetail(self: Test, index: usize, failure: Failure, writer: anytype) !void { + try writer.print("\n", .{}); + + const count = " FAILURE: ".len + (self.module orelse "tests").len + ":".len + self.name.len + 1; + + try writer.writeAll(jetzig.colors.red("┌")); + for (0..count) |_| try writer.writeAll(jetzig.colors.red("─")); + try writer.writeAll(jetzig.colors.red("┐")); + + try writer.print( + jetzig.colors.red("\n│ FAILURE: ") ++ name_template ++ jetzig.colors.red("│") ++ "\n", + .{ self.module orelse "tests", self.name }, + ); + try writer.writeAll(jetzig.colors.red("├")); + for (0..count) |_| try writer.writeAll(jetzig.colors.red("─")); + try writer.writeAll(jetzig.colors.red("┘")); + try writer.writeByte('\n'); + + const maybe_log_events = jetzig.testing.logger.logs.get(index); + if (maybe_log_events) |log_events| { + for (log_events.items) |log_event| try indent(log_event.output, jetzig.colors.red("│ "), writer); + } if (failure.trace) |trace| { - try writer.print("{s}", .{trace}); + try writer.writeAll(jetzig.colors.red("┆\n")); + try indent(trace, jetzig.colors.red("┆ "), writer); } } @@ -132,11 +177,13 @@ pub fn main() !void { try std.io.getStdErr().writer().writeAll("\n[jetzig] Launching Test Runner...\n\n"); + jetzig.testing.logger = jetzig.testing.Logger.init(allocator); jetzig.testing.state = .ready; - for (builtin.test_functions) |test_function| { + for (builtin.test_functions, 0..) |test_function, index| { + jetzig.testing.logger.index = index; var t = Test.init(test_function); - try t.run(); + try t.run(allocator); try t.print(std.io.getStdErr()); try tests.append(t); } @@ -170,6 +217,13 @@ fn printSummary(tests: []const Test, start: i128) !void { false, ); + for (tests, 0..) |t, index| { + switch (t.result) { + .success, .skipped => {}, + .failure => |capture| try t.printFailureDetail(index, capture, writer), + } + } + try writer.print( "\n {s}{s}{}" ++ "\n {s}{s}{}" ++ @@ -200,3 +254,24 @@ fn printSummary(tests: []const Test, start: i128) !void { std.process.exit(1); } } + +fn indent(message: []const u8, comptime indent_sequence: []const u8, writer: anytype) !void { + var it = std.mem.tokenizeScalar(u8, message, '\n'); + var color: ?[]const u8 = null; + + const escape = jetzig.colors.codes.escape; + + while (it.next()) |line| { + try writer.print(indent_sequence ++ "{s}{s}\n", .{ color orelse "", line }); + + // Preserve last color used in previous line (including reset) in case indent changes color. + if (std.mem.lastIndexOf(u8, line, escape)) |index| { + inline for (std.meta.fields(@TypeOf(jetzig.colors.codes))) |field| { + const code = @field(jetzig.colors.codes, field.name); + if (std.mem.startsWith(u8, line[index..], escape ++ code)) { + color = escape ++ code; + } + } + } + } +}