From 80ca764c0f342795c9e3d83dedb28e5530cb87ed Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Sat, 25 May 2024 21:14:05 +0100 Subject: [PATCH] Update Zmpl for template inheritance Permit setting template during view render with `request.setTemplate()` Permit middleware to define custom routes to static content with `pub const Routes` (implemented for something no longer needed but seems useful anyway). Implement globbing on custom routes, `/foo/:bar*` will glob all path segments including and after `/foo/...`, e.g. `/foo/bar/baz/qux` will pass invoke the custom view function with an array of `bar`, `baz`, `qux` as first argument (instead of typical resource ID). --- build.zig.zon | 4 +-- demo/src/app/views/quotes.zig | 1 - src/jetzig.zig | 6 +++- src/jetzig/App.zig | 20 +++++++++---- src/jetzig/http/Path.zig | 24 +++++++++++++++ src/jetzig/http/Request.zig | 16 ++++++++++ src/jetzig/http/Server.zig | 38 ++++++++++++++++++++++-- src/jetzig/middleware.zig | 34 +++++++++++++++++++++ src/jetzig/middleware/HtmxMiddleware.zig | 19 ++---------- src/jetzig/views/Route.zig | 12 +++++++- 10 files changed, 145 insertions(+), 29 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 04619d2..db5bd28 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,8 +7,8 @@ .hash = "12203b56c2e17a2fd62ea3d3d9be466f43921a3aef88b381cf58f41251815205fdb5", }, .zmpl = .{ - .url = "https://github.com/jetzig-framework/zmpl/archive/918b74353c5680b54b435dcacd67268b23ffa129.tar.gz", - .hash = "1220bf01968a822771a33bb51e37ff8ee13d21437f95ec55150ffa7c6a9fb1dfcbc5", + .url = "https://github.com/jetzig-framework/zmpl/archive/aa4d8ad5b63976d96e3b2c187f5b0b2c693905a1.tar.gz", + .hash = "1220b6dfedaf6ad2b464ebcec2aafdc01ba593a07a53885033d56c50a5a04334b517", }, .jetkv = .{ .url = "https://github.com/jetzig-framework/jetkv/archive/78bcdcc6b0cbd3ca808685c64554a15701f13250.tar.gz", diff --git a/demo/src/app/views/quotes.zig b/demo/src/app/views/quotes.zig index 3ee13f6..c02be02 100644 --- a/demo/src/app/views/quotes.zig +++ b/demo/src/app/views/quotes.zig @@ -24,7 +24,6 @@ pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { const params = try request.params(); try root.put("param", params.get("foo").?); - std.debug.print("{}\n", .{params}); return request.render(.ok); } diff --git a/src/jetzig.zig b/src/jetzig.zig index 2cd68c5..57255bf 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -44,7 +44,9 @@ pub const View = views.View; /// A route definition. Generated at build type by `Routes.zig`. pub const Route = views.Route; -const root = @import("root"); +/// A middleware route definition. Allows middleware to define custom routes in order to serve +/// content. +pub const MiddlewareRoute = middleware.MiddlewareRoute; /// An asynchronous job that runs outside of the request/response flow. Create via `Request.job` /// and set params with `Job.put`, then call `Job.schedule()` to add to the @@ -61,6 +63,8 @@ pub const MailerDefinition = mail.MailerDefinition; /// `ERROR`, etc.). Note that all log functions are CAPITALIZED. pub const Logger = loggers.Logger; +const root = @import("root"); + /// Global configuration. Override these values by defining in `src/main.zig` with: /// ```zig /// pub const jetzig_options = struct { diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index cc49fcc..f1d01c2 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -181,15 +181,25 @@ pub fn route( .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 } }, + .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 } }, }, .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, "/:"); +inline fn viewType(path: []const u8) enum { with_id, without_id, with_args } { + var it = std.mem.tokenizeSequence(u8, path, "/"); + while (it.next()) |segment| { + if (std.mem.startsWith(u8, segment, ":")) { + if (std.mem.endsWith(u8, segment, "*")) return .with_args; + return .with_id; + } + } + + return .without_id; } diff --git a/src/jetzig/http/Path.zig b/src/jetzig/http/Path.zig index 915f47f..db695d9 100644 --- a/src/jetzig/http/Path.zig +++ b/src/jetzig/http/Path.zig @@ -54,6 +54,30 @@ pub fn resourceId(self: Self, route: jetzig.views.Route) []const u8 { return self.resource_id; } +pub fn resourceArgs(self: Self, route: jetzig.views.Route, allocator: std.mem.Allocator) ![]const []const u8 { + var args = std.ArrayList([]const u8).init(allocator); + var route_uri_path_it = std.mem.splitScalar(u8, route.uri_path, '/'); + var path_it = std.mem.splitScalar(u8, self.base_path, '/'); + + var matched = false; + + while (path_it.next()) |path_segment| { + const route_uri_path_segment = route_uri_path_it.next(); + if (!matched and + route_uri_path_segment != null and + std.mem.startsWith(u8, route_uri_path_segment.?, ":") and + std.mem.endsWith(u8, route_uri_path_segment.?, "*")) + { + matched = true; + } + if (matched) { + try args.append(path_segment); + } + } + + return try args.toOwnedSlice(); +} + // 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 d78553d..6dbbb5c 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -27,6 +27,7 @@ _cookies: ?*jetzig.http.Cookies = null, _session: ?*jetzig.http.Session = null, body: []const u8 = undefined, processed: bool = false, +dynamic_assigned_template: ?[]const u8 = null, layout: ?[]const u8 = null, layout_disabled: bool = false, rendered: bool = false, @@ -513,6 +514,21 @@ pub fn formatStatus(self: *const Request, status_code: jetzig.http.StatusCode) ! }; } +/// Override default template name for a matched route. +pub fn setTemplate(self: *Request, name: []const u8) void { + self.dynamic_assigned_template = name; +} + +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(); + + for (paths) |subpaths| { + for (subpaths) |path| try buf.append(path); + } + return try std.mem.join(self.allocator, "/", buf.items); +} + pub fn setResponse( self: *Request, rendered_view: jetzig.http.Server.RenderedView, diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index ea03f59..9eafcd6 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -166,6 +166,17 @@ fn renderResponse(self: *Server, request: *jetzig.http.Request) !void { return; } + if (matchMiddlewareRoute(request)) |route| { + if (route.content) |content| { + const rendered: RenderedView = .{ + .view = .{ .data = request.response_data, .status_code = route.status }, + .content = content, + }; + request.setResponse(rendered, .{ .content_type = route.content_type }); + return; + } else unreachable; // In future a MiddlewareRoute might provide a render function etc. + } + const route = self.matchCustomRoute(request) orelse try self.matchRoute(request, false); switch (request.requestFormat()) { @@ -196,19 +207,21 @@ fn renderHTML( }; return request.setResponse(rendered, .{}); } else { - // Try rendering without a template to see if we get a redirect. + // Try rendering without a template to see if we get a redirect or a template + // assigned in a view. const rendered = self.renderView(matched_route, request, null) catch |err| { if (isUnhandledError(err)) return err; const rendered_error = try self.renderInternalServerError(request, err); return request.setResponse(rendered_error, .{}); }; - return if (request.redirected) + return if (request.redirected or request.dynamic_assigned_template != null) request.setResponse(rendered, .{}) else request.setResponse(try self.renderNotFound(request), .{}); } } else { + // If no matching route found, try to render a Markdown file in views directory. if (try self.renderMarkdown(request)) |rendered| { return request.setResponse(rendered, .{}); } else { @@ -259,7 +272,7 @@ fn renderView( self: *Server, route: jetzig.views.Route, request: *jetzig.http.Request, - template: ?zmpl.Template, + maybe_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 @@ -271,6 +284,11 @@ fn renderView( return try self.renderInternalServerError(request, err); }; + const template: ?zmpl.Template = if (request.dynamic_assigned_template) |request_template| + zmpl.findPrefixed("views", request_template) orelse maybe_template + else + maybe_template; + if (request.rendered_multiple) return error.JetzigMultipleRenderError; if (request.rendered_view) |rendered_view| { @@ -507,6 +525,20 @@ fn matchCustomRoute(self: Server, request: *const jetzig.http.Request) ?jetzig.v return null; } +fn matchMiddlewareRoute(request: *const jetzig.http.Request) ?jetzig.middleware.MiddlewareRoute { + const middlewares = jetzig.config.get([]const type, "middleware"); + + inline for (middlewares) |middleware| { + if (@hasDecl(middleware, "routes")) { + inline for (middleware.routes) |route| { + if (route.match(request)) return 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. diff --git a/src/jetzig/middleware.zig b/src/jetzig/middleware.zig index 7271fea..89b27a6 100644 --- a/src/jetzig/middleware.zig +++ b/src/jetzig/middleware.zig @@ -1 +1,35 @@ +const std = @import("std"); +const jetzig = @import("../jetzig.zig"); + pub const HtmxMiddleware = @import("middleware/HtmxMiddleware.zig"); + +const RouteOptions = struct { + content: ?[]const u8 = null, + content_type: []const u8 = "text/html", + status: jetzig.http.StatusCode = .ok, +}; + +pub const MiddlewareRoute = struct { + method: jetzig.http.Request.Method, + path: []const u8, + content: ?[]const u8, + content_type: []const u8, + status: jetzig.http.StatusCode, + + pub fn match(self: MiddlewareRoute, request: *const jetzig.http.Request) bool { + if (self.method != request.method) return false; + if (!std.mem.eql(u8, self.path, request.path.file_path)) return false; + + return true; + } +}; + +pub fn route(method: jetzig.http.Request.Method, path: []const u8, options: RouteOptions) MiddlewareRoute { + return .{ + .method = method, + .path = path, + .content = options.content, + .content_type = options.content_type, + .status = options.status, + }; +} diff --git a/src/jetzig/middleware/HtmxMiddleware.zig b/src/jetzig/middleware/HtmxMiddleware.zig index 8c56431..0f80448 100644 --- a/src/jetzig/middleware/HtmxMiddleware.zig +++ b/src/jetzig/middleware/HtmxMiddleware.zig @@ -1,20 +1,13 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); -const Self = @This(); - -/// Initialize htmx middleware. -pub fn init(request: *jetzig.http.Request) !*Self { - const middleware = try request.allocator.create(Self); - return middleware; -} +const HtmxMiddleware = @This(); /// Detects the `HX-Request` header and, if present, disables the default layout for the current /// request. This allows a view to specify a layout that will render the full page when the /// request doesn't come via htmx and, when the request does come from htmx, only return the /// content rendered directly by the view function. -pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { - _ = self; +pub fn afterRequest(request: *jetzig.http.Request) !void { if (request.headers.get("HX-Target")) |target| { try request.server.logger.DEBUG( "[middleware-htmx] htmx request detected, disabling layout. (#{s})", @@ -26,8 +19,7 @@ pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { /// If a redirect was issued during request processing, reset any response data, set response /// status to `200 OK` and replace the `Location` header with a `HX-Redirect` header. -pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { - _ = self; +pub fn beforeResponse(request: *const jetzig.http.Request, response: *jetzig.http.Response) !void { if (response.status_code != .moved_permanently and response.status_code != .found) return; if (request.headers.get("HX-Request") == null) return; @@ -37,8 +29,3 @@ pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jet try response.headers.append("HX-Redirect", location); } } - -/// Clean up the allocated htmx middleware. -pub fn deinit(self: *Self, request: *jetzig.http.Request) void { - request.allocator.destroy(self); -} diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig index 00f6107..053dd3a 100644 --- a/src/jetzig/views/Route.zig +++ b/src/jetzig/views/Route.zig @@ -11,6 +11,7 @@ pub const RenderStaticFn = *const fn (Route, *jetzig.http.StaticRequest) anyerro 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 DynamicViewType = union(Action) { @@ -36,6 +37,7 @@ pub const StaticViewType = union(Action) { pub const CustomViewType = union(enum) { with_id: ViewWithId, without_id: ViewWithoutId, + with_args: ViewWithArgs, }; pub const ViewType = union(enum) { @@ -87,7 +89,10 @@ pub fn match(self: Route, request: *const jetzig.http.Request) bool { 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.startsWith(u8, expected_segment, ":")) { + if (std.mem.endsWith(u8, expected_segment, "*")) return true; + continue; + } if (!std.mem.eql(u8, expected_segment, actual_segment)) return false; } @@ -100,6 +105,11 @@ fn renderFn(self: Route, request: *jetzig.http.Request) anyerror!jetzig.views.Vi .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