diff --git a/build.zig.zon b/build.zig.zon index c3617ae..f7e96fc 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,8 +7,8 @@ .hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163", }, .zmpl = .{ - .url = "https://github.com/jetzig-framework/zmpl/archive/7b7452bc7fdb0bd2f8a6a9c4e9312900b486aeba.tar.gz", - .hash = "1220ed127f38fa51df53a85b3cc2030a7555e34058db7fd374ebaef817abb43d35f7", + .url = "https://github.com/jetzig-framework/zmpl/archive/25b91d030b992631d319adde1cf01baecd9f3934.tar.gz", + .hash = "12208dd5a4bf0c6c7efc4e9f37a5d8ed80d6004d5680176d1fc2114bfa593e927baf", }, .jetkv = .{ .url = "https://github.com/jetzig-framework/jetkv/archive/2b1130a48979ea2871c8cf6ca89c38b1e7062839.tar.gz", diff --git a/cli/colors.zig b/cli/colors.zig new file mode 100644 index 0000000..80ef89c --- /dev/null +++ b/cli/colors.zig @@ -0,0 +1,208 @@ +const std = @import("std"); + +// Must be consistent with `std.io.tty.Color` for Windows compatibility. +pub const codes = .{ + .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.util.writeAnsi` to parse escape codes so they can be passed to +/// `std.io.tty.Config.setColor` (using Windows API to set console color mode). +const ansi_colors = .{ + .{ "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 }, +}; +pub const codes_map = if (@hasDecl(std, "ComptimeStringMap")) + std.ComptimeStringMap(std.io.tty.Color, ansi_colors) +else if (@hasDecl(std, "StaticStringMap")) + std.StaticStringMap(std.io.tty.Color).initComptime(ansi_colors) +else + unreachable; + +// Map basic ANSI color codes to Windows TextAttribute colors +// used by std.os.windows.SetConsoleTextAttribute() +const windows_colors = .{ + .{ "30", 0 }, + .{ "31", 4 }, + .{ "32", 2 }, + .{ "33", 6 }, + .{ "34", 1 }, + .{ "35", 5 }, + .{ "36", 3 }, + .{ "37", 7 }, + .{ "90", 8 }, + .{ "91", 12 }, + .{ "92", 10 }, + .{ "93", 14 }, + .{ "94", 9 }, + .{ "95", 13 }, + .{ "96", 11 }, + .{ "97", 15 }, + .{ "1", 7 }, + .{ "2", 7 }, + .{ "0", 7 }, +}; +pub const windows_map = if (@hasDecl(std, "ComptimeStringMap")) + std.ComptimeStringMap(u16, windows_colors) +else if (@hasDecl(std, "StaticStringMap")) + std.StaticStringMap(u16).initComptime(windows_colors) +else + unreachable; + +/// 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 { + return codes.escape ++ attribute ++ message ++ codes.escape ++ codes.reset; +} + +fn runtimeWrap(allocator: std.mem.Allocator, attribute: []const u8, message: []const u8) ![]const u8 { + return try std.mem.join( + allocator, + "", + &[_][]const u8{ codes.escape, attribute, message, codes.escape, codes.reset }, + ); +} + +pub fn bold(comptime message: []const u8) []const u8 { + return codes.escape ++ codes.bold ++ message ++ codes.escape ++ codes.reset; +} + +pub fn black(comptime message: []const u8) []const u8 { + return wrap(codes.black, message); +} + +pub fn runtimeBlack(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.black, message); +} + +pub fn red(comptime message: []const u8) []const u8 { + return wrap(codes.red, message); +} + +pub fn runtimeRed(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.red, message); +} + +pub fn green(comptime message: []const u8) []const u8 { + return wrap(codes.green, message); +} + +pub fn runtimeGreen(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.green, message); +} + +pub fn yellow(comptime message: []const u8) []const u8 { + return wrap(codes.yellow, message); +} + +pub fn runtimeYellow(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.yellow, message); +} + +pub fn blue(comptime message: []const u8) []const u8 { + return wrap(codes.blue, message); +} + +pub fn runtimeBlue(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.blue, message); +} + +pub fn magenta(comptime message: []const u8) []const u8 { + return wrap(codes.magenta, 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 { + return wrap(codes.cyan, message); +} + +pub fn runtimeCyan(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.cyan, message); +} + +pub fn white(comptime message: []const u8) []const u8 { + return wrap(codes.white, message); +} + +pub fn runtimeWhite(allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return try runtimeWrap(allocator, codes.white, message); +} + +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/cli/commands/auth.zig b/cli/commands/auth.zig index 9dd120a..052a2a4 100644 --- a/cli/commands/auth.zig +++ b/cli/commands/auth.zig @@ -5,17 +5,16 @@ const util = @import("../util.zig"); /// Command line options for the `update` command. pub const Options = struct { pub const meta = .{ - .usage_summary = "[password]", + .usage_summary = "[init|create]", .full_text = - \\Generates a password + \\Manage user authentication. Initialize with `init` to generate a users table migration. + \\ \\Example: \\ - \\ jetzig update - \\ jetzig update web + \\ jetzig auth init + \\ jetzig auth create bob@jetzig.dev , - .option_docs = .{ - .path = "Set the output path relative to the current directory (default: current directory)", - }, + .option_docs = .{}, }; }; @@ -32,9 +31,10 @@ pub fn run( defer arena.deinit(); const allocator = arena.allocator(); - const Action = enum { user_create }; + const Action = enum { init, create }; const map = std.StaticStringMap(Action).initComptime(.{ - .{ "user:create", .user_create }, + .{ "init", .init }, + .{ "create", .create }, }); const action = if (main_options.positionals.len > 0) @@ -55,7 +55,20 @@ pub fn run( break :blk error.JetzigCommandError; } else if (action) |capture| switch (capture) { - .user_create => blk: { + .init => { + const argv = [_][]const u8{ + "jetzig", + "generate", + "migration", + "create_users", + "table:users", + "column:email:string:index:unique", + "column:password_hash:string", + }; + try util.runCommand(allocator, &argv); + try util.print(.success, "Migration created. Run `jetzig database update` to run migration and reflect database.", .{}); + }, + .create => blk: { if (sub_args.len < 1) { std.debug.print("Missing argument. Expected an email/username parameter.\n", .{}); break :blk error.JetzigCommandError; diff --git a/cli/commands/bundle.zig b/cli/commands/bundle.zig index b40531c..cf4f0af 100644 --- a/cli/commands/bundle.zig +++ b/cli/commands/bundle.zig @@ -149,7 +149,7 @@ pub fn run( const tmpdir_real_path = try tmpdir.realpathAlloc(allocator, "."); defer allocator.free(tmpdir_real_path); - try util.runCommand(allocator, tmpdir_real_path, tar_argv.items); + try util.runCommandInDir(allocator, tar_argv.items, .{ .path = tmpdir_real_path }); switch (builtin.os.tag) { .windows => {}, @@ -215,7 +215,7 @@ fn zig_build_install(allocator: std.mem.Allocator, path: []const u8, options: Op defer project_dir.close(); project_dir.makePath(".bundle") catch {}; - try util.runCommand(allocator, path, install_argv.items); + try util.runCommandInDir(allocator, install_argv.items, .{ .path = path }); const install_bin_path = try std.fs.path.join(allocator, &[_][]const u8{ ".bundle", "bin" }); defer allocator.free(install_bin_path); diff --git a/cli/commands/database.zig b/cli/commands/database.zig index ca1c93c..256019d 100644 --- a/cli/commands/database.zig +++ b/cli/commands/database.zig @@ -9,12 +9,15 @@ const rollback = @import("database/rollback.zig"); const create = @import("database/create.zig"); const drop = @import("database/drop.zig"); const reflect = @import("database/reflect.zig"); +const update = @import("database/update.zig"); +const setup = @import("database/setup.zig"); + pub const confirm_drop_env = "JETZIG_DROP_PRODUCTION_DATABASE"; /// Command line options for the `database` command. pub const Options = struct { pub const meta = .{ - .usage_summary = "[migrate|rollback|create|drop|reflect]", + .usage_summary = "[setup|create|drop|migrate|rollback|reflect|update]", .full_text = \\Manage the application's database. \\ @@ -38,13 +41,15 @@ pub fn run( defer arena.deinit(); const alloc = arena.allocator(); - const Action = enum { migrate, rollback, create, drop, reflect }; + const Action = enum { migrate, rollback, create, drop, reflect, update, setup }; const map = std.StaticStringMap(Action).initComptime(.{ .{ "migrate", .migrate }, .{ "rollback", .rollback }, .{ "create", .create }, .{ "drop", .drop }, .{ "reflect", .reflect }, + .{ "update", .update }, + .{ "setup", .setup }, }); const action = if (main_options.positionals.len > 0) @@ -73,6 +78,8 @@ pub fn run( .create => create.run(alloc, cwd, sub_args, options, T, main_options), .drop => drop.run(alloc, cwd, sub_args, options, T, main_options), .reflect => reflect.run(alloc, cwd, sub_args, options, T, main_options), + .update => update.run(alloc, cwd, sub_args, options, T, main_options), + .setup => setup.run(alloc, cwd, sub_args, options, T, main_options), }; }; } diff --git a/cli/commands/database/setup.zig b/cli/commands/database/setup.zig new file mode 100644 index 0000000..702843a --- /dev/null +++ b/cli/commands/database/setup.zig @@ -0,0 +1,54 @@ +const std = @import("std"); + +const cli = @import("../../cli.zig"); +const util = @import("../../util.zig"); + +pub fn run( + allocator: std.mem.Allocator, + cwd: std.fs.Dir, + args: []const []const u8, + options: cli.database.Options, + T: type, + main_options: T, +) !void { + _ = cwd; + _ = options; + if (main_options.options.help or args.len != 0) { + std.debug.print( + \\Set up a database: create a database, run migrations, reflect schema. + \\ + \\Convenience wrapper for: + \\ + \\* jetzig database create + \\* jetzig database update + \\ + \\Example: + \\ + \\ jetzig database setup + \\ jetzig --environment=testing setup + \\ + , .{}); + + return if (main_options.options.help) {} else error.JetzigCommandError; + } + + const env = main_options.options.environment; + try runCommand(allocator, env, "create"); + try runCommand(allocator, env, "migrate"); + try runCommand(allocator, env, "reflect"); + + try util.print( + .success, + "Database created, migrations applied, and Schema generated successfully.", + .{}, + ); +} + +fn runCommand(allocator: std.mem.Allocator, environment: anytype, comptime action: []const u8) !void { + try util.runCommand(allocator, &.{ + "zig", + "build", + util.environmentBuildOption(environment), + "jetzig:database:" ++ action, + }); +} diff --git a/cli/commands/database/update.zig b/cli/commands/database/update.zig new file mode 100644 index 0000000..3c37f9b --- /dev/null +++ b/cli/commands/database/update.zig @@ -0,0 +1,45 @@ +const std = @import("std"); + +const cli = @import("../../cli.zig"); +const util = @import("../../util.zig"); + +pub fn run( + allocator: std.mem.Allocator, + cwd: std.fs.Dir, + args: []const []const u8, + options: cli.database.Options, + T: type, + main_options: T, +) !void { + _ = cwd; + _ = options; + if (main_options.options.help or args.len != 0) { + std.debug.print( + \\Update a database: run migrations and reflect schema. + \\ + \\Convenience wrapper for `jetzig database migrate` and `jetzig database reflect`. + \\ + \\Example: + \\ + \\ jetzig database update + \\ jetzig --environment=testing update + \\ + , .{}); + + return if (main_options.options.help) {} else error.JetzigCommandError; + } + + try util.runCommand(allocator, &.{ + "zig", + "build", + util.environmentBuildOption(main_options.options.environment), + "jetzig:database:migrate", + }); + + try util.runCommand(allocator, &.{ + "zig", + "build", + util.environmentBuildOption(main_options.options.environment), + "jetzig:database:reflect", + }); +} diff --git a/cli/commands/generate/job.zig b/cli/commands/generate/job.zig index 5272109..5a68df4 100644 --- a/cli/commands/generate/job.zig +++ b/cli/commands/generate/job.zig @@ -47,7 +47,7 @@ pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8, he \\// \\// Arguments: \\// * allocator: Arena allocator for use during the job execution process. - \\// * params: Params assigned to a job (from a request, any values added to `data`). + \\// * params: Params assigned to a job (from a request, values added to response data). \\// * env: Provides the following fields: \\// - logger: Logger attached to the same stream as the Jetzig server. \\// - environment: Enum of `{ production, development }`. diff --git a/cli/commands/init.zig b/cli/commands/init.zig index 215acb1..054f313 100644 --- a/cli/commands/init.zig +++ b/cli/commands/init.zig @@ -194,12 +194,16 @@ pub fn run( null, ); - try util.runCommand(allocator, realpath, &[_][]const u8{ - "zig", - "fetch", - "--save", - github_url, - }); + try util.runCommandInDir( + allocator, + &[_][]const u8{ + "zig", + "fetch", + "--save", + github_url, + }, + .{ .dir = install_dir }, + ); // TODO: Use arg or interactive prompt to do Git setup in net project, default to no. // const git_setup = false; @@ -322,22 +326,34 @@ fn promptInput( // Initialize a new Git repository when setting up a new project (optional). fn gitSetup(allocator: std.mem.Allocator, install_dir: *std.fs.Dir) !void { - try util.runCommand(allocator, install_dir, &[_][]const u8{ - "git", - "init", - ".", - }); + try util.runCommandInDir( + allocator, + &[_][]const u8{ + "git", + "init", + ".", + }, + .{ .path = install_dir }, + ); - try util.runCommand(allocator, install_dir, &[_][]const u8{ - "git", - "add", - ".", - }); + try util.runCommandInDir( + allocator, + &[_][]const u8{ + "git", + "add", + ".", + }, + .{ .path = install_dir }, + ); - try util.runCommand(allocator, install_dir, &[_][]const u8{ - "git", - "commit", - "-m", - "Initialize Jetzig project", - }); + try util.runCommandInDir( + allocator, + &[_][]const u8{ + "git", + "commit", + "-m", + "Initialize Jetzig project", + }, + .{ .path = install_dir }, + ); } diff --git a/cli/commands/server.zig b/cli/commands/server.zig index 50b6272..87f6e22 100644 --- a/cli/commands/server.zig +++ b/cli/commands/server.zig @@ -63,9 +63,8 @@ pub fn run( ); while (true) { - util.runCommand( + util.runCommandInDir( allocator, - realpath, &.{ "zig", "build", @@ -75,6 +74,7 @@ pub fn run( "--color", "on", }, + .{ .path = realpath }, ) catch { std.debug.print("Build failed, waiting for file change...\n", .{}); try awaitFileChange(allocator, cwd, &mtime); diff --git a/cli/commands/update.zig b/cli/commands/update.zig index 7c2fd40..2d68552 100644 --- a/cli/commands/update.zig +++ b/cli/commands/update.zig @@ -55,18 +55,15 @@ pub fn run( const save_arg = try std.mem.concat(allocator, u8, &[_][]const u8{ "--save=", name }); defer allocator.free(save_arg); - var cwd = try util.detectJetzigProjectDir(); - defer cwd.close(); - - const realpath = try std.fs.realpathAlloc(allocator, "."); - defer allocator.free(realpath); - - try util.runCommand(allocator, realpath, &[_][]const u8{ - "zig", - "fetch", - save_arg, - github_url, - }); + try util.runCommand( + allocator, + &[_][]const u8{ + "zig", + "fetch", + save_arg, + github_url, + }, + ); std.debug.print( \\Update complete. diff --git a/cli/util.zig b/cli/util.zig index 7376521..fb37f16 100644 --- a/cli/util.zig +++ b/cli/util.zig @@ -2,6 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const cli = @import("cli.zig"); +const colors = @import("colors.zig"); /// Decode a base64 string, used for parsing out build artifacts generated by the CLI program's /// build.zig which are stored in the executable as a module. @@ -16,14 +17,35 @@ pub fn base64Decode(allocator: std.mem.Allocator, input: []const u8) ![]const u8 return ptr; } +const icons = .{ + .check = "✅", + .cross = "❌", +}; + /// Print a success confirmation. pub fn printSuccess() void { - std.debug.print(" ✅\n", .{}); + std.debug.print(" " ++ icons.check ++ "\n", .{}); } /// Print a failure confirmation. pub fn printFailure() void { - std.debug.print(" ❌\n", .{}); + std.debug.print(" " ++ icons.cross ++ "\n", .{}); +} + +const PrintContext = enum { success, failure }; +/// Print some output in with a given context to stderr. +pub fn print(comptime context: PrintContext, comptime message: []const u8, args: anytype) !void { + const writer = std.io.getStdErr().writer(); + switch (context) { + .success => try writer.print( + std.fmt.comptimePrint("{s} {s}\n", .{ icons.check, colors.green(message) }), + args, + ), + .failure => try writer.print( + std.fmt.comptimePrint("{s} {s}\n", .{ icons.cross, colors.red(message) }), + args, + ), + } } /// Detects a Jetzig project directory either in the current directory or one of its parent @@ -126,9 +148,32 @@ pub fn runCommandStreaming(allocator: std.mem.Allocator, install_path: []const u _ = try child.wait(); } -/// Runs a command as a child process and verifies successful exit code. -pub fn runCommand(allocator: std.mem.Allocator, install_path: []const u8, argv: []const []const u8) !void { - const result = try std.process.Child.run(.{ .allocator = allocator, .argv = argv, .cwd = install_path }); +/// Runs a command as a child process in Jetzig project directory and verifies successful exit +/// code. +pub fn runCommand(allocator: std.mem.Allocator, argv: []const []const u8) !void { + var dir = try detectJetzigProjectDir(); + defer dir.close(); + try runCommandInDir(allocator, argv, .{ .dir = dir }); +} + +const Dir = union(enum) { + path: []const u8, + dir: std.fs.Dir, +}; + +/// Runs a command as a child process in the given directory and verifies successful exit code. +pub fn runCommandInDir(allocator: std.mem.Allocator, argv: []const []const u8, dir: Dir) !void { + const cwd_path = switch (dir) { + .path => |capture| capture, + .dir => |capture| try capture.realpathAlloc(allocator, "."), + }; + defer if (dir == .dir) allocator.free(cwd_path); + + const result = try std.process.Child.run(.{ + .allocator = allocator, + .argv = argv, + .cwd = cwd_path, + }); defer allocator.free(result.stdout); defer allocator.free(result.stderr); diff --git a/demo/src/app/mailers/welcome.zig b/demo/src/app/mailers/welcome.zig index 87e0d83..b53c1c5 100644 --- a/demo/src/app/mailers/welcome.zig +++ b/demo/src/app/mailers/welcome.zig @@ -28,12 +28,11 @@ pub const defaults: jetzig.mail.DefaultMailParams = .{ pub fn deliver( allocator: std.mem.Allocator, mail: *jetzig.mail.MailParams, - data: *jetzig.data.Data, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv, ) !void { _ = allocator; - try params.put("email_message", data.string("Custom email message")); + try params.put("email_message", "Custom email message"); try env.logger.INFO("Delivering email with subject: '{?s}'", .{mail.get(.subject)}); } diff --git a/demo/src/app/views/custom/foo.zig b/demo/src/app/views/custom/foo.zig index 25279a8..21d3ca4 100644 --- a/demo/src/app/views/custom/foo.zig +++ b/demo/src/app/views/custom/foo.zig @@ -1,7 +1,7 @@ const jetzig = @import("jetzig"); -pub fn bar(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - var root = try data.object(); - try root.put("id", data.string(id)); +pub fn bar(id: []const u8, request: *jetzig.Request) !jetzig.View { + var root = try request.data(.object); + try root.put("id", id); return request.render(.ok); } diff --git a/demo/src/app/views/file_upload.zig b/demo/src/app/views/file_upload.zig index 4dc73f5..292365b 100644 --- a/demo/src/app/views/file_upload.zig +++ b/demo/src/app/views/file_upload.zig @@ -1,13 +1,12 @@ const std = @import("std"); const jetzig = @import("jetzig"); -pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; +pub fn index(request: *jetzig.Request) !jetzig.View { return request.render(.ok); } -pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - var root = try data.root(.object); +pub fn post(request: *jetzig.Request) !jetzig.View { + var root = try request.data(.object); const params = try request.params(); diff --git a/demo/src/app/views/kvstore.zig b/demo/src/app/views/kvstore.zig index 40ff79a..5e6784d 100644 --- a/demo/src/app/views/kvstore.zig +++ b/demo/src/app/views/kvstore.zig @@ -2,8 +2,8 @@ const std = @import("std"); const jetzig = @import("jetzig"); /// This example demonstrates usage of Jetzig's KV store. -pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - var root = try data.object(); +pub fn index(request: *jetzig.Request) !jetzig.View { + var root = try request.data(.object); // Fetch a string from the KV store. If it exists, store it in the root data object, // otherwise store a string value to be picked up by the next request. @@ -11,7 +11,7 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { try root.put("stored_string", capture); } else { try root.put("stored_string", null); - try request.store.put("example-key", data.string("example-value")); + try request.store.put("example-key", "example-value"); } // Left-pop an item from an array and store it in the root data object. This will empty the @@ -21,9 +21,9 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { try root.put("popped", value); } else { // Store some values in an array in the KV store. - try request.store.append("example-array", data.string("hello")); - try request.store.append("example-array", data.string("goodbye")); - try request.store.append("example-array", data.string("hello again")); + try request.store.append("example-array", "hello"); + try request.store.append("example-array", "goodbye"); + try request.store.append("example-array", "hello again"); try root.put("popped", null); } diff --git a/demo/src/app/views/nested/route/example.zig b/demo/src/app/views/nested/route/example.zig index c7a1eac..90a3245 100644 --- a/demo/src/app/views/nested/route/example.zig +++ b/demo/src/app/views/nested/route/example.zig @@ -1,8 +1,7 @@ const std = @import("std"); const jetzig = @import("jetzig"); -pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; +pub fn index(request: *jetzig.Request) !jetzig.View { return request.render(.ok); } @@ -13,9 +12,9 @@ pub const static_params = .{ }, }; -pub fn get(id: []const u8, request: *jetzig.StaticRequest, data: *jetzig.Data) !jetzig.View { - var object = try data.object(); - try object.put("id", data.string(id)); +pub fn get(id: []const u8, request: *jetzig.StaticRequest) !jetzig.View { + var object = try request.data(.object); + try object.put("id", id); const params = try request.params(); if (params.get("foo")) |value| try object.put("foo", value); return request.render(.ok); diff --git a/demo/src/app/views/params.zig b/demo/src/app/views/params.zig index 1eff17b..67e3c0d 100644 --- a/demo/src/app/views/params.zig +++ b/demo/src/app/views/params.zig @@ -42,6 +42,14 @@ test "post query params" { }, }); try response2.expectStatus(.unprocessable_entity); + + const response3 = try app.request(.POST, "/params", .{ + .params = .{ + .name = "", // empty param + .favorite_animal = "raccoon", + }, + }); + try response3.expectStatus(.unprocessable_entity); } test "post json" { diff --git a/demo/src/app/views/redirect.zig b/demo/src/app/views/redirect.zig index 2330df0..504eed3 100644 --- a/demo/src/app/views/redirect.zig +++ b/demo/src/app/views/redirect.zig @@ -1,14 +1,13 @@ const std = @import("std"); const jetzig = @import("jetzig"); -pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; +pub fn index(request: *jetzig.Request) !jetzig.View { const params = try request.params(); if (params.get("redirect")) |location| { switch (location.*) { - // Value is `.Null` when param is empty, e.g.: + // Value is `.null` when param is empty, e.g.: // `http://localhost:8080/redirect?redirect` - .Null => return request.redirect("http://www.example.com/", .moved_permanently), + .null => return request.redirect("http://www.example.com/", .moved_permanently), // Value is `.string` when param is present, e.g.: // `http://localhost:8080/redirect?redirect=https://jetzig.dev/` .string => |string| return request.redirect(string.value, .moved_permanently), diff --git a/src/commands/database.zig b/src/commands/database.zig index 2f15f8a..9594455 100644 --- a/src/commands/database.zig +++ b/src/commands/database.zig @@ -7,6 +7,7 @@ const jetzig = @import("jetzig"); const Migrate = @import("jetquery_migrate").Migrate; const MigrateSchema = @import("jetquery_migrate").MigrateSchema; const Schema = @import("Schema"); +const util = @import("util.zig"); const confirm_drop_env = "JETZIG_DROP_PRODUCTION_DATABASE"; const production_drop_failure_message = "To drop a production database, " ++ @@ -20,6 +21,15 @@ pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer std.debug.assert(gpa.deinit() == .ok); + if (comptime !@hasField(@TypeOf(config), "adapter") or config.adapter == .null) { + try util.print( + .failure, + "Database is currently not configured. Update `config/database.zig` before running database commands.", + .{}, + ); + std.process.exit(1); + } + const gpa_allocator = gpa.allocator(); var arena = std.heap.ArenaAllocator.init(gpa_allocator); defer arena.deinit(); diff --git a/src/commands/util.zig b/src/commands/util.zig new file mode 100644 index 0000000..6bfad9d --- /dev/null +++ b/src/commands/util.zig @@ -0,0 +1,33 @@ +const std = @import("std"); +const colors = @import("jetzig").colors; + +const icons = .{ + .check = "✅", + .cross = "❌", +}; + +/// Print a success confirmation. +pub fn printSuccess() void { + std.debug.print(" " ++ icons.check ++ "\n", .{}); +} + +/// Print a failure confirmation. +pub fn printFailure() void { + std.debug.print(" " ++ icons.cross ++ "\n", .{}); +} + +const PrintContext = enum { success, failure }; +/// Print some output in with a given context to stderr. +pub fn print(comptime context: PrintContext, comptime message: []const u8, args: anytype) !void { + const writer = std.io.getStdErr().writer(); + switch (context) { + .success => try writer.print( + std.fmt.comptimePrint("{s} {s}\n", .{ icons.check, colors.green(message) }), + args, + ), + .failure => try writer.print( + std.fmt.comptimePrint("{s} {s}\n", .{ icons.cross, colors.red(message) }), + args, + ), + } +} diff --git a/src/jetzig/http/Query.zig b/src/jetzig/http/Query.zig index b7a8bcb..9764cda 100644 --- a/src/jetzig/http/Query.zig +++ b/src/jetzig/http/Query.zig @@ -186,13 +186,13 @@ test "query string with param without value" { const foo = data.get("foo").?; try switch (foo.*) { - .Null => {}, + .null => {}, else => std.testing.expect(false), }; const bar = data.get("bar").?; try switch (bar.*) { - .Null => {}, + .null => {}, else => std.testing.expect(false), }; } diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 6fb97d3..4a78395 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -60,8 +60,9 @@ pub const RequestStore = struct { } /// Get a String from the store. - pub fn put(self: RequestStore, key: []const u8, value: *jetzig.data.Value) !void { - try self.store.put(key, value); + pub fn put(self: RequestStore, key: []const u8, value: anytype) !void { + const alloc = (try self.data()).allocator(); + try self.store.put(key, try jetzig.Data.zmplValue(value, alloc)); } /// Remove a String to from the key-value store and return it if found. @@ -75,13 +76,15 @@ pub const RequestStore = struct { } /// Append a Value to the end of an Array in the key-value store. - pub fn append(self: RequestStore, key: []const u8, value: *jetzig.data.Value) !void { - try self.store.append(key, value); + pub fn append(self: RequestStore, key: []const u8, value: anytype) !void { + const alloc = (try self.data()).allocator(); + try self.store.append(key, try jetzig.Data.zmplValue(value, alloc)); } /// Prepend a Value to the start of an Array in the key-value store. - pub fn prepend(self: RequestStore, key: []const u8, value: *jetzig.data.Value) !void { - try self.store.prepend(key, value); + pub fn prepend(self: RequestStore, key: []const u8, value: anytype) !void { + const alloc = (try self.data()).allocator(); + try self.store.prepend(key, try jetzig.Data.zmplValue(value, alloc)); } /// Pop a String from an Array in the key-value store. @@ -660,7 +663,7 @@ pub fn joinPath(self: *const Request, args: anytype) ![]const u8 { @compileError("Cannot coerce type `" ++ @typeName(field.type) ++ "` to string."), else => args[index], // Assume []const u8, let Zig do the work. }, - .int, .float => try std.fmt.allocPrint(self.allocator, "{d}", args[index]), + .int, .float => try std.fmt.allocPrint(self.allocator, "{d}", .{args[index]}), else => @compileError("Cannot coerce type `" ++ @typeName(field.type) ++ "` to string."), }; } diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index ba84f78..5ac06be 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -166,7 +166,7 @@ fn renderResponse(self: *Server, request: *jetzig.http.Request) !void { const static_resource = self.matchStaticResource(request) catch |err| { if (isUnhandledError(err)) return err; - const rendered = try self.renderInternalServerError(request, err); + const rendered = try self.renderInternalServerError(request, @errorReturnTrace(), err); request.setResponse(rendered, .{}); return; }; @@ -220,7 +220,11 @@ fn renderHTML( if (zmpl.findPrefixed("views", matched_route.template)) |template| { const rendered = self.renderView(matched_route, request, template) catch |err| { if (isUnhandledError(err)) return err; - const rendered_error = try self.renderInternalServerError(request, err); + const rendered_error = try self.renderInternalServerError( + request, + @errorReturnTrace(), + err, + ); return request.setResponse(rendered_error, .{}); }; return request.setResponse(rendered, .{}); @@ -229,7 +233,7 @@ fn renderHTML( // assigned in a view. const rendered = self.renderView(matched_route, request, null) catch |err| { if (isUnhandledError(err)) return err; - const rendered_error = try self.renderInternalServerError(request, err); + const rendered_error = try self.renderInternalServerError(request, @errorReturnTrace(), err); return request.setResponse(rendered_error, .{}); }; @@ -296,10 +300,9 @@ fn renderView( // `return request.render(.ok)`, but the actual rendered view is stored in // `request.rendered_view`. _ = route.render(route, request) catch |err| { - try self.logger.ERROR("Encountered error: {s}", .{@errorName(err)}); if (isUnhandledError(err)) return err; if (isBadRequest(err)) return try self.renderBadRequest(request); - return try self.renderInternalServerError(request, err); + return try self.renderInternalServerError(request, @errorReturnTrace(), err); }; if (request.failed) { @@ -414,10 +417,15 @@ fn isBadHttpError(err: anyerror) bool { }; } -fn renderInternalServerError(self: *Server, request: *jetzig.http.Request, err: anyerror) !RenderedView { +fn renderInternalServerError( + self: *Server, + request: *jetzig.http.Request, + stack_trace: ?*std.builtin.StackTrace, + err: anyerror, +) !RenderedView { request.response_data.reset(); - try self.logger.logError(err); + try self.logger.logError(stack_trace, err); const status = .internal_server_error; return try self.renderError(request, status); @@ -460,13 +468,11 @@ fn renderErrorView( _ = route.render(route.*, request) catch |err| { if (isUnhandledError(err)) return err; - try self.logger.logError(err); + try self.logger.logError(@errorReturnTrace(), err); try self.logger.ERROR( "Unexepected error occurred while rendering error page: {s}", .{@errorName(err)}, ); - const stack = @errorReturnTrace(); - if (stack) |capture| try self.logStackTrace(capture, request.allocator); return try renderDefaultError(request, status_code); }; diff --git a/src/jetzig/http/StaticRequest.zig b/src/jetzig/http/StaticRequest.zig index e47db1c..289067a 100644 --- a/src/jetzig/http/StaticRequest.zig +++ b/src/jetzig/http/StaticRequest.zig @@ -1,12 +1,12 @@ const std = @import("std"); -const Self = @This(); +const StaticRequest = @This(); const jetzig = @import("../../jetzig.zig"); response_data: *jetzig.data.Data, allocator: std.mem.Allocator, json: []const u8, -pub fn init(allocator: std.mem.Allocator, json: []const u8) !Self { +pub fn init(allocator: std.mem.Allocator, json: []const u8) !StaticRequest { return .{ .allocator = allocator, .response_data = try allocator.create(jetzig.data.Data), @@ -14,31 +14,35 @@ pub fn init(allocator: std.mem.Allocator, json: []const u8) !Self { }; } -pub fn deinit(self: *Self) void { +pub fn deinit(self: *StaticRequest) void { _ = self; } -pub fn render(self: *Self, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View { +pub fn render(self: *StaticRequest, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View { return .{ .data = self.response_data, .status_code = status_code }; } -pub fn resourceId(self: *Self) ![]const u8 { - var data = try self.allocator.create(jetzig.data.Data); - data.* = jetzig.data.Data.init(self.allocator); - defer self.allocator.destroy(data); - defer data.deinit(); +pub fn data(self: *StaticRequest, comptime root: @TypeOf(.enum_literal)) !*jetzig.Data.Value { + return try self.response_data.root(root); +} - try data.fromJson(self.json); +pub fn resourceId(self: *StaticRequest) ![]const u8 { + var params_data = try self.allocator.create(jetzig.data.Data); + params_data.* = jetzig.data.Data.init(self.allocator); + defer self.allocator.destroy(params_data); + defer params_data.deinit(); + + try params_data.fromJson(self.json); // Routes generator rejects missing `.id` option so this should always be present. // Note that static requests are never rendered at runtime so we can be unsafe here and risk // failing a build (which would not be coherent if we allowed it to complete). - return try self.allocator.dupe(u8, data.value.?.get("id").?.string.value); + return try self.allocator.dupe(u8, params_data.value.?.get("id").?.string.value); } /// Returns the static params defined by `pub const static_params` in the relevant view. -pub fn params(self: *Self) !*jetzig.data.Value { - var data = try self.allocator.create(jetzig.data.Data); - data.* = jetzig.data.Data.init(self.allocator); - try data.fromJson(self.json); - return data.value.?.get("params") orelse data.object(); +pub fn params(self: *StaticRequest) !*jetzig.data.Value { + var params_data = try self.allocator.create(jetzig.data.Data); + params_data.* = jetzig.data.Data.init(self.allocator); + try params_data.fromJson(self.json); + return params_data.value.?.get("params") orelse params_data.object(); } diff --git a/src/jetzig/http/params.zig b/src/jetzig/http/params.zig index 53c6c16..2314c0f 100644 --- a/src/jetzig/http/params.zig +++ b/src/jetzig/http/params.zig @@ -13,7 +13,13 @@ pub fn expectParams(request: *jetzig.http.Request, T: type) !?T { var failed = false; inline for (fields, 0..) |field, index| { - if (actual_params.get(field.name)) |value| { + var maybe_value = actual_params.get(field.name); + + if (isBlank(maybe_value)) { + maybe_value = null; + } + + if (maybe_value) |value| { switch (@typeInfo(field.type)) { .optional => |info| if (value.coerce(info.child)) |coerced| { @field(t, field.name) = coerced; @@ -66,6 +72,12 @@ pub fn expectParams(request: *jetzig.http.Request, T: type) !?T { return t; } +fn isBlank(maybe_value: ?*const jetzig.Data.Value) bool { + if (maybe_value) |value| { + return value.* == .string and jetzig.util.strip(value.string.value).len == 0; + } else return true; +} + /// See `Request.paramsInfo`. pub const ParamsInfo = struct { params: std.BoundedArray(ParamInfo, 1024), diff --git a/src/jetzig/kv/Store.zig b/src/jetzig/kv/Store.zig index cddd1bb..cb5dccd 100644 --- a/src/jetzig/kv/Store.zig +++ b/src/jetzig/kv/Store.zig @@ -44,7 +44,7 @@ pub fn deinit(self: *Store) void { self.store.deinit(); } -/// Put a Value or into the key-value store. +/// Put a or into the key-value store. pub fn put(self: *Store, key: []const u8, value: *jetzig.data.Value) !void { try self.store.put(key, try value.toJson()); } diff --git a/src/jetzig/loggers.zig b/src/jetzig/loggers.zig index 91a73a5..5153aea 100644 --- a/src/jetzig/loggers.zig +++ b/src/jetzig/loggers.zig @@ -83,9 +83,13 @@ pub const Logger = union(enum) { } } - pub fn logError(self: *const Logger, err: anyerror) !void { + pub fn logError( + self: *const Logger, + stack_trace: ?*std.builtin.StackTrace, + err: anyerror, + ) !void { switch (self.*) { - inline else => |*logger| try logger.logError(err), + inline else => |*logger| try logger.logError(stack_trace, err), } } diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig index dc4e207..3ae6ba9 100644 --- a/src/jetzig/loggers/DevelopmentLogger.zig +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -238,17 +238,13 @@ fn printSql(self: *const DevelopmentLogger, sql: []const u8) !void { try self.print(.INFO, "{s}", .{stream.getWritten()}); } -pub fn logError(self: *const DevelopmentLogger, err: anyerror) !void { - if (@errorReturnTrace()) |stack| { - try self.log(.ERROR, "\nStack Trace:\n{}", .{stack}); - var buf = std.ArrayList(u8).init(self.allocator); - defer buf.deinit(); - const writer = buf.writer(); - try stack.format("", .{}, writer); - try self.logger.ERROR("{s}\n", .{buf.items}); +pub fn logError(self: *const DevelopmentLogger, stack_trace: ?*std.builtin.StackTrace, err: anyerror) !void { + if (stack_trace) |stack| { + try self.log(.ERROR, "Encountered Error: {s}", .{@errorName(err)}); + try self.log(.ERROR, "Stack trace:\n{}", .{stack}); + } else { + try self.log(.ERROR, "Encountered Error: {s}", .{@errorName(err)}); } - - try self.log(.ERROR, "Encountered Error: {s}", .{@errorName(err)}); } fn logFile(self: DevelopmentLogger, comptime level: jetzig.loggers.LogLevel) std.fs.File { diff --git a/src/jetzig/loggers/JsonLogger.zig b/src/jetzig/loggers/JsonLogger.zig index a17612f..bca5fde 100644 --- a/src/jetzig/loggers/JsonLogger.zig +++ b/src/jetzig/loggers/JsonLogger.zig @@ -112,7 +112,13 @@ pub fn logSql(self: *const JsonLogger, event: jetzig.jetquery.events.Event) !voi try self.log_queue.print("{s}\n", .{stream.getWritten()}, .stdout); } -pub fn logError(self: *const JsonLogger, err: anyerror) !void { +pub fn logError( + self: *const JsonLogger, + stack_trace: ?*std.builtin.StackTrace, + err: anyerror, +) !void { + // TODO: Format this as JSON and include line number/column if available. + _ = stack_trace; try self.log(.ERROR, "Encountered error: {s}", .{@errorName(err)}); } diff --git a/src/jetzig/loggers/NullLogger.zig b/src/jetzig/loggers/NullLogger.zig index 7852b23..a1f4ef2 100644 --- a/src/jetzig/loggers/NullLogger.zig +++ b/src/jetzig/loggers/NullLogger.zig @@ -19,7 +19,8 @@ pub inline fn logRequest(self: @This(), request: *const jetzig.http.Request) !vo _ = request; } -pub inline fn logError(self: @This(), err: anyerror) !void { +pub inline fn logError(self: @This(), stack_trace: ?*std.builtin.StackTrace, err: anyerror) !void { _ = self; + _ = stack_trace; std.debug.print("Error: {s}\n", .{@errorName(err)}); } diff --git a/src/jetzig/loggers/ProductionLogger.zig b/src/jetzig/loggers/ProductionLogger.zig index 5309a41..6c7c51c 100644 --- a/src/jetzig/loggers/ProductionLogger.zig +++ b/src/jetzig/loggers/ProductionLogger.zig @@ -141,6 +141,8 @@ const sql_tokens = .{ "VALUES", }; -pub fn logError(self: *const ProductionLogger, err: anyerror) !void { +pub fn logError(self: *const ProductionLogger, stack_trace: ?*std.builtin.StackTrace, err: anyerror) !void { + // TODO: Include line number/column if available. + _ = stack_trace; try self.log(.ERROR, "Encountered Error: {s}", .{@errorName(err)}); } diff --git a/src/jetzig/loggers/TestLogger.zig b/src/jetzig/loggers/TestLogger.zig index 9683451..50a16c1 100644 --- a/src/jetzig/loggers/TestLogger.zig +++ b/src/jetzig/loggers/TestLogger.zig @@ -46,7 +46,9 @@ pub fn logSql(self: TestLogger, event: jetzig.jetquery.events.Event) !void { try self.log(.INFO, "[database] {?s}", .{event.sql}); } -pub fn logError(self: TestLogger, err: anyerror) !void { +pub fn logError(self: TestLogger, stack_trace: ?*std.builtin.StackTrace, err: anyerror) !void { + // TODO: Output useful debug info from stack trace + _ = stack_trace; try self.log(.ERROR, "Encountered error: {s}", .{@errorName(err)}); } diff --git a/src/jetzig/mail/Job.zig b/src/jetzig/mail/Job.zig index 68915c6..f1585bb 100644 --- a/src/jetzig/mail/Job.zig +++ b/src/jetzig/mail/Job.zig @@ -4,7 +4,7 @@ const jetzig = @import("../../jetzig.zig"); /// Default Mail Job. Send an email with the given params in the background. pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void { const mailer_name = if (params.get("mailer_name")) |param| switch (param.*) { - .Null => null, + .null => null, .string => |string| string.value, else => null, } else null; @@ -34,9 +34,7 @@ pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig .defaults = mailer.defaults, }; - var data = jetzig.data.Data.init(allocator); - - try mailer.deliverFn(allocator, &mail_params, &data, params.get("params").?, env); + try mailer.deliverFn(allocator, &mail_params, params.get("params").?, env); const mail = jetzig.mail.Mail.init(allocator, env, .{ .subject = mail_params.get(.subject) orelse "(No subject)", @@ -62,7 +60,7 @@ pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig fn resolveSubject(subject: ?*const jetzig.data.Value) ?[]const u8 { if (subject) |capture| { return switch (capture.*) { - .Null => null, + .null => null, .string => |string| string.value, else => unreachable, }; @@ -73,7 +71,7 @@ fn resolveSubject(subject: ?*const jetzig.data.Value) ?[]const u8 { fn resolveFrom(from: ?*const jetzig.data.Value) ?[]const u8 { return if (from) |capture| switch (capture.*) { - .Null => null, + .null => null, .string => |string| string.value, else => unreachable, } else null; @@ -97,7 +95,7 @@ fn resolveText( ) !?[]const u8 { if (text) |capture| { return switch (capture.*) { - .Null => try defaultText(allocator, mailer, params), + .null => try defaultText(allocator, mailer, params), .string => |string| string.value, else => unreachable, }; @@ -114,7 +112,7 @@ fn resolveHtml( ) !?[]const u8 { if (text) |capture| { return switch (capture.*) { - .Null => try defaultHtml(allocator, mailer, params), + .null => try defaultHtml(allocator, mailer, params), .string => |string| string.value, else => unreachable, }; diff --git a/src/jetzig/mail/MailerDefinition.zig b/src/jetzig/mail/MailerDefinition.zig index 923d997..2331428 100644 --- a/src/jetzig/mail/MailerDefinition.zig +++ b/src/jetzig/mail/MailerDefinition.zig @@ -4,7 +4,6 @@ const jetzig = @import("../../jetzig.zig"); pub const DeliverFn = *const fn ( std.mem.Allocator, *jetzig.mail.MailParams, - *jetzig.data.Data, *jetzig.data.Value, jetzig.jobs.JobEnv, ) anyerror!void; diff --git a/src/jetzig/testing.zig b/src/jetzig/testing.zig index b52957a..6b9cbf4 100644 --- a/src/jetzig/testing.zig +++ b/src/jetzig/testing.zig @@ -205,7 +205,7 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: }, else => {}, }, - .Null => switch (@typeInfo(@TypeOf(expected_value))) { + .null => switch (@typeInfo(@TypeOf(expected_value))) { .optional => { if (expected_value == null) return; }, @@ -264,7 +264,7 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: else => unreachable, } }, - .Null => { + .null => { logFailure( "Expected value in " ++ jetzig.colors.cyan("{s}") ++ ", found " ++ jetzig.colors.green("null") ++ "\nJSON:" ++ json_banner, .{ expected_path, try jsonPretty(response) },