From 49c5c2db26abe34e179440e3aeac33be057ddd43 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Sun, 17 Nov 2024 12:33:35 +0000 Subject: [PATCH 1/4] Update dependencies Add assertion to verify compatible jetcommon versions between dependencies. --- build.zig.zon | 12 ++++++------ cli/build.zig.zon | 4 ++-- src/jetzig/http/Query.zig | 4 ++-- src/jetzig/mail/Job.zig | 10 ++++++++-- src/tests.zig | 5 +++++ 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 2e8f224..cd300e7 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,20 +7,20 @@ .hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163", }, .zmpl = .{ - .url = "https://github.com/jetzig-framework/zmpl/archive/369193322cc572197b57a1a2cebd85b317bf92c4.tar.gz", - .hash = "12205982592aa38f37bba8d7410f62263827a49b350c1029a2eb79e8cc12b7247e9c", + .url = "https://github.com/jetzig-framework/zmpl/archive/7f2817df78404b8a46c637c212ec1a27a66306fa.tar.gz", + .hash = "12203a2ef05a4c3a76c1436e96c0a0aa5fc8e8406d56e50b1e9c94c394225c113b0e", }, .jetkv = .{ .url = "https://github.com/jetzig-framework/jetkv/archive/2b1130a48979ea2871c8cf6ca89c38b1e7062839.tar.gz", .hash = "12201d75d73aad5e1c996de4d5ae87a00e58479c8d469bc2eeb5fdeeac8857bc09af", }, .jetquery = .{ - .url = "https://github.com/jetzig-framework/jetquery/archive/91ab3139ff7914d9bf40b7a08d7b120bac7a2c7d.tar.gz", - .hash = "1220f1473c69e2b3fbdfc61860a451d1729f384e31097c32acdd3c54054e94387000", + .url = "https://github.com/jetzig-framework/jetquery/archive/a31db467c4af1c97bc7c806e1cc1a81a39162954.tar.gz", + .hash = "12203af0466ccc3a9ab57fcdf57c92c57989fa7e827d81bc98d0a5787d65402c73c3", }, .jetcommon = .{ - .url = "https://github.com/jetzig-framework/jetcommon/archive/a248776ba56d6cc2b160d593ac3305756adcd26e.tar.gz", - .hash = "1220a61e8650f84b28baf31fae5da31712aec4b711b3a41d11ed07c908bac96648d8", + .url = "https://github.com/jetzig-framework/jetcommon/archive/86f24cfdf2aaa0e8ada4539a6edef882708ced2b.tar.gz", + .hash = "12200439fc28aa7fa08f0e8fea100f6724c34c9dbfaaae4feec482c80e5ac08ea4f6", }, .args = .{ .url = "https://github.com/ikskuh/zig-args/archive/0abdd6947a70e6d8cc83b66228cea614aa856206.tar.gz", diff --git a/cli/build.zig.zon b/cli/build.zig.zon index 46d3bc7..b0ace3a 100644 --- a/cli/build.zig.zon +++ b/cli/build.zig.zon @@ -9,8 +9,8 @@ .hash = "1220411a8c46d95bbf3b6e2059854bcb3c5159d428814099df5294232b9980517e9c", }, .jetquery = .{ - .url = "https://github.com/jetzig-framework/jetquery/archive/91ab3139ff7914d9bf40b7a08d7b120bac7a2c7d.tar.gz", - .hash = "1220f1473c69e2b3fbdfc61860a451d1729f384e31097c32acdd3c54054e94387000", + .url = "https://github.com/jetzig-framework/jetquery/archive/a31db467c4af1c97bc7c806e1cc1a81a39162954.tar.gz", + .hash = "12203af0466ccc3a9ab57fcdf57c92c57989fa7e827d81bc98d0a5787d65402c73c3", }, }, .paths = .{ diff --git a/src/jetzig/http/Query.zig b/src/jetzig/http/Query.zig index b1c07c4..b7a8bcb 100644 --- a/src/jetzig/http/Query.zig +++ b/src/jetzig/http/Query.zig @@ -61,7 +61,7 @@ pub fn parse(self: *Query) !void { else => return error.JetzigQueryParseError, } } else { - var array = try self.data.createArray(); + var array = try jetzig.zmpl.Data.createArray(self.data.allocator()); try array.append(self.dataValue(item.value)); try params.put(key, array); } @@ -72,7 +72,7 @@ pub fn parse(self: *Query) !void { else => return error.JetzigQueryParseError, } } else { - var object = try self.data.createObject(); + var object = try jetzig.zmpl.Data.createObject(self.data.allocator()); try object.put(mapping.field, self.dataValue(item.value)); try params.put(mapping.key, object); } diff --git a/src/jetzig/mail/Job.zig b/src/jetzig/mail/Job.zig index f9580e6..68915c6 100644 --- a/src/jetzig/mail/Job.zig +++ b/src/jetzig/mail/Job.zig @@ -129,7 +129,10 @@ fn defaultHtml( params: *jetzig.data.Value, ) !?[]const u8 { var data = jetzig.data.Data.init(allocator); - data.value = if (params.get("params")) |capture| capture else try data.createObject(); + data.value = if (params.get("params")) |capture| + capture + else + try jetzig.zmpl.Data.createObject(data.allocator()); try data.addConst("jetzig_view", data.string("")); try data.addConst("jetzig_action", data.string("")); return if (jetzig.zmpl.findPrefixed("mailers", mailer.html_template)) |template| @@ -144,7 +147,10 @@ fn defaultText( params: *jetzig.data.Value, ) !?[]const u8 { var data = jetzig.data.Data.init(allocator); - data.value = if (params.get("params")) |capture| capture else try data.createObject(); + data.value = if (params.get("params")) |capture| + capture + else + try jetzig.zmpl.Data.createObject(data.allocator()); try data.addConst("jetzig_view", data.string("")); try data.addConst("jetzig_action", data.string("")); return if (jetzig.zmpl.findPrefixed("mailers", mailer.text_template)) |template| diff --git a/src/tests.zig b/src/tests.zig index 544934c..2746893 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -1,4 +1,9 @@ +const std = @import("std"); +const jetzig = @import("jetzig.zig"); + test { + std.debug.assert(jetzig.jetquery.jetcommon == jetzig.zmpl.jetcommon); + std.debug.assert(jetzig.zmpl.jetcommon == jetzig.jetcommon); _ = @import("jetzig/http/Query.zig"); _ = @import("jetzig/http/Headers.zig"); _ = @import("jetzig/http/Cookies.zig"); From e3ab49fa5aff5d7a9c3c43775694f916e6535166 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Sun, 17 Nov 2024 15:08:54 +0000 Subject: [PATCH 2/4] Refactor Route Move all route types into a single union and remove leftover junk. Deprecate view functions that receive a `*jetzig.Data` argument (we will stay backward-compatible until we have a valid reason to drop support for legacy view functions - the extra code overhead is pretty minimal). --- demo/src/app/views/params.zig | 14 +++ demo/src/app/views/params/post.zmpl | 3 + src/Routes.zig | 84 +++++++++++++++--- src/jetzig/http/Request.zig | 12 ++- src/jetzig/http/Server.zig | 4 +- src/jetzig/views/Route.zig | 129 +++++++++++----------------- src/jetzig/views/view_types.zig | 69 +++++++++++++++ 7 files changed, 222 insertions(+), 93 deletions(-) create mode 100644 demo/src/app/views/params.zig create mode 100644 demo/src/app/views/params/post.zmpl create mode 100644 src/jetzig/views/view_types.zig diff --git a/demo/src/app/views/params.zig b/demo/src/app/views/params.zig new file mode 100644 index 0000000..16a47b0 --- /dev/null +++ b/demo/src/app/views/params.zig @@ -0,0 +1,14 @@ +const std = @import("std"); +const jetzig = @import("jetzig"); + +pub fn post(request: *jetzig.Request) !jetzig.View { + return request.render(.created); +} + +test "post" { + var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); + defer app.deinit(); + + const response = try app.request(.POST, "/params", .{}); + try response.expectStatus(.created); +} diff --git a/demo/src/app/views/params/post.zmpl b/demo/src/app/views/params/post.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/demo/src/app/views/params/post.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/src/Routes.zig b/src/Routes.zig index b46d833..eadedb2 100644 --- a/src/Routes.zig +++ b/src/Routes.zig @@ -24,6 +24,7 @@ const Function = struct { path: []const u8, source: []const u8, params: std.ArrayList([]const u8), + legacy: bool = false, static: bool = false, /// The full name of a route. This **must** match the naming convention used by static route @@ -179,7 +180,6 @@ pub fn generateRoutes(self: *Routes) ![]const u8 { ); return try self.buffer.toOwnedSlice(); - // std.debug.print("routes.zig\n{s}\n", .{self.buffer.items}); } pub fn relativePathFrom( @@ -232,7 +232,10 @@ fn writeRoutes(self: *Routes, writer: anytype) !void { const realpath = try dir.realpathAlloc(self.allocator, entry.path); defer self.allocator.free(realpath); - const view_routes = try self.generateRoutesForView(dir, try self.allocator.dupe(u8, realpath)); + const view_routes = try self.generateRoutesForView( + dir, + try self.allocator.dupe(u8, realpath), + ); for (view_routes.static) |view_route| { try self.static_routes.append(view_route); @@ -272,7 +275,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function) \\ .name = "{0s}", \\ .action = .{1s}, \\ .view_name = "{2s}", - \\ .view = jetzig.Route.ViewType{{ .{3s} = .{{ .{1s} = @import("{7s}").{1s} }} }}, + \\ .view = jetzig.Route.View{{ .{3s} = @import("{7s}").{1s} }}, \\ .path = "{7s}", \\ .static = {4s}, \\ .uri_path = "{5s}", @@ -296,6 +299,36 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function) &[_][]const u8{ view_name, "/", route.name }, ); + const with_id = std.StaticStringMap(bool).initComptime(.{ + .{ "index", false }, + .{ "post", false }, + .{ "new", false }, + .{ "get", true }, + .{ "edit", true }, + .{ "put", true }, + .{ "patch", true }, + .{ "delete", true }, + }).get(route.name).?; + + const tag = if (!route.legacy and !route.static and with_id) + "with_id" + else if (!route.legacy and !route.static and !with_id) + "without_id" + else if (!route.legacy and route.static and with_id) + "static_with_id" + else if (!route.legacy and route.static and !with_id) + "static_without_id" + else if (route.legacy and !route.static and with_id) + "legacy_with_id" + else if (route.legacy and !route.static and !with_id) + "legacy_without_id" + else if (route.legacy and route.static and with_id) + "legacy_static_with_id" + else if (route.legacy and route.static and !with_id) + "legacy_static_without_id" + else + unreachable; + std.mem.replaceScalar(u8, module_path, '\\', '/'); try self.module_paths.append(try self.allocator.dupe(u8, module_path)); @@ -305,7 +338,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function) full_name, route.name, view_name, - if (route.static) "static" else "dynamic", + tag, if (route.static) "true" else "false", uri_path, template, @@ -325,7 +358,14 @@ const RouteSet = struct { fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !RouteSet { const stat = try dir.statFile(path); - const source = try dir.readFileAllocOptions(self.allocator, path, @intCast(stat.size), null, @alignOf(u8), 0); + const source = try dir.readFileAllocOptions( + self.allocator, + path, + @intCast(stat.size), + null, + @alignOf(u8), + 0, + ); defer self.allocator.free(source); self.ast = try std.zig.Ast.parse(self.allocator, source, .zig); @@ -336,15 +376,25 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout for (self.ast.nodes.items(.tag), 0..) |tag, index| { switch (tag) { - .fn_proto_multi => { - const function = try self.parseFunction(index, path, source); + .fn_proto_multi, .fn_proto_one, .fn_proto_simple => |function_tag| { + var function = try self.parseFunction(function_tag, index, path, source); if (function) |*capture| { - for (capture.args) |arg| { + if (capture.args.len == 0) { + std.debug.print( + "Expected at least 1 argument for view function `{s}` in `{s}`", + .{ capture.name, path }, + ); + return error.JetzigMissingViewArgument; + } + + for (capture.args, 0..) |arg, arg_index| { if (std.mem.eql(u8, try arg.typeBasename(), "StaticRequest")) { - @constCast(capture).static = true; + capture.static = true; + capture.legacy = arg_index + 1 < capture.args.len; try static_routes.append(capture.*); - } - if (std.mem.eql(u8, try arg.typeBasename(), "Request")) { + } else if (std.mem.eql(u8, try arg.typeBasename(), "Request")) { + capture.static = false; + capture.legacy = arg_index + 1 < capture.args.len; try dynamic_routes.append(capture.*); } } @@ -529,14 +579,21 @@ fn isStaticParamsDecl(self: *Routes, decl: std.zig.Ast.full.VarDecl) bool { fn parseFunction( self: *Routes, + function_type: std.zig.Ast.Node.Tag, index: usize, path: []const u8, source: []const u8, ) !?Function { - const fn_proto = self.ast.fnProtoMulti(@as(u32, @intCast(index))); + var buf: [1]std.zig.Ast.Node.Index = undefined; + + const fn_proto = switch (function_type) { + .fn_proto_multi => self.ast.fnProtoMulti(@as(u32, @intCast(index))), + .fn_proto_one => self.ast.fnProtoOne(&buf, @as(u32, @intCast(index))), + .fn_proto_simple => self.ast.fnProtoSimple(&buf, @as(u32, @intCast(index))), + else => unreachable, + }; if (fn_proto.name_token) |token| { const function_name = try self.allocator.dupe(u8, self.ast.tokenSlice(token)); - var it = fn_proto.iterate(&self.ast); var args = std.ArrayList(Arg).init(self.allocator); defer args.deinit(); @@ -545,6 +602,7 @@ fn parseFunction( return null; } + var it = fn_proto.iterate(&self.ast); while (it.next()) |arg| { if (arg.name_token) |arg_token| { const arg_name = self.ast.tokenSlice(arg_token); diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 0b046b3..a7fe119 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -344,6 +344,11 @@ pub fn params(self: *Request) !*jetzig.data.Value { } } +// TODO +// pub fn expectParams(self: Request, T: type) ?T { +// +// } + /// Retrieve a file from a `multipart/form-data`-encoded request body, if present. pub fn file(self: *Request, name: []const u8) !?jetzig.http.File { _ = try self.parseQuery(); @@ -692,12 +697,17 @@ pub fn match(self: *Request, route: jetzig.views.Route) !bool { }; } -fn isMatch(self: *Request, match_type: enum { exact, resource_id }, route: jetzig.views.Route) bool { +fn isMatch( + self: *Request, + match_type: enum { exact, resource_id }, + route: jetzig.views.Route, +) bool { const path = switch (match_type) { .exact => self.path.base_path, .resource_id => self.path.directory, }; + // Special case for `/foobar/1/new` -> render `new()` if (route.action == .get and std.mem.eql(u8, self.path.resource_id, "new")) return false; return std.mem.eql(u8, path, route.uri_path); diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index 991805a..ba84f78 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -567,7 +567,9 @@ fn matchMiddlewareRoute(request: *const jetzig.http.Request) ?jetzig.middleware. fn matchRoute(self: *Server, request: *jetzig.http.Request, static: bool) !?jetzig.views.Route { for (self.routes) |route| { // .index routes always take precedence. - if (route.static == static and route.action == .index and try request.match(route.*)) return route.*; + if (route.static == static and route.action == .index and try request.match(route.*)) { + return route.*; + } } for (self.routes) |route| { diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig index 2ed4104..9a616b7 100644 --- a/src/jetzig/views/Route.zig +++ b/src/jetzig/views/Route.zig @@ -1,19 +1,33 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); +const view_types = @import("view_types.zig"); const Route = @This(); pub const Action = enum { index, get, new, post, put, patch, delete, custom }; + +pub const View = union(enum) { + with_id: view_types.ViewWithId, + without_id: view_types.ViewWithoutId, + with_args: view_types.ViewWithArgs, + + static_with_id: view_types.StaticViewWithId, + static_without_id: view_types.StaticViewWithoutId, + static_with_args: view_types.StaticViewWithArgs, + + legacy_with_id: view_types.LegacyViewWithId, + legacy_without_id: view_types.LegacyViewWithoutId, + legacy_with_args: view_types.LegacyViewWithArgs, + + legacy_static_with_id: view_types.LegacyStaticViewWithId, + legacy_static_without_id: view_types.LegacyStaticViewWithoutId, + legacy_static_with_args: view_types.LegacyStaticViewWithArgs, +}; + pub const RenderFn = *const fn (Route, *jetzig.http.Request) anyerror!jetzig.views.View; pub const RenderStaticFn = *const fn (Route, *jetzig.http.StaticRequest) anyerror!jetzig.views.View; -pub const ViewWithoutId = *const fn (*jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View; -pub const ViewWithId = *const fn (id: []const u8, *jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View; -const StaticViewWithoutId = *const fn (*jetzig.http.StaticRequest, *jetzig.data.Data) anyerror!jetzig.views.View; -pub const ViewWithArgs = *const fn ([]const []const u8, *jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View; -const StaticViewWithId = *const fn (id: []const u8, *jetzig.http.StaticRequest, *jetzig.data.Data) anyerror!jetzig.views.View; - pub const Formats = struct { index: ?[]const ResponseFormat = null, get: ?[]const ResponseFormat = null, @@ -26,47 +40,13 @@ pub const Formats = struct { }; const ResponseFormat = enum { html, json }; -pub const DynamicViewType = union(Action) { - index: ViewWithoutId, - get: ViewWithId, - new: ViewWithoutId, - post: ViewWithoutId, - put: ViewWithId, - patch: ViewWithId, - delete: ViewWithId, - custom: CustomViewType, -}; - -pub const StaticViewType = union(Action) { - index: StaticViewWithoutId, - get: StaticViewWithId, - new: StaticViewWithoutId, - post: StaticViewWithoutId, - put: StaticViewWithId, - patch: StaticViewWithId, - delete: StaticViewWithId, - custom: void, -}; - -pub const CustomViewType = union(enum) { - with_id: ViewWithId, - without_id: ViewWithoutId, - with_args: ViewWithArgs, -}; - -pub const ViewType = union(enum) { - static: StaticViewType, - dynamic: DynamicViewType, - custom: CustomViewType, -}; - name: []const u8, action: Action, method: jetzig.http.Request.Method = undefined, // Used by custom routes only view_name: []const u8, uri_path: []const u8, path: ?[]const u8 = null, -view: ViewType, +view: View, render: RenderFn = renderFn, renderStatic: RenderStaticFn = renderStaticFn, static: bool = false, @@ -97,6 +77,14 @@ pub fn deinitParams(self: *const Route) void { self.params.deinit(); } +pub fn format(self: Route, _: []const u8, _: anytype, writer: anytype) !void { + try writer.print( + \\Route{{ .name = "{s}", .action = .{s}, .view_name = "{s}", .static = {} }} + , + .{ self.name, @tagName(self.action), self.view_name, self.static }, + ); +} + /// Match a **custom** route to a request - not used by auto-generated route matching. pub fn match(self: Route, request: *const jetzig.http.Request) bool { if (self.method != request.method) return false; @@ -140,46 +128,31 @@ pub fn validateFormat(self: Route, request: *const jetzig.http.Request) bool { } fn renderFn(self: Route, request: *jetzig.http.Request) anyerror!jetzig.views.View { - switch (self.view) { - .dynamic => {}, - .custom => |view_type| switch (view_type) { - .with_id => |view| return try view(request.path.resourceId(self), request, request.response_data), - .without_id => |view| return try view(request, request.response_data), - .with_args => |view| return try view( - try request.path.resourceArgs(self, request.allocator), - request, - request.response_data, - ), - }, - // We only end up here if a static route is defined but its output is not found in the - // file system (e.g. if it was manually deleted after build). This should be avoidable by - // including the content as an artifact in the compiled executable (TODO): - .static => return error.JetzigMissingStaticContent, - } - - switch (self.view.dynamic) { - .index => |view| return try view(request, request.response_data), - .get => |view| return try view(request.path.resource_id, request, request.response_data), - .new => |view| return try view(request, request.response_data), - .post => |view| return try view(request, request.response_data), - .patch => |view| return try view(request.path.resource_id, request, request.response_data), - .put => |view| return try view(request.path.resource_id, request, request.response_data), - .delete => |view| return try view(request.path.resource_id, request, request.response_data), - .custom => unreachable, - } + return switch (self.view) { + .without_id => |func| try func(request), + .legacy_without_id => |func| try func(request, request.response_data), + .with_id => |func| try func(request.path.resource_id, request), + .legacy_with_id => |func| try func( + request.path.resource_id, + request, + request.response_data, + ), + else => unreachable, + }; } fn renderStaticFn(self: Route, request: *jetzig.http.StaticRequest) anyerror!jetzig.views.View { request.response_data.* = jetzig.data.Data.init(request.allocator); - switch (self.view.static) { - .index => |view| return try view(request, request.response_data), - .get => |view| return try view(try request.resourceId(), request, request.response_data), - .new => |view| return try view(request, request.response_data), - .post => |view| return try view(request, request.response_data), - .patch => |view| return try view(try request.resourceId(), request, request.response_data), - .put => |view| return try view(try request.resourceId(), request, request.response_data), - .delete => |view| return try view(try request.resourceId(), request, request.response_data), - .custom => unreachable, - } + return switch (self.view) { + .static_without_id => |func| try func(request), + .legacy_static_without_id => |func| try func(request, request.response_data), + .static_with_id => |func| try func(try request.resourceId(), request), + .legacy_static_with_id => |func| try func( + try request.resourceId(), + request, + request.response_data, + ), + else => unreachable, + }; } diff --git a/src/jetzig/views/view_types.zig b/src/jetzig/views/view_types.zig new file mode 100644 index 0000000..5eea082 --- /dev/null +++ b/src/jetzig/views/view_types.zig @@ -0,0 +1,69 @@ +const jetzig = @import("../../jetzig.zig"); + +pub const ViewWithoutId = *const fn ( + *jetzig.http.Request, +) anyerror!jetzig.views.View; + +pub const ViewWithId = *const fn ( + id: []const u8, + *jetzig.http.Request, +) anyerror!jetzig.views.View; + +pub const ViewWithArgs = *const fn ( + []const []const u8, + *jetzig.http.Request, +) anyerror!jetzig.views.View; + +pub const StaticViewWithoutId = *const fn ( + *jetzig.http.StaticRequest, +) anyerror!jetzig.views.View; + +pub const StaticViewWithId = *const fn ( + id: []const u8, + *jetzig.http.StaticRequest, +) anyerror!jetzig.views.View; + +pub const StaticViewWithArgs = *const fn ( + []const []const u8, + *jetzig.http.StaticRequest, +) anyerror!jetzig.views.View; + +// Legacy view types receive a `data` argument. This made sense when `data.string(...)` etc. were +// needed to create a string, but now we use type inference/coercion when adding values to +// response data. +// `Array.append(.array)`, `Array.append(.object)`, `Object.put(key, .array)`, and +// `Object.put(key, .object)` also remove the need to use `data.array()` and `data.object()`. +// The only remaining use is `data.root(.object)` and `data.root(.array)` which we can move to +// `request.responseData(.object)` and `request.responseData(.array)`. +pub const LegacyViewWithoutId = *const fn ( + *jetzig.http.Request, + *jetzig.data.Data, +) anyerror!jetzig.views.View; + +pub const LegacyViewWithId = *const fn ( + id: []const u8, + *jetzig.http.Request, + *jetzig.data.Data, +) anyerror!jetzig.views.View; + +pub const LegacyStaticViewWithoutId = *const fn ( + *jetzig.http.StaticRequest, + *jetzig.data.Data, +) anyerror!jetzig.views.View; + +pub const LegacyViewWithArgs = *const fn ( + []const []const u8, + *jetzig.http.Request, + *jetzig.data.Data, +) anyerror!jetzig.views.View; + +pub const LegacyStaticViewWithId = *const fn ( + id: []const u8, + *jetzig.http.StaticRequest, + *jetzig.data.Data, +) anyerror!jetzig.views.View; + +pub const LegacyStaticViewWithArgs = *const fn ( + []const []const u8, + *jetzig.http.StaticRequest, +) anyerror!jetzig.views.View; From b95506caf9b989b8d271b78ad00a92c65e510bac Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Sun, 17 Nov 2024 19:07:27 +0000 Subject: [PATCH 3/4] Params helpers Implement `request.expectParams()` to coerce params to a given struct. `request.paramsInfo()` provides information about each param (present, blank, failed + original values and errors where applicable). --- build.zig.zon | 4 +- cli/commands/generate/view.zig | 3 +- demo/src/app/views/params.zig | 72 +++++++++++++- src/jetzig/colors.zig | 3 + src/jetzig/http.zig | 1 + src/jetzig/http/Request.zig | 59 +++++++++--- src/jetzig/http/params.zig | 161 ++++++++++++++++++++++++++++++++ src/jetzig/testing.zig | 2 +- src/jetzig/testing/App.zig | 11 ++- src/jetzig/views/view_types.zig | 2 +- 10 files changed, 294 insertions(+), 24 deletions(-) create mode 100644 src/jetzig/http/params.zig diff --git a/build.zig.zon b/build.zig.zon index cd300e7..c3617ae 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,8 +7,8 @@ .hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163", }, .zmpl = .{ - .url = "https://github.com/jetzig-framework/zmpl/archive/7f2817df78404b8a46c637c212ec1a27a66306fa.tar.gz", - .hash = "12203a2ef05a4c3a76c1436e96c0a0aa5fc8e8406d56e50b1e9c94c394225c113b0e", + .url = "https://github.com/jetzig-framework/zmpl/archive/7b7452bc7fdb0bd2f8a6a9c4e9312900b486aeba.tar.gz", + .hash = "1220ed127f38fa51df53a85b3cc2030a7555e34058db7fd374ebaef817abb43d35f7", }, .jetkv = .{ .url = "https://github.com/jetzig-framework/jetkv/archive/2b1130a48979ea2871c8cf6ca89c38b1e7062839.tar.gz", diff --git a/cli/commands/generate/view.zig b/cli/commands/generate/view.zig index 9d1b483..edcbc97 100644 --- a/cli/commands/generate/view.zig +++ b/cli/commands/generate/view.zig @@ -116,8 +116,7 @@ fn parseAction(arg: []const u8) ?Action { fn writeAction(allocator: std.mem.Allocator, writer: anytype, action: Action) !void { const function = try std.fmt.allocPrint( allocator, - \\pub fn {s}({s}request: *jetzig.{s}, data: *jetzig.Data) !jetzig.View {{ - \\ _ = data;{s} + \\pub fn {s}({s}request: *jetzig.{s}) !jetzig.View {{ \\ return request.render({s}); \\}} \\ diff --git a/demo/src/app/views/params.zig b/demo/src/app/views/params.zig index 16a47b0..1eff17b 100644 --- a/demo/src/app/views/params.zig +++ b/demo/src/app/views/params.zig @@ -2,13 +2,79 @@ const std = @import("std"); const jetzig = @import("jetzig"); pub fn post(request: *jetzig.Request) !jetzig.View { + const Params = struct { + // Required param - `expectParams` returns `null` if not present: + name: []const u8, + // Enum params are converted from string, `expectParams` returns `null` if no match: + favorite_animal: enum { cat, dog, raccoon }, + // Optional params are not required. Numbers are coerced from strings. `expectParams` + // returns `null` if a type coercion fails. + age: ?u8 = 100, + }; + const params = try request.expectParams(Params) orelse { + // Inspect information about the failed params with `request.paramsInfo()`: + // std.debug.print("{?}\n", .{try request.paramsInfo()}); + return request.fail(.unprocessable_entity); + }; + + var root = try request.data(.object); + try root.put("info", params); + return request.render(.created); } -test "post" { +test "post query params" { var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); defer app.deinit(); - const response = try app.request(.POST, "/params", .{}); - try response.expectStatus(.created); + const response1 = try app.request(.POST, "/params", .{ + .params = .{ + .name = "Bob", + .favorite_animal = "raccoon", + }, + }); + try response1.expectStatus(.created); + + const response2 = try app.request(.POST, "/params", .{ + .params = .{ + .name = "Bob", + .favorite_animal = "platypus", + }, + }); + try response2.expectStatus(.unprocessable_entity); +} + +test "post json" { + var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); + defer app.deinit(); + + const response1 = try app.request(.POST, "/params", .{ + .json = .{ + .name = "Bob", + .favorite_animal = "raccoon", + }, + }); + try response1.expectJson("$.info.name", "Bob"); + try response1.expectJson("$.info.favorite_animal", "raccoon"); + try response1.expectJson("$.info.age", 100); + + const response2 = try app.request(.POST, "/params", .{ + .json = .{ + .name = "Hercules", + .favorite_animal = "cat", + .age = 11, + }, + }); + try response2.expectJson("$.info.name", "Hercules"); + try response2.expectJson("$.info.favorite_animal", "cat"); + try response2.expectJson("$.info.age", 11); + + const response3 = try app.request(.POST, "/params", .{ + .json = .{ + .name = "Hercules", + .favorite_animal = "platypus", + .age = 11, + }, + }); + try response3.expectStatus(.unprocessable_entity); } diff --git a/src/jetzig/colors.zig b/src/jetzig/colors.zig index cc776d3..d62379e 100644 --- a/src/jetzig/colors.zig +++ b/src/jetzig/colors.zig @@ -3,6 +3,7 @@ const std = @import("std"); const builtin = @import("builtin"); const types = @import("types.zig"); +const jetzig = @import("../jetzig.zig"); // Must be consistent with `std.io.tty.Color` for Windows compatibility. pub const codes = .{ @@ -108,6 +109,8 @@ pub fn colorize(color: std.io.tty.Color, buf: []u8, input: []const u8, is_colori } fn wrap(comptime attribute: []const u8, comptime message: []const u8) []const u8 { + if (comptime jetzig.environment == .production) return message; + return codes.escape ++ attribute ++ message ++ codes.escape ++ codes.reset; } diff --git a/src/jetzig/http.zig b/src/jetzig/http.zig index 03c21e2..3426606 100644 --- a/src/jetzig/http.zig +++ b/src/jetzig/http.zig @@ -16,6 +16,7 @@ pub const status_codes = @import("http/status_codes.zig"); pub const StatusCode = status_codes.StatusCode; pub const middleware = @import("http/middleware.zig"); pub const mime = @import("http/mime.zig"); +pub const params = @import("http/params.zig"); pub const SimplifiedRequest = struct { location: ?[]const u8, diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index a7fe119..6fb97d3 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -23,6 +23,7 @@ status_code: jetzig.http.status_codes.StatusCode = .not_found, response_data: *jetzig.data.Data, query_params: ?*jetzig.http.Query = null, query_body: ?*jetzig.http.Query = null, +_params_info: ?jetzig.http.params.ParamsInfo = null, multipart: ?jetzig.http.MultipartQuery = null, parsed_multipart: ?*jetzig.data.Data = null, _cookies: ?*jetzig.http.Cookies = null, @@ -172,6 +173,15 @@ pub fn respond(self: *Request) !void { self.httpz_response.body = self.response.content; } +/// Set the root value for response data. +/// ```zig +/// var root = request.data(.object) +/// var root = request.data(.array) +/// ``` +pub fn data(self: Request, comptime root: @TypeOf(.enum_literal)) !*jetzig.Data.Value { + return try self.response_data.root(root); +} + /// Render a response. This function can only be called once per request (repeat calls will /// trigger an error). pub fn render(self: *Request, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View { @@ -330,24 +340,45 @@ pub fn params(self: *Request) !*jetzig.data.Value { .JSON => { if (self.body.len == 0) return self.queryParams(); - var data = try self.allocator.create(jetzig.data.Data); - data.* = jetzig.data.Data.init(self.allocator); - data.fromJson(self.body) catch |err| { + var params_data = try self.allocator.create(jetzig.data.Data); + params_data.* = jetzig.data.Data.init(self.allocator); + params_data.fromJson(self.body) catch |err| { switch (err) { error.SyntaxError, error.UnexpectedEndOfInput => return error.JetzigBodyParseError, else => return err, } }; - return data.value.?; + return params_data.value.?; }, .HTML, .UNKNOWN => return self.parseQuery(), } } -// TODO -// pub fn expectParams(self: Request, T: type) ?T { -// -// } +/// Expect params that match a struct defined by `T`. If any required params are missing (or +/// cannot be matched to an enum param) then `null` is returned, otherwise a new `T` is returned +/// with values populated from matched params. +/// In both cases, `request.paramsInfo()` returns a hashmap keyed by all fields specified by `T` +/// and with values of `ParamInfo`. +/// A field in `T` can be an `enum`. In this case, if the param is a string (always true for +/// query params) then it is coerced to the target enum type if possible. +pub fn expectParams(self: *Request, T: type) !?T { + return try jetzig.http.params.expectParams(self, T); +} + +/// Information about request parameters after a call to `request.expectParams`. Call +/// `request.paramsInfo()` to initialize. +/// +/// Use `get` to fetch information about a specific param. +/// +/// ```zig +/// const params = try request.expectParams(struct { foo: []const u8 }); +/// const params_info = try request.paramsInfo(); +/// const foo_info = params_info.get("foo"); +/// ``` +pub fn paramsInfo(self: Request) !?jetzig.http.params.ParamsInfo { + const params_info = self._params_info orelse return null; + return try params_info.init(self.allocator); +} /// Retrieve a file from a `multipart/form-data`-encoded request body, if present. pub fn file(self: *Request, name: []const u8) !?jetzig.http.File { @@ -364,13 +395,13 @@ pub fn file(self: *Request, name: []const u8) !?jetzig.http.File { pub fn queryParams(self: *Request) !*jetzig.data.Value { if (self.query_params) |parsed| return parsed.data.value.?; - const data = try self.allocator.create(jetzig.data.Data); - data.* = jetzig.data.Data.init(self.allocator); + const params_data = try self.allocator.create(jetzig.data.Data); + params_data.* = jetzig.data.Data.init(self.allocator); self.query_params = try self.allocator.create(jetzig.http.Query); self.query_params.?.* = jetzig.http.Query.init( self.allocator, self.path.query orelse "", - data, + params_data, ); try self.query_params.?.parse(); return self.query_params.?.data.value.?; @@ -395,13 +426,13 @@ fn parseQuery(self: *Request) !*jetzig.data.Value { return self.parsed_multipart.?.value.?; } - const data = try self.allocator.create(jetzig.data.Data); - data.* = jetzig.data.Data.init(self.allocator); + const params_data = try self.allocator.create(jetzig.data.Data); + params_data.* = jetzig.data.Data.init(self.allocator); self.query_body = try self.allocator.create(jetzig.http.Query); self.query_body.?.* = jetzig.http.Query.init( self.allocator, self.body, - data, + params_data, ); try self.query_body.?.parse(); diff --git a/src/jetzig/http/params.zig b/src/jetzig/http/params.zig new file mode 100644 index 0000000..53c6c16 --- /dev/null +++ b/src/jetzig/http/params.zig @@ -0,0 +1,161 @@ +const std = @import("std"); + +const jetzig = @import("../../jetzig.zig"); + +/// See `Request.expectParams`. +pub fn expectParams(request: *jetzig.http.Request, T: type) !?T { + const actual_params = try request.params(); + + var t: T = undefined; + + const fields = std.meta.fields(T); + var statuses: [fields.len]ParamInfo = undefined; + var failed = false; + + inline for (fields, 0..) |field, index| { + if (actual_params.get(field.name)) |value| { + switch (@typeInfo(field.type)) { + .optional => |info| if (value.coerce(info.child)) |coerced| { + @field(t, field.name) = coerced; + statuses[index] = .{ .present = value.* }; + } else |err| { + failed = true; + statuses[index] = .{ .failed = .{ .err = err, .value = value.* } }; + }, + // coerce value to target type, null on coerce error (e.g. numeric expected) + else => { + if (value.coerce(field.type)) |coerced| { + @field(t, field.name) = coerced; + statuses[index] = .{ .present = value.* }; + } else |err| { + failed = true; + statuses[index] = .{ .failed = .{ .err = err, .value = value.* } }; + } + }, + } + } else if (@typeInfo(field.type) == .optional) { + // if no matching param found and params struct provides a default value, use it, + // otherwise set value to null + @field(t, field.name) = if (field.default_value) |default_value| + @as(*field.type, @ptrCast(@alignCast(@constCast(default_value)))).* + else + null; + statuses[index] = .blank; + // We don't set `failed = true` here because optional values are not required. + } else { + statuses[index] = .blank; + failed = true; + } + } + + request._params_info = .{ + .fields = try std.BoundedArray([]const u8, 1024).init(@intCast(fields.len)), + .params = try std.BoundedArray(ParamInfo, 1024).init(@intCast(fields.len)), + .required = try std.BoundedArray(bool, 1024).init(@intCast(fields.len)), + }; + inline for (fields, 0..) |field, index| { + request._params_info.?.fields.set(index, field.name); + request._params_info.?.params.set(index, statuses[index]); + request._params_info.?.required.set(index, @typeInfo(field.type) != .optional); + } + + if (failed) { + return null; + } + + return t; +} + +/// See `Request.paramsInfo`. +pub const ParamsInfo = struct { + params: std.BoundedArray(ParamInfo, 1024), + fields: std.BoundedArray([]const u8, 1024), + required: std.BoundedArray(bool, 1024), + state: enum { initial, ready } = .initial, + hashmap: std.StringHashMap(ParamInfo) = undefined, + + pub fn init(self: ParamsInfo, allocator: std.mem.Allocator) !ParamsInfo { + var hashmap = std.StringHashMap(ParamInfo).init(allocator); + try hashmap.ensureTotalCapacity(@intCast(self.params.len)); + for (self.fields.constSlice(), self.params.constSlice()) |field, param_info| { + hashmap.putAssumeCapacity(field, param_info); + } + return .{ + .params = self.params, + .fields = self.fields, + .required = self.required, + .hashmap = hashmap, + .state = .ready, + }; + } + + /// Get a information about a param. Provides param status (present/blank/failed) and + /// original values. See `ParamInfo`. + pub fn get(self: ParamsInfo, key: []const u8) ?ParamInfo { + std.debug.assert(self.state == .ready); + return self.hashmap.get(key); + } + + /// Detect if any required params are blank or if any errors occurred when coercing params to + /// their target type. + pub fn isValid(self: ParamsInfo) bool { + for (self.params.constSlice(), self.required.constSlice()) |param, required| { + if (required and param == .blank) return false; + if (param == .failed) return false; + } + return true; + } + + pub fn format(self: ParamsInfo, _: anytype, _: anytype, writer: anytype) !void { + std.debug.assert(self.state == .ready); + var it = self.hashmap.iterator(); + try writer.print("{s}{{ ", .{ + if (self.isValid()) + jetzig.colors.green(@typeName(@TypeOf(self))) + else + jetzig.colors.red(@typeName(@TypeOf(self))), + }); + while (it.next()) |entry| { + try writer.print("[" ++ jetzig.colors.blue("{s}") ++ ":{}] ", .{ entry.key_ptr.*, entry.value_ptr.* }); + } + try writer.writeByte('}'); + } +}; + +/// Status of a param as defined by the last call to `expectParams`. +pub const ParamInfo = union(enum) { + /// The field was matched and coerced correctly. `value` is the original param value. + present: jetzig.Data.Value, + /// The field was not present (regardless of whether the field was optional) + blank: void, + /// The field was present but could not be coerced to the required type + failed: ParamError, + + /// `err` is the error triggered by the type coercion attempt, `value` is the original param + /// value. + pub const ParamError = struct { + err: anyerror, + value: jetzig.Data.Value, + + pub fn format(self: ParamError, _: anytype, _: anytype, writer: anytype) !void { + try writer.print( + jetzig.colors.red("{s}") ++ ":\"" ++ jetzig.colors.yellow("{}") ++ "\"", + .{ @errorName(self.err), self.value }, + ); + } + }; + + pub fn format(self: ParamInfo, _: anytype, _: anytype, writer: anytype) !void { + switch (self) { + .present => |present| try writer.print( + jetzig.colors.green("present") ++ ":\"" ++ jetzig.colors.cyan("{}") ++ "\"", + .{present}, + ), + .blank => try writer.writeAll(jetzig.colors.yellow("blank")), + .failed => |failed| try writer.print( + jetzig.colors.red("failed") ++ ":" ++ jetzig.colors.cyan("{}") ++ "", + .{failed}, + ), + } + } +}; diff --git a/src/jetzig/testing.zig b/src/jetzig/testing.zig index e0b6908..b52957a 100644 --- a/src/jetzig/testing.zig +++ b/src/jetzig/testing.zig @@ -151,7 +151,7 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response: const json_banner = "\n{s}"; - if (data.ref(std.mem.trimLeft(u8, expected_path, &.{'.'}))) |value| { + if (data.ref(expected_path)) |value| { switch (value.*) { .string => |string| switch (@typeInfo(@TypeOf(expected_value))) { .pointer, .array => { diff --git a/src/jetzig/testing/App.zig b/src/jetzig/testing/App.zig index 9df1e04..9b99e42 100644 --- a/src/jetzig/testing/App.zig +++ b/src/jetzig/testing/App.zig @@ -345,7 +345,16 @@ fn buildOptions(allocator: std.mem.Allocator, app: *const App, args: anytype) !R if (std.mem.eql(u8, field.name, "body")) continue; } - @compileError("Unrecognized request option: " ++ field.name); + @compileError(std.fmt.comptimePrint( + "Unrecognized request option `{s}`. Expected: {{ {s}, {s}, {s}, {s} }}", + .{ + jetzig.colors.yellow(field.name), + jetzig.colors.cyan("headers"), + jetzig.colors.cyan("json"), + jetzig.colors.cyan("params"), + jetzig.colors.cyan("body"), + }, + )); } return .{ diff --git a/src/jetzig/views/view_types.zig b/src/jetzig/views/view_types.zig index 5eea082..b23ed69 100644 --- a/src/jetzig/views/view_types.zig +++ b/src/jetzig/views/view_types.zig @@ -34,7 +34,7 @@ pub const StaticViewWithArgs = *const fn ( // `Array.append(.array)`, `Array.append(.object)`, `Object.put(key, .array)`, and // `Object.put(key, .object)` also remove the need to use `data.array()` and `data.object()`. // The only remaining use is `data.root(.object)` and `data.root(.array)` which we can move to -// `request.responseData(.object)` and `request.responseData(.array)`. +// `request.data(.object)` and `request.data(.array)`. pub const LegacyViewWithoutId = *const fn ( *jetzig.http.Request, *jetzig.data.Data, From 78b69385302037dfbb6bd0ff4863dbadbb069ef0 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Sun, 17 Nov 2024 19:32:46 +0000 Subject: [PATCH 4/4] Fix custom routes Fix CLI view generator --- cli/commands/generate/view.zig | 4 ++-- src/jetzig/App.zig | 18 +++++++++++++++--- src/jetzig/views/Route.zig | 15 ++++++++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/cli/commands/generate/view.zig b/cli/commands/generate/view.zig index edcbc97..c2c847e 100644 --- a/cli/commands/generate/view.zig +++ b/cli/commands/generate/view.zig @@ -117,7 +117,7 @@ fn writeAction(allocator: std.mem.Allocator, writer: anytype, action: Action) !v const function = try std.fmt.allocPrint( allocator, \\pub fn {s}({s}request: *jetzig.{s}) !jetzig.View {{ - \\ return request.render({s}); + \\ {s}return request.render({s}); \\}} \\ \\ @@ -131,7 +131,7 @@ fn writeAction(allocator: std.mem.Allocator, writer: anytype, action: Action) !v if (action.static) "StaticRequest" else "Request", switch (action.method) { .index, .post, .new => "", - .get, .put, .patch, .delete => "\n _ = id;", + .get, .put, .patch, .delete => "_ = id;\n ", }, switch (action.method) { .index, .get, .new => ".ok", diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index 22c57ca..474b1fd 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -166,6 +166,9 @@ pub fn route( @memcpy(&view_name, module_name); std.mem.replaceScalar(u8, &view_name, '.', '/'); + const args_fields = std.meta.fields(std.meta.ArgsTuple(@TypeOf(viewFn))); + const legacy = args_fields.len > 0 and args_fields[args_fields.len - 1].type == *jetzig.Data; + self.custom_routes.append(.{ .id = "custom", .name = member, @@ -175,9 +178,18 @@ pub fn route( .uri_path = path, .layout = if (@hasDecl(module, "layout")) module.layout else null, .view = comptime switch (viewType(path)) { - .with_id => .{ .custom = .{ .with_id = viewFn } }, - .with_args => .{ .custom = .{ .with_args = viewFn } }, - .without_id => .{ .custom = .{ .without_id = viewFn } }, + .with_id => if (legacy) + .{ .legacy_with_id = viewFn } + else + .{ .with_id = viewFn }, + .without_id => if (legacy) + .{ .legacy_without_id = viewFn } + else + .{ .without_id = viewFn }, + .with_args => if (legacy) + .{ .legacy_with_args = viewFn } + else + .{ .with_args = viewFn }, }, .template = self.allocator.dupe(u8, &template) catch @panic("OOM"), .json_params = &.{}, diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig index 9a616b7..50a89a7 100644 --- a/src/jetzig/views/Route.zig +++ b/src/jetzig/views/Route.zig @@ -129,15 +129,24 @@ pub fn validateFormat(self: Route, request: *const jetzig.http.Request) bool { fn renderFn(self: Route, request: *jetzig.http.Request) anyerror!jetzig.views.View { return switch (self.view) { - .without_id => |func| try func(request), - .legacy_without_id => |func| try func(request, request.response_data), .with_id => |func| try func(request.path.resource_id, request), + .without_id => |func| try func(request), + .with_args => |func| try func( + try request.path.resourceArgs(self, request.allocator), + request, + ), .legacy_with_id => |func| try func( request.path.resource_id, request, request.response_data, ), - else => unreachable, + .legacy_without_id => |func| try func(request, request.response_data), + .legacy_with_args => |func| try func( + try request.path.resourceArgs(self, request.allocator), + request, + request.response_data, + ), + else => unreachable, // renderStaticFn is called for static routes, we can never get here. }; }