diff --git a/build.zig b/build.zig index e64f999..bfba4e2 100644 --- a/build.zig +++ b/build.zig @@ -7,7 +7,10 @@ const zmpl_build = @import("zmpl"); const Environment = enum { development, testing, production }; const builtin = @import("builtin"); -const use_llvm_default = builtin.os.tag != .linux; +const use_llvm_default = switch (builtin.cpu.arch) { + .x86, .x86_64 => builtin.os.tag != .linux, + else => true, +}; pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); @@ -73,6 +76,7 @@ pub fn build(b: *std.Build) !void { .target = target, .optimize = optimize, .jetquery_migrations_path = @as([]const u8, "src/app/database/migrations"), + .jetquery_seeders_path = @as([]const u8, "src/app/database/seeders"), .jetquery_config_path = @as([]const u8, "config/database.zig"), }); const jetcommon_dep = b.dependency("jetcommon", .{ .target = target, .optimize = optimize }); @@ -87,6 +91,10 @@ pub fn build(b: *std.Build) !void { b.modules.put("jetquery", jetquery_dep.module("jetquery")) catch @panic("Out of memory"); b.modules.put("jetcommon", jetcommon_dep.module("jetcommon")) catch @panic("Out of memory"); b.modules.put("jetquery_migrate", jetquery_dep.module("jetquery_migrate")) catch @panic("Out of memory"); + b.modules.put("jetquery_seeder", jetquery_dep.module("jetquery_seeder")) catch @panic("Out of memory"); + + jetquery_dep.module("jetquery_seeder").addImport("jetzig", jetzig_module); + jetquery_dep.module("jetquery_migrate").addImport("jetzig", jetzig_module); const smtp_client_dep = b.dependency("smtp_client", .{ .target = target, @@ -185,6 +193,7 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn .target = target, .optimize = optimize, .jetquery_migrations_path = @as([]const u8, "src/app/database/migrations"), + .jetquery_seeders_path = @as([]const u8, "src/app/database/seeders"), .jetquery_config_path = @as([]const u8, "config/database.zig"), }); @@ -194,8 +203,12 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn const jetquery_module = jetzig_dep.module("jetquery"); const jetcommon_module = jetzig_dep.module("jetcommon"); const jetquery_migrate_module = jetzig_dep.module("jetquery_migrate"); + const jetquery_seeder_module = jetzig_dep.module("jetquery_seeder"); const jetquery_reflect_module = jetquery_dep.module("jetquery_reflect"); + jetquery_dep.module("jetquery_seeders").addImport("jetzig", jetzig_module); + jetquery_dep.module("jetquery_migrations").addImport("jetzig", jetzig_module); + const build_options = b.addOptions(); build_options.addOption(Environment, "environment", environment); build_options.addOption(bool, "build_static", build_static); @@ -414,6 +427,7 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn exe_database.root_module.addImport("jetzig", jetzig_module); exe_database.root_module.addImport("jetcommon", jetcommon_module); exe_database.root_module.addImport("jetquery_migrate", jetquery_migrate_module); + exe_database.root_module.addImport("jetquery_seeder", jetquery_seeder_module); exe_database.root_module.addImport("jetquery_reflect", jetquery_reflect_module); exe_database.root_module.addImport("Schema", schema_module); @@ -432,6 +446,7 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn fn registerDatabaseSteps(b: *std.Build, exe_database: *std.Build.Step.Compile) void { const commands = .{ .{ "migrate", "Migrate your Jetzig app's database." }, + .{ "seed", "Run seeds and set up initial data." }, .{ "rollback", "Roll back a migration in your Jetzig app's database." }, .{ "create", "Create a database for your Jetzig app." }, .{ "drop", "Drop your Jetzig app's database." }, diff --git a/build.zig.zon b/build.zig.zon index e087537..c17043c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -25,17 +25,16 @@ .hash = "jetkv-0.0.0-zCv0fmCGAgCyYqwHjk0P5KrYVRew1MJAtbtAcIO-WPpT", }, .zmpl = .{ - // .url = "https://github.com/jetzig-framework/zmpl/archive/89ee0ce9b4c96c316cc0575266fb66c864f24a49.tar.gz", - // .hash = "zmpl-0.0.1-SYFGBtuNAwCj2YbqnoEJt3bk1iFIZjGK6JwMc72toZBR", - .path = "../zmpl", + .url = "https://github.com/jetzig-framework/zmpl/archive/febec2dd477adadf09c67676ac4bf2079046b1d6.tar.gz", + .hash = "zmpl-0.0.1-SYFGBhilAwAoY1evzcCHqpNFZf1zuB6IhY0P2w-bgM3t", }, .httpz = .{ .url = "https://github.com/karlseguin/http.zig/archive/37d7cb9819b804ade5f4b974b82f8dd0622225ed.tar.gz", .hash = "httpz-0.0.0-PNVzrEK4BgBpHQGA2m0RPqPGEjnTdDXHodBwzjYDrmps", }, .jetquery = .{ - .url = "https://github.com/jetzig-framework/jetquery/archive/e1f969f2e3e0e1ad9cc30d56fde9739aa692fdc3.tar.gz", - .hash = "jetquery-0.0.0-TNf3zo2ABgBgcsIAvJ1Ud2B2zDzrBy9GQ31kKmTYZ7Ya", + .url = "https://github.com/jetzig-framework/jetquery/archive/907acae15dd36834dbdab06b17c3ba8f576d77cb.tar.gz", + .hash = "jetquery-0.0.0-TNf3zm-5BgDOtCpRuVLEZQMWjkgKWRe1pNlGhrdoyvYE", }, }, diff --git a/cli/build.zig b/cli/build.zig index a0fc17f..7bc84ca 100644 --- a/cli/build.zig +++ b/cli/build.zig @@ -18,6 +18,7 @@ pub fn build(b: *std.Build) !void { .target = target, .optimize = optimize, .jetquery_migrations_path = @as([]const u8, "src/app/database/migrations"), + .jetquery_seeders_path = @as([]const u8, "src/app/database/seeders"), }); exe.root_module.addImport("jetquery", jetquery_dep.module("jetquery")); exe.root_module.addImport("args", zig_args_dep.module("args")); diff --git a/cli/build.zig.zon b/cli/build.zig.zon index bb04aa6..8ea4cd1 100644 --- a/cli/build.zig.zon +++ b/cli/build.zig.zon @@ -10,8 +10,8 @@ .hash = "zig_args-0.0.0-jqtN6P_NAAC97fGpk9hS2K681jkiqPsWP6w3ucb_ctGH", }, .jetquery = .{ - .url = "https://github.com/jetzig-framework/jetquery/archive/e1f969f2e3e0e1ad9cc30d56fde9739aa692fdc3.tar.gz", - .hash = "jetquery-0.0.0-TNf3zo2ABgBgcsIAvJ1Ud2B2zDzrBy9GQ31kKmTYZ7Ya", + .url = "https://github.com/jetzig-framework/jetquery/archive/907acae15dd36834dbdab06b17c3ba8f576d77cb.tar.gz", + .hash = "jetquery-0.0.0-TNf3zm-5BgDOtCpRuVLEZQMWjkgKWRe1pNlGhrdoyvYE", }, }, .paths = .{ diff --git a/cli/commands/database.zig b/cli/commands/database.zig index 256019d..9df480a 100644 --- a/cli/commands/database.zig +++ b/cli/commands/database.zig @@ -5,6 +5,7 @@ const args = @import("args"); const util = @import("../util.zig"); const cli = @import("../cli.zig"); const migrate = @import("database/migrate.zig"); +const seed = @import("database/seed.zig"); const rollback = @import("database/rollback.zig"); const create = @import("database/create.zig"); const drop = @import("database/drop.zig"); @@ -41,9 +42,19 @@ pub fn run( defer arena.deinit(); const alloc = arena.allocator(); - const Action = enum { migrate, rollback, create, drop, reflect, update, setup }; + const Action = enum { + migrate, + seed, + rollback, + create, + drop, + reflect, + update, + setup, + }; const map = std.StaticStringMap(Action).initComptime(.{ .{ "migrate", .migrate }, + .{ "seed", .seed }, .{ "rollback", .rollback }, .{ "create", .create }, .{ "drop", .drop }, @@ -74,6 +85,7 @@ pub fn run( break :blk switch (capture) { .migrate => migrate.run(alloc, cwd, sub_args, options, T, main_options), + .seed => seed.run(alloc, cwd, sub_args, options, T, main_options), .rollback => rollback.run(alloc, cwd, sub_args, options, T, main_options), .create => create.run(alloc, cwd, sub_args, options, T, main_options), .drop => drop.run(alloc, cwd, sub_args, options, T, main_options), diff --git a/cli/commands/database/seed.zig b/cli/commands/database/seed.zig new file mode 100644 index 0000000..a497ab8 --- /dev/null +++ b/cli/commands/database/seed.zig @@ -0,0 +1,36 @@ +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( + \\Run database seeders. + \\ + \\Example: + \\ + \\ jetzig database seed + \\ jetzig --environment=testing database seed + \\ + , .{}); + + return if (main_options.options.help) {} else error.JetzigCommandError; + } + + try util.execCommand(allocator, &.{ + "zig", + "build", + util.environmentBuildOption(main_options.options.environment), + "jetzig:database:seed", + }); +} diff --git a/cli/commands/generate.zig b/cli/commands/generate.zig index bb6b3b9..b3c3ad7 100644 --- a/cli/commands/generate.zig +++ b/cli/commands/generate.zig @@ -10,11 +10,12 @@ const middleware = @import("generate/middleware.zig"); const job = @import("generate/job.zig"); const mailer = @import("generate/mailer.zig"); const migration = @import("generate/migration.zig"); +const seeder = @import("generate/seeder.zig"); /// Command line options for the `generate` command. pub const Options = struct { pub const meta = .{ - .usage_summary = "[view|partial|layout|mailer|middleware|job|secret|migration] [options]", + .usage_summary = "[view|partial|layout|mailer|middleware|job|secret|migration|seeder] [options]", .full_text = \\Generate scaffolding for views, middleware, and other objects. \\ @@ -39,7 +40,17 @@ pub fn run( _ = options; - const Generator = enum { view, partial, layout, mailer, middleware, job, secret, migration }; + const Generator = enum { + view, + partial, + layout, + mailer, + middleware, + job, + secret, + migration, + seeder, + }; var sub_args = std.ArrayList([]const u8).init(allocator); defer sub_args.deinit(); @@ -55,6 +66,7 @@ pub fn run( .{ "middleware", .middleware }, .{ "secret", .secret }, .{ "migration", .migration }, + .{ "seeder", .seeder }, }); for (map.keys()) |key| try available_buf.append(key); @@ -92,6 +104,7 @@ pub fn run( .middleware => middleware.run(arena, cwd, sub_args.items, main_options.options.help), .secret => secret.run(arena, cwd, sub_args.items, main_options.options.help), .migration => migration.run(arena, cwd, sub_args.items, main_options.options.help), + .seeder => seeder.run(arena, cwd, sub_args.items, main_options.options.help), }; } } diff --git a/cli/commands/generate/seeder.zig b/cli/commands/generate/seeder.zig new file mode 100644 index 0000000..10936a8 --- /dev/null +++ b/cli/commands/generate/seeder.zig @@ -0,0 +1,40 @@ +const std = @import("std"); + +const jetquery = @import("jetquery"); + +/// Run the seeder generator. Create a seed in `src/app/database/seeders/` +pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8, help: bool) !void { + if (help or args.len < 1) { + std.debug.print( + \\Generate a new Seeder. Seeders is a way to set up some inital data for your application. + \\ + \\Example: + \\ + \\ jetzig generate seeder iguana + \\ + \\ More information: https://www.jetzig.dev/documentation/sections/database/command_line_tools + \\ + , .{}); + + if (help) return; + + return error.JetzigCommandError; + } + + const name = args[0]; + + const seeders_dir = try cwd.makeOpenPath( + try std.fs.path.join(allocator, &.{ "src", "app", "database", "seeders" }), + .{}, + ); + const seed = jetquery.Seeder.init( + allocator, + name, + .{ + .seeders_path = try seeders_dir.realpathAlloc(allocator, "."), + }, + ); + const path = try seed.save(); + + std.log.info("Saved seed: {s}", .{path}); +} diff --git a/demo/src/app/database/migrations/2025-03-10_01-36-58_create_users.zig b/demo/src/app/database/migrations/2025-03-10_01-36-58_create_users.zig index da76936..6ac23c3 100644 --- a/demo/src/app/database/migrations/2025-03-10_01-36-58_create_users.zig +++ b/demo/src/app/database/migrations/2025-03-10_01-36-58_create_users.zig @@ -1,6 +1,7 @@ const std = @import("std"); const jetquery = @import("jetquery"); const t = jetquery.schema.table; +const jetzig = @import("jetzig"); pub fn up(repo: anytype) !void { try repo.createTable( diff --git a/demo/src/app/database/seeders/2025-03-10_01-36-58_create_users.zig b/demo/src/app/database/seeders/2025-03-10_01-36-58_create_users.zig new file mode 100644 index 0000000..13ccb9e --- /dev/null +++ b/demo/src/app/database/seeders/2025-03-10_01-36-58_create_users.zig @@ -0,0 +1,21 @@ +const std = @import("std"); + +const jetzig = @import("jetzig"); + +pub fn run(repo: anytype) !void { + try repo.insert( + .User, + .{ + .email = "iguana@jetzig.dev", + .password_hash = try jetzig.auth.hashPassword(repo.allocator, "password"), + }, + ); + + try repo.insert( + .User, + .{ + .email = "admin@jetzig.dev", + .password_hash = try jetzig.auth.hashPassword(repo.allocator, "admin"), + }, + ); +} diff --git a/demo/src/app/views/background_jobs.zig b/demo/src/app/views/background_jobs.zig index d73a53e..877a9e9 100644 --- a/demo/src/app/views/background_jobs.zig +++ b/demo/src/app/views/background_jobs.zig @@ -2,16 +2,24 @@ const std = @import("std"); const jetzig = @import("jetzig"); /// This example demonstrates usage of Jetzig's background jobs. -pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { +pub fn index(request: *jetzig.Request) !jetzig.View { // Prepare a job using `src/app/jobs/example.zig`. var job = try request.job("example"); // Add a param `foo` to the job. - try job.params.put("foo", data.string("bar")); - try job.params.put("id", data.integer(std.crypto.random.int(u32))); + try job.params.put("foo", "bar"); + try job.params.put("id", std.crypto.random.int(u32)); // Schedule the job for background processing. try job.schedule(); return request.render(.ok); } + +test "index" { + var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); + defer app.deinit(); + + const response = try app.request(.GET, "/background_jobs", .{}); + try response.expectJob("example", .{ .foo = "bar" }); +} diff --git a/demo/src/app/views/basic.zig b/demo/src/app/views/basic.zig index 4fac587..08f3ae5 100644 --- a/demo/src/app/views/basic.zig +++ b/demo/src/app/views/basic.zig @@ -1,7 +1,6 @@ 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); } diff --git a/demo/src/app/views/cache.zig b/demo/src/app/views/cache.zig index a931056..7ec7415 100644 --- a/demo/src/app/views/cache.zig +++ b/demo/src/app/views/cache.zig @@ -1,15 +1,15 @@ const std = @import("std"); const jetzig = @import("jetzig"); -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); try root.put("message", try request.cache.get("message")); return request.render(.ok); } -pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - var root = try data.object(); +pub fn post(request: *jetzig.Request) !jetzig.View { + var root = try request.data(.object); const params = try request.params(); @@ -17,7 +17,7 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { try request.cache.put("message", message); try root.put("message", message); } else { - try root.put("message", data.string("[no message param detected]")); + try root.put("message", "[no message param detected]"); } return request.render(.ok); diff --git a/demo/src/app/views/errors.zig b/demo/src/app/views/errors.zig index ccc1082..26d1fd8 100644 --- a/demo/src/app/views/errors.zig +++ b/demo/src/app/views/errors.zig @@ -4,16 +4,14 @@ const jetzig = @import("jetzig"); // Generic handler for all errors. // Use `jetzig.http.status_codes.get(request.status_code)` to get a value that provides string // versions of the error code and message for use in templates. -pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - var root = try data.object(); - var error_info = try data.object(); +pub fn index(request: *jetzig.Request) !jetzig.View { + var root = try request.data(.object); + var error_info = try root.put("error", .object); const status = jetzig.http.status_codes.get(request.status_code); - try error_info.put("code", data.string(status.getCode())); - try error_info.put("message", data.string(status.getMessage())); - - try root.put("error", error_info); + try error_info.put("code", status.getCode()); + try error_info.put("message", status.getMessage()); // Render with the original error status code, or override if preferred. return request.render(request.status_code); diff --git a/demo/src/app/views/format.zig b/demo/src/app/views/format.zig index 6e9a83c..f3cbe26 100644 --- a/demo/src/app/views/format.zig +++ b/demo/src/app/views/format.zig @@ -9,13 +9,11 @@ pub const formats: jetzig.Route.Formats = .{ .get = &.{.html}, }; -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 get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; +pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View { _ = id; return request.render(.ok); } diff --git a/demo/src/app/views/init.zig b/demo/src/app/views/init.zig index d567fc5..06190f8 100644 --- a/demo/src/app/views/init.zig +++ b/demo/src/app/views/init.zig @@ -12,14 +12,14 @@ const jetzig = @import("jetzig"); /// PUT /1234 => put(id, request, data) /// PATCH /1234 => patch(id, request, data) /// DELETE /1234 => delete(id, request, data) -pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - // The first call to `data.object()` or `data.array()` sets the root response data value. +pub fn index(request: *jetzig.Request) !jetzig.View { + // Sets the root response data value. // JSON requests return a JSON string representation of the root data value. // Zmpl templates can access all values in the root data value. - var root = try data.object(); + var root = try request.data(.object); // Add a string to the root object. - try root.put("welcome_message", data.string("Welcome to Jetzig!")); + try root.put("welcome_message", "Welcome to Jetzig!"); // Request params have the same type as a `data.object()` so they can be inserted them // directly into the response data. Fetch `http://localhost:8080/?message=hello` to set the diff --git a/demo/src/app/views/login.zig b/demo/src/app/views/login.zig index 0736dd2..5304cb6 100644 --- a/demo/src/app/views/login.zig +++ b/demo/src/app/views/login.zig @@ -3,38 +3,44 @@ const jetzig = @import("jetzig"); const auth = @import("jetzig").auth; pub fn index(request: *jetzig.Request) !jetzig.View { + var root = try request.data(.object); + + if (request.middleware(.auth).user) |user| { + try root.put("user", .{ .email = user.email }); + } + return request.render(.ok); } pub fn post(request: *jetzig.Request) !jetzig.View { - const Login = struct { - email: []const u8, - password: []const u8, - }; + const Logout = struct { logout: []const u8 }; + const Login = struct { email: []const u8, password: []const u8 }; + + if (try request.expectParams(Logout)) |_| { + try auth.signOut(request); + return request.redirect("/login", .found); + } const params = try request.expectParams(Login) orelse { return request.fail(.forbidden); }; // Lookup the user by email - const query = jetzig.database.Query(.User).findBy( + const user = try jetzig.database.Query(.User).findBy( .{ .email = params.email }, - ); - - const user = try request.repo.execute(query) orelse { + ).execute(request.repo) orelse { return request.fail(.forbidden); }; // Check that the password matches - if (try auth.verifyPassword( + if (!try auth.verifyPassword( request.allocator, user.password_hash, params.password, - )) { - try auth.signIn(request, user.id); - return request.redirect("/", .found); - } - return request.fail(.forbidden); + )) return request.fail(.forbidden); + + try auth.signIn(request, user.id); + return request.redirect("/login", .found); } test "post" { diff --git a/demo/src/app/views/login/index.zmpl b/demo/src/app/views/login/index.zmpl index 605b051..2fb4638 100644 --- a/demo/src/app/views/login/index.zmpl +++ b/demo/src/app/views/login/index.zmpl @@ -1,7 +1,16 @@ -
- - - - - -
+@if ($.user) |user| +
Logged in as {{user.email}}
+ +
+ + +
+@else +
+ + + + + +
+@end diff --git a/demo/src/app/views/mail.zig b/demo/src/app/views/mail.zig index e497d9f..a54c3a0 100644 --- a/demo/src/app/views/mail.zig +++ b/demo/src/app/views/mail.zig @@ -1,9 +1,9 @@ const std = @import("std"); const jetzig = @import("jetzig"); -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!")); +pub fn index(request: *jetzig.Request) !jetzig.View { + var root = try request.data(.object); + try root.put("message", "Welcome to Jetzig!"); // Create a new mail using `src/app/mailers/welcome.zig`. // HTML and text parts are rendered using Zmpl templates: diff --git a/demo/src/app/views/markdown.zig b/demo/src/app/views/markdown.zig index 0a929d6..d9f6308 100644 --- a/demo/src/app/views/markdown.zig +++ b/demo/src/app/views/markdown.zig @@ -3,8 +3,7 @@ const jetzig = @import("jetzig"); pub const layout = "application"; -pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; +pub fn index(request: *jetzig.Request) !jetzig.View { return request.render(.ok); } diff --git a/demo/src/app/views/render_text.zig b/demo/src/app/views/render_text.zig new file mode 100644 index 0000000..dba87f9 --- /dev/null +++ b/demo/src/app/views/render_text.zig @@ -0,0 +1,17 @@ +const std = @import("std"); +const jetzig = @import("jetzig"); + +pub fn index(request: *jetzig.Request) !jetzig.View { + request.response.content_type = "text/xml"; + return request.renderText("baz", .ok); +} + +test "index" { + var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); + defer app.deinit(); + + const response = try app.request(.GET, "/render_text", .{}); + try response.expectStatus(.ok); + try response.expectBodyContains("baz"); + try response.expectHeader("content-type", "text/xml"); +} diff --git a/demo/src/app/views/render_text/index.zmpl b/demo/src/app/views/render_text/index.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/demo/src/app/views/render_text/index.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/demo/src/app/views/session.zig b/demo/src/app/views/session.zig index 27373cc..55cca6e 100644 --- a/demo/src/app/views/session.zig +++ b/demo/src/app/views/session.zig @@ -1,15 +1,15 @@ const std = @import("std"); const jetzig = @import("jetzig"); -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); const session = try request.session(); if (session.get("message")) |message| { try root.put("message", message); } else { - try root.put("message", data.string("No message saved yet")); + try root.put("message", "No message saved yet"); } return request.render(.ok); @@ -20,8 +20,7 @@ pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View { return request.render(.ok); } -pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { - _ = data; +pub fn post(request: *jetzig.Request) !jetzig.View { const params = try request.params(); var session = try request.session(); diff --git a/demo/src/main.zig b/demo/src/main.zig index 48f463a..8aa74c4 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -18,7 +18,10 @@ pub const jetzig_options = struct { // jetzig.middleware.AntiCsrfMiddleware, jetzig.middleware.HtmxMiddleware, jetzig.middleware.ChannelsMiddleware, - // jetzig.middleware.InertiaMiddleware, + // jetzig.middleware.InertiaMiddleware, + jetzig.middleware.AuthMiddleware, + // jetzig.middleware.AntiCsrfMiddleware, + // jetzig.middleware.HtmxMiddleware, // jetzig.middleware.CompressionMiddleware, // @import("app/middleware/DemoMiddleware.zig"), }; @@ -70,6 +73,10 @@ pub const jetzig_options = struct { // Path relative to cwd() to serve public content from. Symlinks are not followed. pub const public_content_path = "public"; + /// Request path to map to public directory, e.g. if `public_routing_path` is `"/foo"` then a + /// request to `/foo/bar.png` will serve static content found in `public/bar.png` + pub const public_routing_path = "/"; + // HTTP buffer. Must be large enough to store all headers. This should typically not be modified. pub const http_buffer_size: usize = std.math.pow(usize, 2, 16); diff --git a/src/commands/database.zig b/src/commands/database.zig index e3a67d0..2db0615 100644 --- a/src/commands/database.zig +++ b/src/commands/database.zig @@ -6,6 +6,8 @@ const jetquery = @import("jetquery"); const jetzig = @import("jetzig"); const Migrate = @import("jetquery_migrate").Migrate; const MigrateSchema = @import("jetquery_migrate").MigrateSchema; +const Seeder = @import("jetquery_seeder").Seed; +const SeederSchema = @import("jetquery_seeder").SeederSchema; const Schema = @import("Schema"); const util = @import("util.zig"); @@ -15,7 +17,7 @@ const production_drop_failure_message = "To drop a production database, " ++ const environment = jetzig.build_options.environment; const config = @field(jetquery.config.database, @tagName(environment)); -const Action = enum { migrate, rollback, create, drop, reflect, setup, update }; +const Action = enum { migrate, rollback, create, drop, reflect, setup, update, seed }; pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; @@ -42,6 +44,7 @@ pub fn main() !void { const map = std.StaticStringMap(Action).initComptime(.{ .{ "migrate", .migrate }, + .{ "seed", .seed }, .{ "rollback", .rollback }, .{ "create", .create }, .{ "drop", .drop }, @@ -69,6 +72,11 @@ pub fn main() !void { defer repo.deinit(); try Migrate(config.adapter).init(&repo).migrate(); }, + .seed => { + var repo = try seedersRepo(action, allocator, repo_env); + defer repo.deinit(); + try Seeder(config.adapter, Schema).init(&repo).seed(); + }, .rollback => { var repo = try migrationsRepo(action, allocator, repo_env); defer repo.deinit(); @@ -136,7 +144,7 @@ fn migrationsRepo(action: Action, allocator: std.mem.Allocator, repo_env: anytyp @field(jetquery.Environment, @tagName(environment)), .{ .admin = switch (action) { - .migrate, .rollback, .update => false, + .migrate, .seed, .rollback, .update => false, .create, .drop => true, .reflect => unreachable, // We use a separate repo for schema reflection. .setup => unreachable, // Setup uses `create` and then `update` @@ -147,6 +155,24 @@ fn migrationsRepo(action: Action, allocator: std.mem.Allocator, repo_env: anytyp ); } +const SeedersRepo = jetquery.Repo(config.adapter, Schema); +fn seedersRepo(action: Action, allocator: std.mem.Allocator, repo_env: anytype) !SeedersRepo { + return try SeedersRepo.loadConfig( + allocator, + @field(jetquery.Environment, @tagName(environment)), + .{ + .admin = switch (action) { + .migrate, .seed, .rollback, .update => false, + .create, .drop => false, + .reflect => unreachable, // We use a separate repo for schema reflection. + .setup => unreachable, // Setup uses `create` and then `update` + }, + .context = .seed, + .env = repo_env, + }, + ); +} + fn reflectSchema(allocator: std.mem.Allocator, repo_env: anytype) !void { var cwd = try jetzig.util.detectJetzigProjectDir(); defer cwd.close(); diff --git a/src/compile_static_routes.zig b/src/compile_static_routes.zig index b027218..a39cbf9 100644 --- a/src/compile_static_routes.zig +++ b/src/compile_static_routes.zig @@ -31,7 +31,7 @@ pub fn main() !void { var index: usize = 0; while (it.next()) |arg| : (index += 1) { if (index == 0) continue; - const file = try std.fs.createFileAbsolute(arg, .{}); + const file = try std.fs.cwd().createFile(arg, .{}); const writer = file.writer(); try compileStaticRoutes(allocator, writer); file.close(); diff --git a/src/jetzig/auth.zig b/src/jetzig/auth.zig index 15b401a..f55e073 100644 --- a/src/jetzig/auth.zig +++ b/src/jetzig/auth.zig @@ -22,6 +22,11 @@ pub fn signIn(request: *jetzig.Request, user_id: anytype) !void { try session.put("_jetzig_user_id", user_id); } +pub fn signOut(request: *jetzig.Request) !void { + var session = try request.session(); + _ = try session.remove("_jetzig_user_id"); +} + pub fn verifyPassword( allocator: std.mem.Allocator, hash: []const u8, diff --git a/src/jetzig/config.zig b/src/jetzig/config.zig index 7dd77fb..4484350 100644 --- a/src/jetzig/config.zig +++ b/src/jetzig/config.zig @@ -84,6 +84,10 @@ pub const max_connections: u16 = 512; /// Path relative to cwd() to serve public content from. Symlinks are not followed. pub const public_content_path = "public"; +/// Request path to map to public directory, e.g. if `public_routing_path` is `"/foo"` then a +/// request to `/foo/bar.png` will serve static content found in `public/bar.png` +pub const public_routing_path = "/"; + /// Middleware chain. Add any custom middleware here, or use middleware provided in /// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`). pub const middleware = &.{}; diff --git a/src/jetzig/http.zig b/src/jetzig/http.zig index 5137903..f3caf66 100644 --- a/src/jetzig/http.zig +++ b/src/jetzig/http.zig @@ -26,3 +26,5 @@ pub const params = @import("http/params.zig"); pub const SimplifiedRequest = struct { location: ?[]const u8, }; + +pub const default_content_type = "application/octet-stream"; diff --git a/src/jetzig/http/Cookies.zig b/src/jetzig/http/Cookies.zig index 214bb79..95844a4 100644 --- a/src/jetzig/http/Cookies.zig +++ b/src/jetzig/http/Cookies.zig @@ -107,10 +107,12 @@ pub fn deinit(self: *Cookies) void { self.arena.deinit(); } +/// Fetch a cookie by name. pub fn get(self: *Cookies, key: []const u8) ?*Cookie { return self.cookies.get(key); } +/// Put a cookie into the cookie store. pub fn put(self: *Cookies, cookie: Cookie) !void { self.modified = true; @@ -127,6 +129,18 @@ pub fn put(self: *Cookies, cookie: Cookie) !void { try self.cookies.put(key, ptr); } +/// Overwrite a cookie with an empty string and expiry of 0. The browser should then no longer +/// send the specified cookie value. +/// +/// > Notice that servers can delete cookies by sending the user agent a new cookie with an +/// > Expires attribute with a value in the past. +/// - https://www.rfc-editor.org/rfc/rfc6265.html +pub fn delete(self: *Cookies, key: []const u8) !void { + self.modified = true; + + try self.put(.{ .name = key, .value = "", .expires = 0 }); +} + pub const HeaderIterator = struct { allocator: std.mem.Allocator, cookies_iterator: std.StringArrayHashMap(*Cookie).Iterator, @@ -434,3 +448,17 @@ test "default flags" { try std.testing.expect(cookie.expires == null); try std.testing.expect(cookie.max_age == null); } + +test "delete" { + const allocator = std.testing.allocator; + var cookies = Cookies.init(allocator, "foo=bar;"); + defer cookies.deinit(); + + try cookies.parse(); + + try cookies.delete("foo"); + const cookie = cookies.get("foo").?; + + try std.testing.expectEqualStrings(cookie.value, ""); + try std.testing.expectEqual(cookie.expires.?, 0); +} diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 597f057..2c06db1 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -17,7 +17,7 @@ pub const RequestState = enum { after_request, // Initial middleware processing after_view, // View returned, response data ready for full response render rendered, // Rendered by middleware or view - rendered_content, // Rendered a plain string by middleware or view + rendered_text, // Rendered text by middleware or view redirected, // Redirected by middleware or view failed, // Failed by middleware or view before_response, // Post middleware processing @@ -251,7 +251,7 @@ pub fn renderContent( if (self.isRendered()) self.rendered_multiple = true; self.rendered_view = .{ .data = self.response_data, .status_code = status_code, .content = content }; - self.state = .rendered_content; + self.state = .rendered_text; return self.rendered_view.?; } @@ -268,7 +268,7 @@ pub fn fail(self: *Request, status_code: jetzig.http.status_codes.StatusCode) je pub inline fn isRendered(self: *const Request) bool { return switch (self.state) { .initial, .processed, .after_request, .before_response => false, - .after_view, .rendered, .rendered_content, .redirected, .failed, .finalized => true, + .after_view, .rendered, .rendered_text, .redirected, .failed, .finalized => true, }; } @@ -778,6 +778,18 @@ pub fn joinPath(self: *const Request, args: anytype) ![]const u8 { return try std.mem.join(self.allocator, "/", buf[0..]); } +pub fn renderText( + self: *Request, + text: []const u8, + status_code: jetzig.http.StatusCode, +) jetzig.views.View { + self.state = .rendered_text; + self.rendered_view = .{ .data = self.response_data, .status_code = status_code }; + self.setResponse(.{ .view = self.rendered_view.?, .content = text }, .{}); + + return self.rendered_view.?; +} + pub fn joinPaths(self: *const Request, paths: []const []const []const u8) ![]const u8 { var buf = std.ArrayList([]const u8).init(self.allocator); defer buf.deinit(); @@ -795,10 +807,12 @@ pub fn setResponse( ) void { self.response.content = rendered_view.content; self.response.status_code = rendered_view.view.status_code; - self.response.content_type = options.content_type orelse switch (self.requestFormat()) { - .HTML, .UNKNOWN => "text/html", - .JSON => "application/json", - }; + if (self.response.content_type == null) { + self.response.content_type = options.content_type orelse switch (self.requestFormat()) { + .HTML, .UNKNOWN => "text/html", + .JSON => "application/json", + }; + } } fn setCookieHeaders(self: *Request) !void { diff --git a/src/jetzig/http/Response.zig b/src/jetzig/http/Response.zig index f2c26b5..db1e843 100644 --- a/src/jetzig/http/Response.zig +++ b/src/jetzig/http/Response.zig @@ -11,7 +11,7 @@ allocator: std.mem.Allocator, headers: jetzig.http.Headers, content: []const u8, status_code: http.status_codes.StatusCode, -content_type: []const u8, +content_type: ?[]const u8 = null, httpz_response: *httpz.Response, pub fn init( @@ -22,8 +22,11 @@ pub fn init( .allocator = allocator, .httpz_response = httpz_response, .status_code = .no_content, - .content_type = "application/octet-stream", .content = "", .headers = jetzig.http.Headers.init(allocator, &httpz_response.headers), }; } + +pub inline fn contentType(self: *const jetzig.http.Response) []const u8 { + return self.content_type orelse jetzig.http.default_content_type; +} diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index 226e51c..20bab44 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -184,7 +184,7 @@ pub fn RoutedServer(Routes: type) type { } try self.renderResponse(&request); - try request.response.headers.append("Content-Type", response.content_type); + try request.response.headers.append("Content-Type", response.contentType()); try jetzig.http.middleware.beforeResponse(&middleware_data, &request); try request.respond(); @@ -244,7 +244,7 @@ pub fn RoutedServer(Routes: type) type { // TODO: Allow middleware to set content request.setResponse(.{ .view = rendered, .content = "" }, .{}); } - try request.response.headers.append("Content-Type", response.content_type); + try request.response.headers.append("Content-Type", response.contentType()); try request.respond(); return true; } else return false; @@ -418,12 +418,16 @@ pub fn RoutedServer(Routes: type) type { return try self.renderInternalServerError(request, @errorReturnTrace(), err); }; - if (request.state == .failed) { - const view: jetzig.views.View = request.rendered_view orelse .{ - .data = request.response_data, - .status_code = .internal_server_error, - }; - return try self.renderError(request, view.status_code, .{}); + switch (request.state) { + .failed => { + const status_code = request.rendered_view.?.status_code; + return try self.renderError(request, status_code, .{}); + }, + .rendered_text => { + const view = request.rendered_view.?; // a panic here is a bug. + return .{ .view = view, .content = request.response.content }; + }, + else => {}, } const template: ?zmpl.Template = if (request.dynamic_assigned_template) |request_template| @@ -435,7 +439,7 @@ pub fn RoutedServer(Routes: type) type { if (request.rendered_view) |rendered_view| { if (request.state == .redirected) return .{ .view = rendered_view, .content = "" }; - if (request.state == .rendered_content) return .{ + if (request.state == .rendered_text) return .{ .view = rendered_view, .content = rendered_view.content.?, }; diff --git a/src/jetzig/testing.zig b/src/jetzig/testing.zig index 6b9cbf4..e6c3d9a 100644 --- a/src/jetzig/testing.zig +++ b/src/jetzig/testing.zig @@ -66,7 +66,7 @@ pub const TestResponse = struct { jobs: []Job, pub const Header = struct { name: []const u8, value: []const u8 }; - pub const Job = struct { name: []const u8, params: ?[]const u8 = null }; + pub const Job = struct { name: []const u8, params: ?*jetzig.data.Value = null }; pub fn expectStatus(self: TestResponse, comptime expected: jetzig.http.status_codes.StatusCode) !void { try testing.expectStatus(expected, self); @@ -120,14 +120,31 @@ pub fn expectBodyContains(expected: []const u8, response: TestResponse) !void { } pub fn expectHeader(expected_name: []const u8, expected_value: ?[]const u8, response: TestResponse) !void { + var mismatches = std.ArrayList([]const u8).init(response.allocator); + defer mismatches.deinit(); + for (response.headers) |header| { if (!std.ascii.eqlIgnoreCase(header.name, expected_name)) continue; if (expected_value) |value| { - if (std.mem.eql(u8, header.value, value)) return; + if (std.mem.eql(u8, header.value, value)) { + return; + } else { + try mismatches.append(header.value); + } } else { return; } } + + logFailure( + "Expected header " ++ + jetzig.colors.cyan("{s}") ++ + ": " ++ + jetzig.colors.green("{?s}") ++ + ", found: " ++ + jetzig.colors.red("{s}"), + .{ expected_name, expected_value, mismatches.items }, + ); return error.JetzigExpectHeaderError; } @@ -282,13 +299,54 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: } pub fn expectJob(job_name: []const u8, job_params: anytype, response: TestResponse) !void { + var actual_params_buf = std.ArrayList([]const u8).init(response.allocator); + defer actual_params_buf.deinit(); + + const value: ?*jetzig.data.Value = if (@TypeOf(job_params) == @TypeOf(null)) + null + else + try jetzig.Data.zmplValue(job_params, response.allocator); for (response.jobs) |job| { - comptime var has_args = false; - inline for (@typeInfo(@TypeOf(job_params)).Struct.fields) |field| { - has_args = true; - _ = field; + if (std.mem.eql(u8, job_name, job.name)) { + if (value != null and job.params != null) { + if (job.params.?.includes(value.?.*)) { + return; + } else { + try actual_params_buf.append(try job.params.?.toJson()); + } + } else { + return; + } } - if (!has_args and std.mem.eql(u8, job_name, job.name)) return; + } + + if (actual_params_buf.items.len == 0) { + logFailure( + "Expected job " ++ + jetzig.colors.cyan("{s}") ++ + " to have been scheduled but job was not found in the queue.", + .{job_name}, + ); + } else { + const actual_params_formatted = try std.mem.join( + response.allocator, + "\n", + actual_params_buf.items, + ); + defer response.allocator.free(actual_params_formatted); + logFailure( + "Expected params for job " ++ + jetzig.colors.cyan("{s}") ++ + ":\n" ++ + jetzig.colors.red("{s}\n") ++ + "Actual job params:\n" ++ + jetzig.colors.green("{s}"), + .{ + job_name, + if (value) |v| try v.toJson() else "null", + actual_params_formatted, + }, + ); } return error.JetzigExpectJobError; } diff --git a/src/jetzig/testing/App.zig b/src/jetzig/testing/App.zig index 5863230..381ba96 100644 --- a/src/jetzig/testing/App.zig +++ b/src/jetzig/testing/App.zig @@ -234,6 +234,7 @@ pub fn request( while (try self.job_queue.popFirst(&data, "__jetzig_jobs")) |value| { if (value.getT(.string, "__jetzig_job_name")) |job_name| try jobs.append(.{ .name = try allocator.dupe(u8, job_name), + .params = value, }); } diff --git a/src/routes_file.zig b/src/routes_file.zig index 8fafc02..d32e61a 100644 --- a/src/routes_file.zig +++ b/src/routes_file.zig @@ -29,7 +29,7 @@ pub fn main() !void { mailers_path, ); const generated_routes = try routes.generateRoutes(); - var src_dir = try std.fs.openDirAbsolute(src_path, .{ .iterate = true }); + var src_dir = try std.fs.cwd().openDir(src_path, .{ .iterate = true }); defer src_dir.close(); var walker = try src_dir.walk(allocator); defer walker.deinit(); @@ -39,7 +39,7 @@ pub fn main() !void { const stat = try src_dir.statFile(entry.path); const src_data = try src_dir.readFileAlloc(allocator, entry.path, @intCast(stat.size)); const relpath = try std.fs.path.join(allocator, &[_][]const u8{ "src", entry.path }); - var dir = try std.fs.openDirAbsolute(std.fs.path.dirname(output_path).?, .{}); + var dir = try std.fs.cwd().openDir(std.fs.path.dirname(output_path).?, .{}); const dest_dir = try dir.makeOpenPath(std.fs.path.dirname(relpath).?, .{}); const src_file = try dest_dir.createFile(std.fs.path.basename(relpath), .{}); try src_file.writeAll(src_data); @@ -47,7 +47,7 @@ pub fn main() !void { } } - const file = try std.fs.createFileAbsolute(output_path, .{ .truncate = true }); + const file = try std.fs.cwd().createFile(output_path, .{ .truncate = true }); try file.writeAll(generated_routes); file.close(); }