From 77794cd34d878d90f0cf38614feebe1a0308a973 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Wed, 22 May 2024 18:33:52 +0100 Subject: [PATCH] Implement custom routes In `main()`: ```zig app.route(.GET, "/custom/:id/foo/bar", @import("app/views/custom/foo.zig"), .bar); ``` Routes `GET` request with path (e.g.) `/custom/1234/foo/bar` to `bar()` defined in `src/app/views/custom/foo.zig`. Routes with an `:id` segment expect a function with three parameters, routes without an `:id` segment expect a function with two parameters (i.e. the same as `get` vs `index`). --- demo/src/app/views/custom/foo.zig | 7 +++ demo/src/app/views/custom/foo/bar.zmpl | 3 ++ demo/src/main.zig | 3 ++ src/jetzig.zig | 1 + src/jetzig/App.zig | 66 +++++++++++++++++++++----- src/jetzig/http/Path.zig | 16 +++++++ src/jetzig/http/Request.zig | 2 +- src/jetzig/http/Server.zig | 43 ++++++++++++----- src/jetzig/views.zig | 1 + src/jetzig/views/CustomRoute.zig | 8 ++++ src/jetzig/views/Route.zig | 50 +++++++++++++++---- 11 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 demo/src/app/views/custom/foo.zig create mode 100644 demo/src/app/views/custom/foo/bar.zmpl create mode 100644 src/jetzig/views/CustomRoute.zig diff --git a/demo/src/app/views/custom/foo.zig b/demo/src/app/views/custom/foo.zig new file mode 100644 index 0000000..25279a8 --- /dev/null +++ b/demo/src/app/views/custom/foo.zig @@ -0,0 +1,7 @@ +const jetzig = @import("jetzig"); + +pub fn bar(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { + var root = try data.object(); + try root.put("id", data.string(id)); + return request.render(.ok); +} diff --git a/demo/src/app/views/custom/foo/bar.zmpl b/demo/src/app/views/custom/foo/bar.zmpl new file mode 100644 index 0000000..dc0f14d --- /dev/null +++ b/demo/src/app/views/custom/foo/bar.zmpl @@ -0,0 +1,3 @@ +{{jetzig_view}} +{{jetzig_action}} +{{.id}} diff --git a/demo/src/main.zig b/demo/src/main.zig index e89b27a..685efd1 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -169,5 +169,8 @@ pub fn main() !void { const app = try jetzig.init(allocator); defer app.deinit(); + // Example custom route: + // app.route(.GET, "/custom/:id/foo/bar", @import("app/views/custom/foo.zig"), .bar); + try app.start(routes, .{}); } diff --git a/src/jetzig.zig b/src/jetzig.zig index fc5026d..2cd68c5 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -191,5 +191,6 @@ pub fn init(allocator: std.mem.Allocator) !App { return .{ .environment = environment, .allocator = allocator, + .custom_routes = std.ArrayList(views.Route).init(allocator), }; } diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index 4e209cd..cc49fcc 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -9,9 +9,10 @@ const App = @This(); environment: jetzig.Environment, allocator: std.mem.Allocator, +custom_routes: std.ArrayList(jetzig.views.Route), -pub fn deinit(self: App) void { - _ = self; +pub fn deinit(self: *const App) void { + @constCast(self).custom_routes.deinit(); } // Not used yet, but allows us to add new options to `start()` without breaking @@ -31,30 +32,32 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void { var routes = std.ArrayList(*jetzig.views.Route).init(self.allocator); for (routes_module.routes) |const_route| { - var route = try self.allocator.create(jetzig.views.Route); - route.* = .{ + var var_route = try self.allocator.create(jetzig.views.Route); + var_route.* = .{ .name = const_route.name, .action = const_route.action, .view_name = const_route.view_name, .uri_path = const_route.uri_path, .view = const_route.view, - .static_view = const_route.static_view, .static = const_route.static, - .render = const_route.render, - .renderStatic = const_route.renderStatic, .layout = const_route.layout, .template = const_route.template, .json_params = const_route.json_params, }; - try route.initParams(self.allocator); - try routes.append(route); + try var_route.initParams(self.allocator); + try routes.append(var_route); } defer routes.deinit(); - defer for (routes.items) |route| { - route.deinitParams(); - self.allocator.destroy(route); + defer for (routes.items) |var_route| { + var_route.deinitParams(); + self.allocator.destroy(var_route); + }; + + defer for (self.custom_routes.items) |custom_route| { + self.allocator.free(custom_route.view_name); + self.allocator.free(custom_route.template); }; var store = try jetzig.kv.Store.init( @@ -105,6 +108,7 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void { self.allocator, server_options, routes.items, + self.custom_routes.items, &routes_module.jobs, &routes_module.mailers, &mime_map, @@ -151,3 +155,41 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void { } }; } + +pub fn route( + self: *const App, + comptime method: jetzig.http.Request.Method, + comptime path: []const u8, + comptime module: type, + comptime action: std.meta.DeclEnum(module), +) void { + const member = @tagName(action); + const viewFn = @field(module, member); + const module_name = comptime std.mem.trimLeft(u8, @typeName(module), "app.views."); + + var template: [module_name.len + 1 + member.len]u8 = undefined; + @memcpy(&template, module_name ++ "/" ++ member); + std.mem.replaceScalar(u8, &template, '.', '/'); + + var view_name: [module_name.len]u8 = undefined; + @memcpy(&view_name, module_name); + std.mem.replaceScalar(u8, &view_name, '.', '/'); + + @constCast(self).custom_routes.append(.{ + .name = member, + .action = .custom, + .method = method, + .view_name = self.allocator.dupe(u8, &view_name) catch @panic("OOM"), + .uri_path = path, + .view = comptime switch (isIncludeId(path)) { + true => .{ .custom = .{ .with_id = viewFn } }, + false => .{ .custom = .{ .without_id = viewFn } }, + }, + .template = self.allocator.dupe(u8, &template) catch @panic("OOM"), + .json_params = &.{}, + }) catch @panic("OOM"); +} + +inline fn isIncludeId(comptime path: []const u8) bool { + return std.mem.containsAtLeast(u8, path, 1, "/:"); +} diff --git a/src/jetzig/http/Path.zig b/src/jetzig/http/Path.zig index defd4fb..915f47f 100644 --- a/src/jetzig/http/Path.zig +++ b/src/jetzig/http/Path.zig @@ -7,6 +7,7 @@ /// * Extension (".json", ".html", etc.) /// * Query (everything after first "?" character) const std = @import("std"); +const jetzig = @import("../../jetzig.zig"); path: []const u8, base_path: []const u8, @@ -38,6 +39,21 @@ pub fn deinit(self: *Self) void { _ = self; } +/// For a given route with a possible `:id` placeholder, return the matching URL segment for that +/// placeholder. e.g. route with path `/foo/:id/bar` and request path `/foo/1234/bar` returns +/// `"1234"`. +pub fn resourceId(self: Self, route: jetzig.views.Route) []const u8 { + var route_uri_path_it = std.mem.splitScalar(u8, route.uri_path, '/'); + var base_path_it = std.mem.splitScalar(u8, self.base_path, '/'); + + while (route_uri_path_it.next()) |route_uri_path_segment| { + const base_path_segment = base_path_it.next() orelse return self.resource_id; + if (std.mem.startsWith(u8, route_uri_path_segment, ":")) return base_path_segment; + } + + return self.resource_id; +} + // Extract `"/foo/bar/baz"` from: // * `"/foo/bar/baz"` // * `"/foo/bar/baz.html"` diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 35de773..d78553d 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -241,7 +241,7 @@ pub fn setLayout(self: *Request, layout: ?[]const u8) void { /// Derive a layout name from the current request if defined, otherwise from the route (if /// defined). -pub fn getLayout(self: *Request, route: *jetzig.views.Route) ?[]const u8 { +pub fn getLayout(self: *Request, route: jetzig.views.Route) ?[]const u8 { if (self.layout_disabled) return null; if (self.layout) |capture| return capture; if (route.layout) |capture| return capture; diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index 879ee0b..ea03f59 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -20,6 +20,7 @@ allocator: std.mem.Allocator, logger: jetzig.loggers.Logger, options: ServerOptions, routes: []*jetzig.views.Route, +custom_routes: []jetzig.views.Route, job_definitions: []const jetzig.JobDefinition, mailer_definitions: []const jetzig.MailerDefinition, mime_map: *jetzig.http.mime.MimeMap, @@ -35,6 +36,7 @@ pub fn init( allocator: std.mem.Allocator, options: ServerOptions, routes: []*jetzig.views.Route, + custom_routes: []jetzig.views.Route, job_definitions: []const jetzig.JobDefinition, mailer_definitions: []const jetzig.MailerDefinition, mime_map: *jetzig.http.mime.MimeMap, @@ -47,6 +49,7 @@ pub fn init( .logger = options.logger, .options = options, .routes = routes, + .custom_routes = custom_routes, .job_definitions = job_definitions, .mailer_definitions = mailer_definitions, .mime_map = mime_map, @@ -163,7 +166,7 @@ fn renderResponse(self: *Server, request: *jetzig.http.Request) !void { return; } - const route = try self.matchRoute(request, false); + const route = self.matchCustomRoute(request) orelse try self.matchRoute(request, false); switch (request.requestFormat()) { .HTML => try self.renderHTML(request, route), @@ -182,7 +185,7 @@ fn renderStatic(resource: StaticResource, request: *jetzig.http.Request) !void { fn renderHTML( self: *Server, request: *jetzig.http.Request, - route: ?*jetzig.views.Route, + route: ?jetzig.views.Route, ) !void { if (route) |matched_route| { if (zmpl.findPrefixed("views", matched_route.template)) |template| { @@ -217,7 +220,7 @@ fn renderHTML( fn renderJSON( self: *Server, request: *jetzig.http.Request, - route: ?*jetzig.views.Route, + route: ?jetzig.views.Route, ) !void { if (route) |matched_route| { var rendered = try self.renderView(matched_route, request, null); @@ -254,14 +257,14 @@ pub const RenderedView = struct { view: jetzig.views.View, content: []const u8 } fn renderView( self: *Server, - route: *jetzig.views.Route, + route: jetzig.views.Route, request: *jetzig.http.Request, template: ?zmpl.Template, ) !RenderedView { // View functions return a `View` to encourage users to return from a view function with // `return request.render(.ok)`, but the actual rendered view is stored in // `request.rendered_view`. - _ = route.render(route.*, request) catch |err| { + _ = route.render(route, request) catch |err| { try self.logger.ERROR("Encountered error: {s}", .{@errorName(err)}); if (isUnhandledError(err)) return err; if (isBadRequest(err)) return try self.renderBadRequest(request); @@ -299,7 +302,7 @@ fn renderTemplateWithLayout( request: *jetzig.http.Request, template: zmpl.Template, view: jetzig.views.View, - route: *jetzig.views.Route, + route: jetzig.views.Route, ) ![]const u8 { try addTemplateConstants(view, route); @@ -321,9 +324,15 @@ fn renderTemplateWithLayout( } else return try template.render(view.data); } -fn addTemplateConstants(view: jetzig.views.View, route: *const jetzig.views.Route) !void { +fn addTemplateConstants(view: jetzig.views.View, route: jetzig.views.Route) !void { try view.data.addConst("jetzig_view", view.data.string(route.view_name)); - try view.data.addConst("jetzig_action", view.data.string(@tagName(route.action))); + + const action = switch (route.action) { + .custom => route.name, + else => |tag| @tagName(tag), + }; + + try view.data.addConst("jetzig_action", view.data.string(action)); } fn isBadRequest(err: anyerror) bool { @@ -421,7 +430,7 @@ fn renderErrorView( switch (request.requestFormat()) { .HTML, .UNKNOWN => { if (zmpl.findPrefixed("views", route.template)) |template| { - try addTemplateConstants(view, route); + try addTemplateConstants(view, route.*); return .{ .view = view, .content = try template.render(request.response_data) }; } }, @@ -490,14 +499,22 @@ fn logStackTrace( try self.logger.ERROR("{s}\n", .{buf.items}); } -fn matchRoute(self: *Server, request: *jetzig.http.Request, static: bool) !?*jetzig.views.Route { +fn matchCustomRoute(self: Server, request: *const jetzig.http.Request) ?jetzig.views.Route { + for (self.custom_routes) |custom_route| { + if (custom_route.match(request)) return custom_route; + } + + return null; +} + +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| { - if (route.static == static and try request.match(route.*)) return route; + if (route.static == static and try request.match(route.*)) return route.*; } return null; @@ -574,7 +591,7 @@ fn matchStaticContent(self: *Server, request: *jetzig.http.Request) !?[]const u8 const matched_route = try self.matchRoute(request, true); if (matched_route) |route| { - const static_path = try staticPath(request, route.*); + const static_path = try staticPath(request, route); if (static_path) |capture| { return static_dir.readFileAlloc( diff --git a/src/jetzig/views.zig b/src/jetzig/views.zig index cfdf19b..b33a2fa 100644 --- a/src/jetzig/views.zig +++ b/src/jetzig/views.zig @@ -1,2 +1,3 @@ pub const Route = @import("views/Route.zig"); pub const View = @import("views/View.zig"); +pub const CustomRoute = @import("views/CustomRoute.zig"); diff --git a/src/jetzig/views/CustomRoute.zig b/src/jetzig/views/CustomRoute.zig new file mode 100644 index 0000000..a3c7593 --- /dev/null +++ b/src/jetzig/views/CustomRoute.zig @@ -0,0 +1,8 @@ +const jetzig = @import("../../jetzig.zig"); + +method: jetzig.http.Request.Method, +path: []const u8, +view: union(enum) { + with_id: *const fn (id: []const u8, *jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View, + without_id: *const fn (*jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View, +}, diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig index ae903f6..00f6107 100644 --- a/src/jetzig/views/Route.zig +++ b/src/jetzig/views/Route.zig @@ -4,12 +4,12 @@ const jetzig = @import("../../jetzig.zig"); const Route = @This(); -pub const Action = enum { index, get, post, put, patch, delete }; +pub const Action = enum { index, get, post, put, patch, delete, custom }; 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; -const ViewWithoutId = *const fn (*jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View; -const ViewWithId = *const fn (id: []const u8, *jetzig.http.Request, *jetzig.data.Data) 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; const StaticViewWithId = *const fn (id: []const u8, *jetzig.http.StaticRequest, *jetzig.data.Data) anyerror!jetzig.views.View; @@ -20,6 +20,7 @@ pub const DynamicViewType = union(Action) { put: ViewWithId, patch: ViewWithId, delete: ViewWithId, + custom: CustomViewType, }; pub const StaticViewType = union(Action) { @@ -29,23 +30,30 @@ pub const StaticViewType = union(Action) { put: StaticViewWithId, patch: StaticViewWithId, delete: StaticViewWithId, + custom: void, +}; + +pub const CustomViewType = union(enum) { + with_id: ViewWithId, + without_id: ViewWithoutId, }; 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, -view: ?ViewType = null, -static_view: ?StaticViewType = null, -static: bool, +view: ViewType, render: RenderFn = renderFn, renderStatic: RenderStaticFn = renderStaticFn, -layout: ?[]const u8, +static: bool = false, +layout: ?[]const u8 = null, template: []const u8, json_params: []const []const u8, params: std.ArrayList(*jetzig.data.Data) = undefined, @@ -70,34 +78,56 @@ pub fn deinitParams(self: *const Route) void { self.params.deinit(); } +/// 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; + + var request_path_it = std.mem.splitScalar(u8, request.path.base_path, '/'); + var uri_path_it = std.mem.splitScalar(u8, self.uri_path, '/'); + + while (uri_path_it.next()) |expected_segment| { + const actual_segment = request_path_it.next() orelse return false; + if (std.mem.startsWith(u8, expected_segment, ":")) continue; + if (!std.mem.eql(u8, expected_segment, actual_segment)) return false; + } + + return true; +} + fn renderFn(self: Route, request: *jetzig.http.Request) anyerror!jetzig.views.View { - switch (self.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), + }, // 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) { + 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), .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, } } 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) { + 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), .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, } }