From a6d1b92f5e50722653840131b0cd9f2119902ae4 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Mon, 11 Nov 2024 22:25:35 +0000 Subject: [PATCH] Simplify DevelopmentLogger, add ProductionLogger Add auth helper to create a user from CLI: ``` jetzig auth user:create user@example.com ``` --- build.zig | 21 +++- build.zig.zon | 4 +- cli/commands/auth.zig | 30 ++--- demo/src/main.zig | 10 ++ src/commands/auth.zig | 72 +++++++++++ src/commands/database.zig | 4 +- src/commands/routes.zig | 2 +- src/jetzig.zig | 4 +- src/jetzig/Environment.zig | 117 ++++++++++++++---- src/jetzig/database.zig | 27 ++++- src/jetzig/loggers.zig | 5 +- src/jetzig/loggers/DevelopmentLogger.zig | 70 ++++++----- src/jetzig/loggers/ProductionLogger.zig | 146 +++++++++++++++++++++++ src/jetzig/mail/Mail.zig | 2 +- src/jetzig/testing/App.zig | 5 +- src/jetzig/util.zig | 9 ++ 16 files changed, 441 insertions(+), 87 deletions(-) create mode 100644 src/commands/auth.zig create mode 100644 src/jetzig/loggers/ProductionLogger.zig diff --git a/build.zig b/build.zig index 2feb6c6..b0003f0 100644 --- a/build.zig +++ b/build.zig @@ -127,8 +127,6 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn return error.ZmplVersionNotSupported; } - _ = b.option([]const u8, "seed", "Internal test seed"); - const target = b.host; const optimize = exe.root_module.optimize orelse .Debug; @@ -333,6 +331,25 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn .optimize = optimize, }); + const auth_user_create_step = b.step("jetzig:auth:user:create", "List all routes in your app"); + const exe_auth = b.addExecutable(.{ + .name = "auth", + .root_source_file = jetzig_dep.path("src/commands/auth.zig"), + .target = target, + .optimize = optimize, + }); + exe_auth.root_module.addImport("jetquery", jetquery_module); + exe_auth.root_module.addImport("jetzig", jetzig_module); + exe_auth.root_module.addImport("jetcommon", jetcommon_module); + exe_auth.root_module.addImport("Schema", schema_module); + exe_auth.root_module.addImport("main", main_module); + const run_auth_user_create_cmd = b.addRunArtifact(exe_auth); + auth_user_create_step.dependOn(&run_auth_user_create_cmd.step); + run_auth_user_create_cmd.addArg("user:create"); + if (b.option([]const u8, "auth_username", "Auth username")) |username| { + run_auth_user_create_cmd.addArg(username); + } + const exe_database = b.addExecutable(.{ .name = "database", .root_source_file = jetzig_dep.path("src/commands/database.zig"), diff --git a/build.zig.zon b/build.zig.zon index 14b39ae..7c45e8f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -15,8 +15,8 @@ .hash = "12201d75d73aad5e1c996de4d5ae87a00e58479c8d469bc2eeb5fdeeac8857bc09af", }, .jetquery = .{ - .url = "https://github.com/jetzig-framework/jetquery/archive/c9df298b5a2d0713257c99d89096f6f981cbc6b6.tar.gz", - .hash = "12200417dd5948bd2942c36b4965fb7b68a3e4582a7a74dc05bc30801597ace00849", + .url = "https://github.com/jetzig-framework/jetquery/archive/23c9741a407b6c4f93d9d21508f6568be747bdcc.tar.gz", + .hash = "122020374e5fd67d5836c0f3d7a8f814262aa076e3b90c1043f44184da1c2997e0bb", }, .jetcommon = .{ .url = "https://github.com/jetzig-framework/jetcommon/archive/a248776ba56d6cc2b160d593ac3305756adcd26e.tar.gz", diff --git a/cli/commands/auth.zig b/cli/commands/auth.zig index 9da3dd0..0b4432c 100644 --- a/cli/commands/auth.zig +++ b/cli/commands/auth.zig @@ -1,5 +1,6 @@ const std = @import("std"); const args = @import("args"); +const util = @import("../util.zig"); /// Command line options for the `update` command. pub const Options = struct { @@ -31,9 +32,9 @@ pub fn run( defer arena.deinit(); const alloc = arena.allocator(); - const Action = enum { password }; + const Action = enum { user_create }; const map = std.StaticStringMap(Action).initComptime(.{ - .{ "password", .password }, + .{ "user:create", .user_create }, }); const action = if (main_options.positionals.len > 0) @@ -54,26 +55,19 @@ pub fn run( break :blk error.JetzigCommandError; } else if (action) |capture| switch (capture) { - .password => blk: { + .user_create => blk: { if (sub_args.len < 1) { - std.debug.print("Missing argument. Expected a password paramater.\n", .{}); + std.debug.print("Missing argument. Expected an email/username parameter.\n", .{}); break :blk error.JetzigCommandError; } else { - const hash = try hashPassword(alloc, sub_args[0]); - try std.io.getStdOut().writer().print("Password hash: {s}\n", .{hash}); + try util.execCommand(allocator, &.{ + "zig", + "build", + util.environmentBuildOption(main_options.options.environment), + try std.mem.concat(allocator, u8, &.{ "-Dauth_username=", sub_args[0] }), + "jetzig:auth:user:create", + }); } }, }; } - -pub fn hashPassword(allocator: std.mem.Allocator, password: []const u8) ![]const u8 { - const buf = try allocator.alloc(u8, 128); - return try std.crypto.pwhash.argon2.strHash( - password, - .{ - .allocator = allocator, - .params = .{ .t = 3, .m = 32, .p = 4 }, - }, - buf, - ); -} diff --git a/demo/src/main.zig b/demo/src/main.zig index 934aa7c..336851c 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -78,6 +78,16 @@ pub const jetzig_options = struct { /// Database Schema. Set to `@import("Schema")` to load `src/app/database/Schema.zig`. pub const Schema = @import("Schema"); + /// HTTP cookie configuration + pub const cookies: jetzig.http.Cookies.CookieOptions = .{ + .domain = switch (jetzig.environment) { + .development => "localhost", + .testing => "localhost", + .production => "www.example.com", + }, + .path = "/", + }; + /// Key-value store options. Set backend to `.file` to use a file-based store. /// When using `.file` backend, you must also set `.file_options`. /// The key-value store is exposed as `request.store` in views and is also available in as diff --git a/src/commands/auth.zig b/src/commands/auth.zig new file mode 100644 index 0000000..3199266 --- /dev/null +++ b/src/commands/auth.zig @@ -0,0 +1,72 @@ +const std = @import("std"); + +const build_options = @import("build_options"); + +const jetquery = @import("jetquery"); +const jetzig = @import("jetzig"); +const Schema = @import("Schema"); +const Action = enum { @"user:create" }; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer std.debug.assert(gpa.deinit() == .ok); + + const gpa_allocator = gpa.allocator(); + var arena = std.heap.ArenaAllocator.init(gpa_allocator); + defer arena.deinit(); + + const allocator = arena.allocator(); + + const args = try std.process.argsAlloc(allocator); + + if (args.len < 3) return error.JetzigMissingArgument; + + const map = std.StaticStringMap(Action).initComptime(.{ + .{ "user:create", .@"user:create" }, + }); + + const action = map.get(args[1]) orelse return error.JetzigUnrecognizedArgument; + const env = try jetzig.Environment.init(allocator, .{ .silent = true }); + + switch (action) { + .@"user:create" => { + const Repo = jetzig.jetquery.Repo(jetzig.database.adapter, Schema); + var repo = try Repo.loadConfig( + allocator, + std.enums.nameCast(jetzig.jetquery.Environment, jetzig.environment), + .{ .env = try jetzig.database.repoEnv(env), .context = .cli }, + ); + const model = comptime jetzig.config.get(jetzig.auth.AuthOptions, "auth").user_model; + const stdin = std.io.getStdIn(); + const reader = stdin.reader(); + + if (stdin.isTty()) std.debug.print("Enter password: ", .{}); + + var buf: [1024]u8 = undefined; + if (try reader.readUntilDelimiterOrEof(&buf, '\n')) |input| { + const password = std.mem.trim(u8, input, &std.ascii.whitespace); + const email = args[2]; + + try repo.insert(std.enums.nameCast(std.meta.DeclEnum(Schema), model), .{ + .email = email, + .password_hash = try hashPassword(allocator, password), + }); + std.debug.print("Created user: `{s}`.\n", .{email}); + } else { + std.debug.print("Blank password. Exiting.\n", .{}); + } + }, + } +} + +fn hashPassword(allocator: std.mem.Allocator, password: []const u8) ![]const u8 { + const buf = try allocator.alloc(u8, 128); + return try std.crypto.pwhash.argon2.strHash( + password, + .{ + .allocator = allocator, + .params = .{ .t = 3, .m = 32, .p = 4 }, + }, + buf, + ); +} diff --git a/src/commands/database.zig b/src/commands/database.zig index 8607185..dd1aece 100644 --- a/src/commands/database.zig +++ b/src/commands/database.zig @@ -28,7 +28,7 @@ pub fn main() !void { const args = try std.process.argsAlloc(allocator); - if (args.len < 2) return error.JetzigMissingDatabaseArgument; + if (args.len < 2) return error.JetzigMissingArgument; const map = std.StaticStringMap(Action).initComptime(.{ .{ "migrate", .migrate }, @@ -37,7 +37,7 @@ pub fn main() !void { .{ "drop", .drop }, .{ "reflect", .reflect }, }); - const action = map.get(args[1]) orelse return error.JetzigUnrecognizedDatabaseArgument; + const action = map.get(args[1]) orelse return error.JetzigUnrecognizedArgument; switch (action) { .migrate => { diff --git a/src/commands/routes.zig b/src/commands/routes.zig index 97e24a8..d5a5599 100644 --- a/src/commands/routes.zig +++ b/src/commands/routes.zig @@ -13,7 +13,7 @@ pub fn main() !void { log("Jetzig Routes:", .{}); - const environment = jetzig.Environment.init(undefined); + const environment = jetzig.Environment.init(allocator, .{ .silent = true }); const initHook: ?*const fn (*jetzig.App) anyerror!void = if (@hasDecl(app, "init")) app.init else null; inline for (routes.routes) |route| max_uri_path_len = @max(route.uri_path.len + 5, max_uri_path_len); diff --git a/src/jetzig.zig b/src/jetzig.zig index ec95a65..231f149 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -28,7 +28,7 @@ pub const Time = jetcommon.types.Time; pub const Date = jetcommon.types.Date; pub const build_options = @import("build_options"); -pub const environment = build_options.environment; +pub const environment = std.enums.nameCast(Environment.EnvironmentName, build_options.environment); /// The primary interface for a Jetzig application. Create an `App` in your application's /// `src/main.zig` and call `start` to launch the application. @@ -85,7 +85,7 @@ pub const initHook: ?*const fn (*App) anyerror!void = if (@hasDecl(root, "init") /// Initialize a new Jetzig app. Call this from `src/main.zig` and then call /// `start(@import("routes").routes)` on the returned value. pub fn init(allocator: std.mem.Allocator) !App { - const env = try Environment.init(allocator); + const env = try Environment.init(allocator, .{}); return .{ .env = env, diff --git a/src/jetzig/Environment.zig b/src/jetzig/Environment.zig index 72b4081..d73ef51 100644 --- a/src/jetzig/Environment.zig +++ b/src/jetzig/Environment.zig @@ -21,16 +21,61 @@ log_queue: *jetzig.loggers.LogQueue, pub const EnvironmentName = enum { development, production, testing }; pub const Vars = struct { env_map: std.process.EnvMap, + env_file: ?EnvFile, + + pub const EnvFile = struct { + allocator: std.mem.Allocator, + hashmap: *std.StringHashMap([]const u8), + content: []const u8, + + pub fn init(allocator: std.mem.Allocator, file: std.fs.File) !EnvFile { + const stat = try file.stat(); + const content = try file.readToEndAlloc(allocator, stat.size); + file.close(); + const hashmap = try allocator.create(std.StringHashMap([]const u8)); + hashmap.* = std.StringHashMap([]const u8).init(allocator); + var it = std.mem.tokenizeScalar(u8, content, '\n'); + while (it.next()) |line| { + const stripped = jetzig.util.strip(line); + if (std.mem.startsWith(u8, stripped, "#")) continue; + const equals_index = std.mem.indexOfScalar(u8, stripped, '=') orelse continue; + const name = stripped[0..equals_index]; + const value = if (equals_index + 1 < stripped.len) stripped[equals_index + 1 ..] else ""; + try hashmap.put(name, jetzig.util.unquote(value)); + } + + return .{ .allocator = allocator, .hashmap = hashmap, .content = content }; + } + + pub fn deinit(self: EnvFile) void { + self.hashmap.deinit(); + self.allocator.destroy(self.hashmap); + self.allocator.free(self.content); + } + }; + + pub fn init(allocator: std.mem.Allocator, env_file: ?std.fs.File) !Vars { + return .{ + .env_file = if (env_file) |file| try EnvFile.init(allocator, file) else null, + .env_map = try std.process.getEnvMap(allocator), + }; + } + + pub fn deinit(self: Vars) void { + var env_map = self.env_map; + env_map.deinit(); + } pub fn get(self: Vars, key: []const u8) ?[]const u8 { - return self.env_map.get(key); + const env_file = self.env_file orelse return self.env_map.get(key); + return env_file.hashmap.get(key) orelse self.env_map.get(key); } pub fn getT(self: Vars, T: type, key: []const u8) !switch (@typeInfo(T)) { .bool => T, else => ?T, } { - const value = self.env_map.get(key) orelse return if (@typeInfo(T) == .bool) + const value = self.get(key) orelse return if (@typeInfo(T) == .bool) false else null; @@ -48,11 +93,6 @@ pub const Vars = struct { }; } - pub fn deinit(self: Vars) void { - var env_map = self.env_map; - env_map.deinit(); - } - fn parseEnum(E: type, value: []const u8) ?E { return std.meta.stringToEnum(E, value); } @@ -65,8 +105,11 @@ const Options = struct { log: []const u8 = "-", @"log-error": []const u8 = "-", @"log-level": ?jetzig.loggers.LogLevel = null, - // TODO: Create a production logger and select default logger based on environment. - @"log-format": jetzig.loggers.LogFormat = .development, + @"log-format": jetzig.loggers.LogFormat = switch (jetzig.environment) { + .development, .testing => .development, + .production => .production, + }, + @"env-file": []const u8 = ".env", detach: bool = false, pub const shorthands = .{ @@ -95,6 +138,9 @@ const Options = struct { .detach = \\Run the server in the background. Must be used in conjunction with --log (default: false) , + .@"env-file" = + \\Load environment variables from a file. Variables defined in this file take precedence over process environment variables. + , .help = "Print help and exit", }, }; @@ -103,6 +149,7 @@ const Options = struct { const LaunchLogger = struct { stdout: std.fs.File, stderr: std.fs.File, + silent: bool = false, pub fn log( self: LaunchLogger, @@ -110,6 +157,8 @@ const LaunchLogger = struct { comptime message: []const u8, log_args: anytype, ) !void { + if (self.silent) return; + const target = @field(self, @tagName(jetzig.loggers.logTarget(level))); const writer = target.writer(); try writer.print( @@ -119,7 +168,11 @@ const LaunchLogger = struct { } }; -pub fn init(parent_allocator: std.mem.Allocator) !Environment { +pub const EnvironmentOptions = struct { + silent: bool = false, +}; + +pub fn init(parent_allocator: std.mem.Allocator, env_options: EnvironmentOptions) !Environment { const arena = try parent_allocator.create(std.heap.ArenaAllocator); arena.* = std.heap.ArenaAllocator.init(parent_allocator); const allocator = arena.allocator(); @@ -127,12 +180,13 @@ pub fn init(parent_allocator: std.mem.Allocator) !Environment { const options = try args.parseForCurrentProcess(Options, allocator, .print); defer options.deinit(); + const stdout = try getLogFile(.stdout, options.options); + const stderr = try getLogFile(.stdout, options.options); + const log_queue = try allocator.create(jetzig.loggers.LogQueue); log_queue.* = jetzig.loggers.LogQueue.init(allocator); - try log_queue.setFiles( - try getLogFile(.stdout, options.options), - try getLogFile(.stderr, options.options), - ); + + try log_queue.setFiles(stdout, stderr); if (options.options.help) { const writer = std.io.getStdErr().writer(); @@ -140,26 +194,40 @@ pub fn init(parent_allocator: std.mem.Allocator) !Environment { std.process.exit(0); } - const environment = std.enums.nameCast(EnvironmentName, jetzig.environment); - const vars = Vars{ .env_map = try std.process.getEnvMap(allocator) }; + const env_file = std.fs.cwd().openFile(options.options.@"env-file", .{}) catch |err| + switch (err) { + error.FileNotFound => null, + else => return err, + }; + + const vars = try Vars.init(allocator, env_file); var launch_logger = LaunchLogger{ - .stdout = try getLogFile(.stdout, options.options), - .stderr = try getLogFile(.stdout, options.options), + .stdout = stdout, + .stderr = stderr, + .silent = env_options.silent, }; const logger = switch (options.options.@"log-format") { .development => jetzig.loggers.Logger{ .development_logger = jetzig.loggers.DevelopmentLogger.init( allocator, - resolveLogLevel(options.options.@"log-level", environment), + resolveLogLevel(options.options.@"log-level", jetzig.environment), + stdout, + stderr, + ), + }, + .production => jetzig.loggers.Logger{ + .production_logger = jetzig.loggers.ProductionLogger.init( + allocator, + resolveLogLevel(options.options.@"log-level", jetzig.environment), log_queue, ), }, .json => jetzig.loggers.Logger{ .json_logger = jetzig.loggers.JsonLogger.init( allocator, - resolveLogLevel(options.options.@"log-level", environment), + resolveLogLevel(options.options.@"log-level", jetzig.environment), log_queue, ), }, @@ -171,7 +239,7 @@ pub fn init(parent_allocator: std.mem.Allocator) !Environment { } const secret_len = jetzig.http.Session.Cipher.key_length; - const secret_value = try getSecret(allocator, launch_logger, secret_len, environment); + const secret_value = try getSecret(allocator, launch_logger, secret_len, jetzig.environment); const secret = if (secret_value.len > secret_len) secret_value[0..secret_len] else secret_value; if (secret.len != secret_len) { @@ -200,8 +268,9 @@ pub fn init(parent_allocator: std.mem.Allocator) !Environment { "Using `{s}` database adapter with database: `{s}`.", .{ @tagName(jetzig.database.adapter), - switch (environment) { - inline else => |tag| @field(jetzig.jetquery.config.database, @tagName(tag)).database, + switch (jetzig.environment) { + inline else => |tag| vars.get("JETQUERY_DATABASE") orelse + @field(jetzig.jetquery.config.database, @tagName(tag)).database, }, }, ); @@ -216,7 +285,7 @@ pub fn init(parent_allocator: std.mem.Allocator) !Environment { .bind = try allocator.dupe(u8, options.options.bind), .port = options.options.port, .detach = options.options.detach, - .environment = environment, + .environment = jetzig.environment, .vars = vars, .log_queue = log_queue, }; diff --git a/src/jetzig/database.zig b/src/jetzig/database.zig index b1c4283..8289610 100644 --- a/src/jetzig/database.zig +++ b/src/jetzig/database.zig @@ -2,7 +2,11 @@ const std = @import("std"); const jetzig = @import("../jetzig.zig"); -pub const adapter = @field(jetzig.jetquery.config.database, @tagName(jetzig.environment)).adapter; +pub const adapter = std.enums.nameCast( + jetzig.jetquery.adapters.Name, + @field(jetzig.jetquery.config.database, @tagName(jetzig.environment)).adapter, +); + pub const Schema = jetzig.config.get(type, "Schema"); pub const Repo = jetzig.jetquery.Repo(adapter, Schema); @@ -11,7 +15,6 @@ pub fn Query(comptime model: anytype) type { } pub fn repo(allocator: std.mem.Allocator, app: anytype) !Repo { - // XXX: Is this terrible ? const Callback = struct { var jetzig_app: @TypeOf(app) = undefined; pub fn callbackFn(event: jetzig.jetquery.events.Event) !void { @@ -25,7 +28,12 @@ pub fn repo(allocator: std.mem.Allocator, app: anytype) !Repo { std.enums.nameCast(jetzig.jetquery.Environment, jetzig.environment), .{ .eventCallback = Callback.callbackFn, - .lazy_connect = jetzig.environment == .development, + .lazy_connect = switch (jetzig.environment) { + .development, .production => true, + .testing => false, + }, + // Checking field presence here makes setting up test App a bit simpler. + .env = if (@hasField(@TypeOf(app), "env")) try repoEnv(app.env) else .{}, }, ); } @@ -36,3 +44,16 @@ fn eventCallback(event: jetzig.jetquery.events.Event, app: anytype) !void { try app.server.logger.ERROR("[database] {?s}", .{err.message}); } } + +pub fn repoEnv(env: jetzig.Environment) !Repo.AdapterOptions { + return switch (comptime adapter) { + .null => .{}, + .postgresql => .{ + .hostname = @as(?[]const u8, env.vars.get("JETQUERY_HOSTNAME")), + .port = @as(?u16, try env.vars.getT(u16, "JETQUERY_PORT")), + .username = @as(?[]const u8, env.vars.get("JETQUERY_USERNAME")), + .password = @as(?[]const u8, env.vars.get("JETQUERY_PASSWORD")), + .database = @as(?[]const u8, env.vars.get("JETQUERY_DATABASE")), + }, + }; +} diff --git a/src/jetzig/loggers.zig b/src/jetzig/loggers.zig index 7c8502c..184d2ab 100644 --- a/src/jetzig/loggers.zig +++ b/src/jetzig/loggers.zig @@ -7,10 +7,12 @@ const Self = @This(); pub const DevelopmentLogger = @import("loggers/DevelopmentLogger.zig"); pub const JsonLogger = @import("loggers/JsonLogger.zig"); pub const TestLogger = @import("loggers/TestLogger.zig"); +pub const ProductionLogger = @import("loggers/ProductionLogger.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 }; +pub const LogFormat = enum { development, production, json }; /// Infer a log target (stdout or stderr) from a given log level. pub inline fn logTarget(comptime level: LogLevel) LogQueue.Target { @@ -23,6 +25,7 @@ pub const Logger = union(enum) { development_logger: DevelopmentLogger, json_logger: JsonLogger, test_logger: TestLogger, + production_logger: ProductionLogger, /// Log a TRACE level message to the configured logger. pub fn TRACE(self: *const Logger, comptime message: []const u8, args: anytype) !void { diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig index b8cd69a..91b77d0 100644 --- a/src/jetzig/loggers/DevelopmentLogger.zig +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -8,25 +8,30 @@ 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, -log_queue: *jetzig.loggers.LogQueue, mutex: *std.Thread.Mutex, /// Initialize a new Development Logger. pub fn init( allocator: std.mem.Allocator, level: LogLevel, - log_queue: *jetzig.loggers.LogQueue, + stdout: std.fs.File, + stderr: std.fs.File, ) DevelopmentLogger { const mutex = allocator.create(std.Thread.Mutex) catch unreachable; + mutex.* = std.Thread.Mutex{}; + return .{ .allocator = allocator, .level = level, - .log_queue = log_queue, - .stdout_colorized = log_queue.stdout_is_tty, - .stderr_colorized = log_queue.stderr_is_tty, + .stdout = stdout, + .stderr = stderr, + .stdout_colorized = stdout.isTty(), + .stderr_colorized = stderr.isTty(), .mutex = mutex, }; } @@ -38,6 +43,9 @@ pub fn log( comptime message: []const u8, args: anytype, ) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + if (@intFromEnum(level) < @intFromEnum(self.level)) return; const output = try std.fmt.allocPrint(self.allocator, message, args); @@ -47,13 +55,11 @@ pub fn log( var timestamp_buf: [256]u8 = undefined; const iso8601 = try timestamp.iso8601(×tamp_buf); - const target = jetzig.loggers.logTarget(level); const formatted_level = colorizedLogLevel(level); - try self.log_queue.print( + try self.logWriter(level).print( "{s: >5} [{s}] {s}\n", .{ formatted_level, iso8601, output }, - target, ); } @@ -87,7 +93,7 @@ pub fn logRequest(self: DevelopmentLogger, request: *const jetzig.http.Request) const formatted_level = if (self.stdout_colorized) colorizedLogLevel(.INFO) else @tagName(.INFO); - try self.log_queue.print("{s: >5} [{s}] [{s}/{s}/{s}]{s}{s}{s}{s}{s}{s}{s}{s}{s}{s} {s}\n", .{ + try self.logWriter(.INFO).print("{s: >5} [{s}] [{s}/{s}/{s}]{s}{s}{s}{s}{s}{s}{s}{s}{s}{s} {s}\n", .{ formatted_level, iso8601, formatted_duration, @@ -104,22 +110,15 @@ pub fn logRequest(self: DevelopmentLogger, request: *const jetzig.http.Request) if (request.middleware_rendered) |_| jetzig.colors.codes.escape ++ jetzig.colors.codes.reset else "", if (request.middleware_rendered) |_| "]" else "", request.path.path, - }, .stdout); + }); } pub fn logSql(self: *const DevelopmentLogger, event: jetzig.jetquery.events.Event) !void { - self.mutex.lock(); - defer self.mutex.unlock(); - // XXX: This function does not make any effort to prevent log messages clobbering each other - // from multiple threads. JSON logger etc. write in one call and the logger's mutex prevents + // from multiple threads. JSON logger etc. write in one call and the log queue prevents // clobbering, but this is not the case here. const formatted_level = if (self.stdout_colorized) colorizedLogLevel(.INFO) else @tagName(.INFO); - try self.log_queue.print( - "{s} [database] ", - .{formatted_level}, - .stdout, - ); + try self.logWriter(.INFO).print("{s} [database] ", .{formatted_level}); try self.printSql(event.sql orelse ""); var duration_buf: [256]u8 = undefined; @@ -129,10 +128,9 @@ pub fn logSql(self: *const DevelopmentLogger, event: jetzig.jetquery.events.Even self.stdout_colorized, ) else ""; - try self.log_queue.print( + try self.logWriter(.INFO).print( std.fmt.comptimePrint(" [{s}]\n", .{jetzig.colors.cyan("{s}")}), .{formatted_duration}, - .stdout, ); } @@ -170,6 +168,9 @@ fn printSql(self: *const DevelopmentLogger, sql: []const u8) !void { const string_color = jetzig.colors.codes.escape ++ jetzig.colors.codes.green; const identifier_color = jetzig.colors.codes.escape ++ jetzig.colors.codes.yellow; const reset_color = jetzig.colors.codes.escape ++ jetzig.colors.codes.reset; + var buf: [4096]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const writer = stream.writer(); var index: usize = 0; var single_quote: bool = false; @@ -181,9 +182,9 @@ fn printSql(self: *const DevelopmentLogger, sql: []const u8) !void { if (!single_quote) { double_quote = !double_quote; if (double_quote) { - try self.log_queue.print(identifier_color ++ "\"", .{}, .stdout); + try writer.print(identifier_color ++ "\"", .{}); } else { - try self.log_queue.print("\"" ++ reset_color, .{}, .stdout); + try writer.print("\"" ++ reset_color, .{}); } index += 1; } @@ -192,16 +193,16 @@ fn printSql(self: *const DevelopmentLogger, sql: []const u8) !void { if (!double_quote) { single_quote = !single_quote; if (single_quote) { - try self.log_queue.print(string_color ++ "'", .{}, .stdout); + try writer.print(string_color ++ "'", .{}); } else { - try self.log_queue.print("'" ++ reset_color, .{}, .stdout); + try writer.print("'" ++ reset_color, .{}); } } index += 1; }, '$' => { if (double_quote or single_quote) { - try self.log_queue.print("{c}", .{sql[index]}, .stdout); + try writer.print("{c}", .{sql[index]}); index += 1; } else { const param = sql[index..][0 .. std.mem.indexOfAny( @@ -209,29 +210,30 @@ fn printSql(self: *const DevelopmentLogger, sql: []const u8) !void { sql[index..], &std.ascii.whitespace, ) orelse sql.len - index]; - try self.log_queue.print(jetzig.colors.magenta("{s}"), .{param}, .stdout); + try writer.print(jetzig.colors.magenta("{s}"), .{param}); index += param.len; } }, else => { if (double_quote or single_quote) { - try self.log_queue.print("{c}", .{sql[index]}, .stdout); + try writer.print("{c}", .{sql[index]}); index += 1; } else { inline for (sql_tokens) |token| { if (std.mem.startsWith(u8, sql[index..], token)) { - try self.log_queue.print(jetzig.colors.cyan(token), .{}, .stdout); + try writer.print(jetzig.colors.cyan(token), .{}); index += token.len; break; } } else { - try self.log_queue.print("{c}", .{sql[index]}, .stdout); + try writer.print("{c}", .{sql[index]}); index += 1; } } }, } } + try self.logWriter(.INFO).print("{s}", .{stream.getWritten()}); } pub fn logError(self: *const DevelopmentLogger, err: anyerror) !void { @@ -247,6 +249,14 @@ pub fn logError(self: *const DevelopmentLogger, err: anyerror) !void { try self.log(.ERROR, "Encountered Error: {s}", .{@errorName(err)}); } +fn logWriter(self: DevelopmentLogger, comptime level: jetzig.loggers.LogLevel) std.fs.File.Writer { + const target = comptime jetzig.loggers.logTarget(level); + return switch (target) { + .stdout => self.stdout, + .stderr => self.stderr, + }.writer(); +} + inline fn colorizedLogLevel(comptime level: LogLevel) []const u8 { return switch (level) { .TRACE => jetzig.colors.white(@tagName(level)), diff --git a/src/jetzig/loggers/ProductionLogger.zig b/src/jetzig/loggers/ProductionLogger.zig new file mode 100644 index 0000000..5309a41 --- /dev/null +++ b/src/jetzig/loggers/ProductionLogger.zig @@ -0,0 +1,146 @@ +const std = @import("std"); + +const jetzig = @import("../../jetzig.zig"); + +const ProductionLogger = @This(); + +const Timestamp = jetzig.types.Timestamp; +const LogLevel = jetzig.loggers.LogLevel; + +allocator: std.mem.Allocator, +level: LogLevel, +log_queue: *jetzig.loggers.LogQueue, + +/// Initialize a new Development Logger. +pub fn init( + allocator: std.mem.Allocator, + level: LogLevel, + log_queue: *jetzig.loggers.LogQueue, +) ProductionLogger { + return .{ + .allocator = allocator, + .level = level, + .log_queue = log_queue, + }; +} + +/// Generic log function, receives log level, message (format string), and args for format string. +pub fn log( + self: *const ProductionLogger, + comptime level: LogLevel, + comptime message: []const u8, + args: anytype, +) !void { + if (@intFromEnum(level) < @intFromEnum(self.level)) return; + + const output = try std.fmt.allocPrint(self.allocator, message, args); + defer self.allocator.free(output); + + const timestamp = Timestamp.init(std.time.timestamp()); + var timestamp_buf: [256]u8 = undefined; + const iso8601 = try timestamp.iso8601(×tamp_buf); + + const target = jetzig.loggers.logTarget(level); + + try self.log_queue.print( + "{s} [{s}] {s}\n", + .{ @tagName(level), iso8601, output }, + target, + ); +} + +/// Log a one-liner including response status code, path, method, duration, etc. +pub fn logRequest(self: ProductionLogger, request: *const jetzig.http.Request) !void { + if (@intFromEnum(LogLevel.INFO) < @intFromEnum(self.level)) return; + + var duration_buf: [256]u8 = undefined; + const formatted_duration = try jetzig.colors.duration( + &duration_buf, + jetzig.util.duration(request.start_time), + false, + ); + + const status: jetzig.http.status_codes.TaggedStatusCode = switch (request.response.status_code) { + inline else => |status_code| @unionInit( + jetzig.http.status_codes.TaggedStatusCode, + @tagName(status_code), + .{}, + ), + }; + + const formatted_status = status.getFormatted(.{}); + const timestamp = Timestamp.init(std.time.timestamp()); + var timestamp_buf: [256]u8 = undefined; + const iso8601 = try timestamp.iso8601(×tamp_buf); + + const formatted_level = @tagName(.INFO); + + try self.log_queue.print("{s} [{s}] [{s}/{s}/{s}]{s}{s}{s}{s}{s}{s}{s} {s}\n", .{ + formatted_level, + iso8601, + formatted_duration, + request.fmtMethod(false), + formatted_status, + if (request.middleware_rendered) |_| " [" else "", + if (request.middleware_rendered) |middleware| middleware.name else "", + if (request.middleware_rendered) |_| ":" else "", + if (request.middleware_rendered) |middleware| middleware.action else "", + if (request.middleware_rendered) |_| ":" else "", + if (request.middleware_rendered) |_| if (request.redirected) "redirect" else "render" else "", + if (request.middleware_rendered) |_| "]" else "", + request.path.path, + }, .stdout); +} + +pub fn logSql(self: *const ProductionLogger, event: jetzig.jetquery.events.Event) !void { + var duration_buf: [256]u8 = undefined; + const formatted_duration = if (event.duration) |duration| try jetzig.colors.duration( + &duration_buf, + duration, + false, + ) else ""; + + const timestamp = Timestamp.init(std.time.timestamp()); + var timestamp_buf: [256]u8 = undefined; + const iso8601 = try timestamp.iso8601(×tamp_buf); + + try self.log_queue.print( + "{s} [{s}] [database] [sql:{s}] [duration:{s}]\n", + .{ @tagName(.INFO), iso8601, event.sql orelse "", formatted_duration }, + .stdout, + ); +} + +const sql_tokens = .{ + "SELECT", + "INSERT", + "UPDATE", + "DELETE", + "WHERE", + "SET", + "ANY", + "FROM", + "INTO", + "IN", + "ON", + "IS", + "NOT", + "NULL", + "LIMIT", + "ORDER BY", + "GROUP BY", + "HAVING", + "LEFT OUTER JOIN", + "INNER JOIN", + "ASC", + "DESC", + "MAX", + "MIN", + "COUNT", + "SUM", + "VALUES", +}; + +pub fn logError(self: *const ProductionLogger, err: anyerror) !void { + try self.log(.ERROR, "Encountered Error: {s}", .{@errorName(err)}); +} diff --git a/src/jetzig/mail/Mail.zig b/src/jetzig/mail/Mail.zig index da5efca..af85e8e 100644 --- a/src/jetzig/mail/Mail.zig +++ b/src/jetzig/mail/Mail.zig @@ -361,7 +361,7 @@ test "environment SMTP config" { try env_map.put("JETZIG_SMTP_USERNAME", "example-username"); try env_map.put("JETZIG_SMTP_PASSWORD", "example-password"); - env.vars = jetzig.Environment.Vars{ .env_map = env_map }; + env.vars = jetzig.Environment.Vars{ .env_map = env_map, .env_file = null }; const mail = Mail{ .allocator = undefined, diff --git a/src/jetzig/testing/App.zig b/src/jetzig/testing/App.zig index 7230b67..9df1e04 100644 --- a/src/jetzig/testing/App.zig +++ b/src/jetzig/testing/App.zig @@ -114,7 +114,10 @@ pub fn request( // We init the `std.process.EnvMap` directly here (instead of calling `std.process.getEnvMap` // to ensure that tests run in a clean environment. Users can manually add items to the // environment within a test if required. - const vars = jetzig.Environment.Vars{ .env_map = std.process.EnvMap.init(allocator) }; + const vars = jetzig.Environment.Vars{ + .env_map = std.process.EnvMap.init(allocator), + .env_file = null, + }; var server = jetzig.http.Server{ .allocator = allocator, .logger = self.logger, diff --git a/src/jetzig/util.zig b/src/jetzig/util.zig index f60535d..0391b3f 100644 --- a/src/jetzig/util.zig +++ b/src/jetzig/util.zig @@ -57,6 +57,15 @@ pub inline fn strip(input: []const u8) []const u8 { return std.mem.trim(u8, input, &std.ascii.whitespace); } +pub inline fn unquote(input: []const u8) []const u8 { + return if (std.mem.startsWith(u8, input, "\"") and std.mem.endsWith(u8, input, "\"")) + std.mem.trim(u8, input, "\"") + else if (std.mem.startsWith(u8, input, "'") and std.mem.endsWith(u8, input, "'")) + std.mem.trim(u8, input, "'") + else + input; +} + /// Generate a secure random string of `len` characters (for cryptographic purposes). pub fn generateSecret(allocator: std.mem.Allocator, comptime len: u10) ![]const u8 { const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";