From 60d27d9a6c62dfbc91d48319ce194496bc48ccfd Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Sat, 16 Mar 2024 09:49:27 +0000 Subject: [PATCH] Implement htmx middleware When `HX-Target` header is present, bypass any configured layout for the request. This allows a full page reload to render with a layout, i.e. render the entire page, while a request from htmx will load just the content directly generated by the view. --- demo/src/app/views/htmx.zig | 13 ++ demo/src/app/views/htmx/index.zmpl | 3 + demo/src/app/views/layouts/application.zmpl | 4 + demo/src/app/views/layouts/bangbang.zmpl | 6 - demo/src/app/views/quotes.zig | 2 + demo/src/app/views/root.zig | 2 + demo/src/app/views/root/index.zmpl | 35 ++-- demo/src/main.zig | 7 +- src/jetzig.zig | 2 + src/jetzig/http/Headers.zig | 37 ++++- src/jetzig/http/Path.zig | 174 ++++++++++++++++++++ src/jetzig/http/Request.zig | 70 +++++++- src/jetzig/http/Server.zig | 34 +++- src/jetzig/middleware.zig | 1 + src/jetzig/middleware/HtmxMiddleware.zig | 44 +++++ src/jetzig/util.zig | 9 + src/tests.zig | 1 + 17 files changed, 396 insertions(+), 48 deletions(-) create mode 100644 demo/src/app/views/htmx.zig create mode 100644 demo/src/app/views/htmx/index.zmpl delete mode 100644 demo/src/app/views/layouts/bangbang.zmpl create mode 100644 src/jetzig/http/Path.zig create mode 100644 src/jetzig/middleware.zig create mode 100644 src/jetzig/middleware/HtmxMiddleware.zig create mode 100644 src/jetzig/util.zig diff --git a/demo/src/app/views/htmx.zig b/demo/src/app/views/htmx.zig new file mode 100644 index 0000000..52263bd --- /dev/null +++ b/demo/src/app/views/htmx.zig @@ -0,0 +1,13 @@ +const std = @import("std"); +const jetzig = @import("jetzig"); + +pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { + _ = data; + const params = try request.params(); + if (params.get("redirect")) |location| { + std.debug.print("location: {s}\n", .{try location.toString()}); + return request.redirect(try location.toString(), .moved_permanently); + } + + return request.render(.ok); +} diff --git a/demo/src/app/views/htmx/index.zmpl b/demo/src/app/views/htmx/index.zmpl new file mode 100644 index 0000000..76457d0 --- /dev/null +++ b/demo/src/app/views/htmx/index.zmpl @@ -0,0 +1,3 @@ +
+ Content goes here +
diff --git a/demo/src/app/views/layouts/application.zmpl b/demo/src/app/views/layouts/application.zmpl index 6701f4d..57aad59 100644 --- a/demo/src/app/views/layouts/application.zmpl +++ b/demo/src/app/views/layouts/application.zmpl @@ -1,5 +1,9 @@ + + + + diff --git a/demo/src/app/views/layouts/bangbang.zmpl b/demo/src/app/views/layouts/bangbang.zmpl deleted file mode 100644 index 2778ea4..0000000 --- a/demo/src/app/views/layouts/bangbang.zmpl +++ /dev/null @@ -1,6 +0,0 @@ - - - -
{zmpl.content}
- - diff --git a/demo/src/app/views/quotes.zig b/demo/src/app/views/quotes.zig index 27cace8..3626913 100644 --- a/demo/src/app/views/quotes.zig +++ b/demo/src/app/views/quotes.zig @@ -1,6 +1,8 @@ const std = @import("std"); const jetzig = @import("jetzig"); +pub const layout = "application"; + pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { var body = try data.object(); diff --git a/demo/src/app/views/root.zig b/demo/src/app/views/root.zig index e12ef72..721a390 100644 --- a/demo/src/app/views/root.zig +++ b/demo/src/app/views/root.zig @@ -2,6 +2,8 @@ const jetzig = @import("jetzig"); const importedFunction = @import("../lib/example.zig").exampleFunction; +pub const layout = "application"; + 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!")); diff --git a/demo/src/app/views/root/index.zmpl b/demo/src/app/views/root/index.zmpl index 5de793a..ca64125 100644 --- a/demo/src/app/views/root/index.zmpl +++ b/demo/src/app/views/root/index.zmpl @@ -1,26 +1,15 @@ - - - +
+
- - - + // Renders `src/app/views/root/_quotes.zmpl`: +
{^root/quotes}
- -
-
+
+ + + +
- // Renders `src/app/views/root/_quotes.zmpl`: -
{^root/quotes}
- -
- - - -
- -
Take a look at the /demo/src/app/ directory to see how this application works.
-
Visit jetzig.dev to get started.
-
- - +
Take a look at the /demo/src/app/ directory to see how this application works.
+
Visit jetzig.dev to get started.
+
diff --git a/demo/src/main.zig b/demo/src/main.zig index c73e865..6ba3a19 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -4,7 +4,12 @@ pub const jetzig = @import("jetzig"); pub const routes = @import("routes").routes; pub const jetzig_options = struct { - pub const middleware: []const type = &.{@import("app/middleware/DemoMiddleware.zig")}; + pub const middleware: []const type = &.{ + // htmx middleware skips layouts when `HX-Target` header is present and issues + // `HX-Redirect` instead of a regular HTTP redirect when `request.redirect` is called. + jetzig.middleware.HtmxMiddleware, + @import("app/middleware/DemoMiddleware.zig"), + }; }; pub fn main() !void { diff --git a/src/jetzig.zig b/src/jetzig.zig index 53feb86..91447f7 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -8,6 +8,8 @@ pub const data = @import("jetzig/data.zig"); pub const caches = @import("jetzig/caches.zig"); pub const views = @import("jetzig/views.zig"); pub const colors = @import("jetzig/colors.zig"); +pub const middleware = @import("jetzig/middleware.zig"); +pub const util = @import("jetzig/util.zig"); /// The primary interface for a Jetzig application. Create an `App` in your application's /// `src/main.zig` and call `start` to launch the application. diff --git a/src/jetzig/http/Headers.zig b/src/jetzig/http/Headers.zig index 8fbcfd5..b49027a 100644 --- a/src/jetzig/http/Headers.zig +++ b/src/jetzig/http/Headers.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const jetzig = @import("../../jetzig.zig"); allocator: std.mem.Allocator, headers: HeadersArray, @@ -18,14 +19,10 @@ pub fn deinit(self: *Self) void { self.headers.deinit(self.allocator); } -// Gets the first value for a given header identified by `name`. Case-insensitive string comparison. +// Gets the first value for a given header identified by `name`. Names are case insensitive. pub fn getFirstValue(self: *Self, name: []const u8) ?[]const u8 { - headers: for (self.headers.items) |header| { - if (name.len != header.name.len) continue; - for (name, header.name) |expected, actual| { - if (std.ascii.toLower(expected) != std.ascii.toLower(actual)) continue :headers; - } - return header.value; + for (self.headers.items) |header| { + if (jetzig.util.equalStringsCaseInsensitive(name, header.name)) return header.value; } return null; } @@ -35,6 +32,20 @@ pub fn append(self: *Self, name: []const u8, value: []const u8) !void { self.headers.appendAssumeCapacity(.{ .name = name, .value = value }); } +/// Removes **all** header entries matching `name`. Names are case-insensitive. +pub fn remove(self: *Self, name: []const u8) void { + if (self.headers.items.len == 0) return; + + var index: usize = self.headers.items.len; + + while (index > 0) { + index -= 1; + if (jetzig.util.equalStringsCaseInsensitive(name, self.headers.items[index].name)) { + _ = self.headers.orderedRemove(index); + } + } +} + /// Returns an iterator which implements `next()` returning each name/value of the stored headers. pub fn iterator(self: *Self) Iterator { return Iterator{ .headers = self.headers }; @@ -116,6 +127,18 @@ test "iterator" { } } +test "remove" { + const allocator = std.testing.allocator; + var headers = Self.init(allocator); + defer headers.deinit(); + try headers.append("foo", "baz"); + try headers.append("foo", "qux"); + try headers.append("bar", "quux"); + headers.remove("Foo"); // Headers are case-insensitive. + try std.testing.expect(headers.getFirstValue("foo") == null); + try std.testing.expectEqualStrings(headers.getFirstValue("bar").?, "quux"); +} + test "stdHeaders" { const allocator = std.testing.allocator; var headers = Self.init(allocator); diff --git a/src/jetzig/http/Path.zig b/src/jetzig/http/Path.zig new file mode 100644 index 0000000..758eb27 --- /dev/null +++ b/src/jetzig/http/Path.zig @@ -0,0 +1,174 @@ +const std = @import("std"); + +path: []const u8, +base_path: []const u8, +resource_id: []const u8, +extension: ?[]const u8, +query: ?[]const u8, + +const Self = @This(); + +/// Initialize a new HTTP Path. +pub fn init(path: []const u8) Self { + return .{ + .path = path, + .base_path = getBasePath(path), + .resource_id = getResourceId(path), + .extension = getExtension(path), + .query = getQuery(path), + }; +} + +/// No-op - no allocations currently performed. +pub fn deinit(self: *Self) void { + _ = self; +} + +// Extract `"/foo/bar/baz"` from: +// * `"/foo/bar/baz"` +// * `"/foo/bar/baz.html"` +// * `"/foo/bar/baz.html?qux=quux&corge=grault"` +fn getBasePath(path: []const u8) []const u8 { + if (std.mem.indexOfScalar(u8, path, '?')) |query_index| { + if (std.mem.lastIndexOfScalar(u8, path[0..query_index], '.')) |extension_index| { + return path[0..extension_index]; + } else { + return path[0..query_index]; + } + } else if (std.mem.lastIndexOfScalar(u8, path, '.')) |extension_index| { + return path[0..extension_index]; + } else { + return path; + } +} + +// Extract `"baz"` from: +// * `"/foo/bar/baz"` +// * `"/foo/bar/baz.html"` +// * `"/foo/bar/baz.html?qux=quux&corge=grault"` +// * `"/baz"` +fn getResourceId(path: []const u8) []const u8 { + const base_path = getBasePath(path); + var it = std.mem.splitBackwardsScalar(u8, base_path, '/'); + while (it.next()) |segment| return segment; + return base_path; +} + +// Extract `".html"` from: +// * `"/foo/bar/baz.html"` +// * `"/foo/bar/baz.html?qux=quux&corge=grault"` +fn getExtension(path: []const u8) ?[]const u8 { + if (std.mem.indexOfScalar(u8, path, '?')) |query_index| { + if (std.mem.lastIndexOfScalar(u8, path[0..query_index], '.')) |extension_index| { + return path[extension_index..query_index]; + } else { + return null; + } + } else if (std.mem.lastIndexOfScalar(u8, path, '.')) |extension_index| { + return path[extension_index..]; + } else { + return null; + } +} + +// Extract `"qux=quux&corge=grault"` from: +// * `"/foo/bar/baz.html?qux=quux&corge=grault"` +// * `"/foo/bar/baz?qux=quux&corge=grault"` +fn getQuery(path: []const u8) ?[]const u8 { + if (std.mem.indexOfScalar(u8, path, '?')) |query_index| { + if (path.len - 1 <= query_index) return null; + return path[query_index + 1 ..]; + } else { + return null; + } +} + +test ".base_path (with extension, with query)" { + const path = Self.init("/foo/bar/baz.html?qux=quux&corge=grault"); + + try std.testing.expectEqualStrings("/foo/bar/baz", path.base_path); +} + +test ".base_path (with extension, without query)" { + const path = Self.init("/foo/bar/baz.html"); + + try std.testing.expectEqualStrings("/foo/bar/baz", path.base_path); +} + +test ".base_path (without extension, without query)" { + const path = Self.init("/foo/bar/baz"); + + try std.testing.expectEqualStrings("/foo/bar/baz", path.base_path); +} + +test ".resource_id (with extension, with query)" { + const path = Self.init("/foo/bar/baz.html?qux=quux&corge=grault"); + + try std.testing.expectEqualStrings("baz", path.resource_id); +} + +test ".resource_id (with extension, without query)" { + const path = Self.init("/foo/bar/baz.html"); + + try std.testing.expectEqualStrings("baz", path.resource_id); +} + +test ".resource_id (without extension, without query)" { + const path = Self.init("/foo/bar/baz"); + + try std.testing.expectEqualStrings("baz", path.resource_id); +} + +test ".resource_id (without extension, without query, without base path)" { + const path = Self.init("/baz"); + + try std.testing.expectEqualStrings("baz", path.resource_id); +} + +test ".extension (with query)" { + const path = Self.init("/foo/bar/baz.html?qux=quux&corge=grault"); + + try std.testing.expectEqualStrings(".html", path.extension.?); +} + +test ".extension (without query)" { + const path = Self.init("/foo/bar/baz.html"); + + try std.testing.expectEqualStrings(".html", path.extension.?); +} + +test ".extension (without extension)" { + const path = Self.init("/foo/bar/baz"); + + try std.testing.expect(path.extension == null); +} + +test ".query (with extension, with query)" { + const path = Self.init("/foo/bar/baz.html?qux=quux&corge=grault"); + + try std.testing.expectEqualStrings(path.query.?, "qux=quux&corge=grault"); +} + +test ".query (without extension, with query)" { + const path = Self.init("/foo/bar/baz?qux=quux&corge=grault"); + + try std.testing.expectEqualStrings(path.query.?, "qux=quux&corge=grault"); +} + +test ".query (with extension, without query)" { + const path = Self.init("/foo/bar/baz.json"); + + try std.testing.expect(path.query == null); +} + +test ".query (without extension, without query)" { + const path = Self.init("/foo/bar/baz"); + + try std.testing.expect(path.query == null); +} + +test ".query (with empty query)" { + const path = Self.init("/foo/bar/baz?"); + + try std.testing.expect(path.query == null); +} diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 0bf76ab..01f15d3 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -25,6 +25,11 @@ cookies: *jetzig.http.Cookies = undefined, session: *jetzig.http.Session = undefined, body: []const u8 = undefined, processed: bool = false, +layout: ?[]const u8 = null, +layout_disabled: bool = false, +rendered: bool = false, +rendered_multiple: bool = false, +rendered_view: ?jetzig.views.View = null, pub fn init( allocator: std.mem.Allocator, @@ -146,10 +151,48 @@ pub fn respond(self: *Self) !void { ); } +/// Render a response. This function can only be called once per request (repeat calls will +/// trigger an error). pub fn render(self: *Self, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View { - return .{ .data = self.response_data, .status_code = status_code }; + if (self.rendered) self.rendered_multiple = true; + + self.rendered = true; + self.rendered_view = .{ .data = self.response_data, .status_code = status_code }; + return self.rendered_view.?; } +/// Issue a redirect to a new location. +/// ```zig +/// return request.redirect("https://www.example.com/", .moved_permanently); +/// ``` +/// ```zig +/// return request.redirect("https://www.example.com/", .found); +/// ``` +/// The second argument must be `moved_permanently` or `found`. +pub fn redirect(self: *Self, location: []const u8, redirect_status: enum { moved_permanently, found }) jetzig.views.View { + if (self.rendered) self.rendered_multiple = true; + + self.rendered = true; + + const status_code = switch (redirect_status) { + .moved_permanently => jetzig.http.status_codes.StatusCode.moved_permanently, + .found => jetzig.http.status_codes.StatusCode.found, + }; + + self.response_data.reset(); + + self.response.headers.remove("Location"); + self.response.headers.append("Location", location) catch @panic("OOM"); + + self.rendered_view = .{ .data = self.response_data, .status_code = status_code }; + return self.rendered_view.?; +} + +/// Infer the current format (JSON or HTML) from the request in this order: +/// * Extension (path ends in `.json` or `.html`) +/// * `Accept` header (`application/json` or `text/html`) +/// * `Content-Type` header (`application/json` or `text/html`) +/// * Fall back to default: HTML pub fn requestFormat(self: *Self) jetzig.http.Request.Format { return self.extensionFormat() orelse self.acceptHeaderFormat() orelse @@ -157,11 +200,34 @@ pub fn requestFormat(self: *Self) jetzig.http.Request.Format { .UNKNOWN; } +/// Set the layout for the current request/response. Use this to override a `pub const layout` +/// declaration in a view, either in middleware or in a view function itself. +pub fn setLayout(self: *Self, layout: ?[]const u8) void { + if (layout) |layout_name| { + self.layout = layout_name; + self.layout_disabled = false; + } else { + self.layout_disabled = true; + } +} + +/// Derive a layout name from the current request if defined, otherwise from the route (if +/// defined). +pub fn getLayout(self: *Self, route: *jetzig.views.Route) ?[]const u8 { + if (self.layout_disabled) return null; + if (self.layout) |capture| return capture; + if (route.layout) |capture| return capture; + + return null; +} + +/// Shortcut for `request.headers.getFirstValue`. Returns the first matching value for a given +/// header name or `null` if not found. Header names are case-insensitive. pub fn getHeader(self: *Self, key: []const u8) ?[]const u8 { return self.headers.getFirstValue(key); } -/// Provides a `Value` representing request parameters. Parameters are normalized, meaning that +/// Return a `Value` representing request parameters. Parameters are normalized, meaning that /// both the JSON request body and query parameters are accessed via the same interface. /// Note that query parameters are supported for JSON requests if no request body is present, /// otherwise the parsed JSON request body will take precedence and query parameters will be diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index 10809ad..569fde1 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -203,30 +203,46 @@ fn renderView( request: *jetzig.http.Request, template: ?zmpl.manifest.Template, ) !RenderedView { - const view = route.render(route.*, request) catch |err| { + // View functions return a `View` to help 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| { self.logger.debug("Encountered error: {s}", .{@errorName(err)}); if (isUnhandledError(err)) return err; if (isBadRequest(err)) return try self.renderBadRequest(request); return try self.renderInternalServerError(request, err); }; - if (template) |capture| { - return .{ - .view = view, - .content = try self.renderTemplateWithLayout(capture, view, route), - }; + + if (request.rendered_multiple) return error.JetzigMultipleRenderError; + + if (request.rendered_view) |rendered_view| { + if (template) |capture| { + return .{ + .view = rendered_view, + .content = try self.renderTemplateWithLayout(request, capture, rendered_view, route), + }; + } else { + // We are rendering JSON, content is the result of `toJson` on view data. + return .{ .view = rendered_view, .content = "" }; + } } else { - // We are rendering JSON, content is ignored. - return .{ .view = view, .content = "" }; + self.logger.debug("`request.render` was not invoked. Rendering empty content.", .{}); + request.response_data.reset(); + return .{ + .view = .{ .data = request.response_data, .status_code = .no_content }, + .content = "", + }; } } fn renderTemplateWithLayout( self: *Self, + request: *jetzig.http.Request, template: zmpl.manifest.Template, view: jetzig.views.View, route: *jetzig.views.Route, ) ![]const u8 { - if (route.layout) |layout_name| { + if (request.getLayout(route)) |layout_name| { // TODO: Allow user to configure layouts directory other than src/app/views/layouts/ const prefixed_name = try std.mem.concat(self.allocator, u8, &[_][]const u8{ "layouts_", layout_name }); defer self.allocator.free(prefixed_name); diff --git a/src/jetzig/middleware.zig b/src/jetzig/middleware.zig new file mode 100644 index 0000000..7271fea --- /dev/null +++ b/src/jetzig/middleware.zig @@ -0,0 +1 @@ +pub const HtmxMiddleware = @import("middleware/HtmxMiddleware.zig"); diff --git a/src/jetzig/middleware/HtmxMiddleware.zig b/src/jetzig/middleware/HtmxMiddleware.zig new file mode 100644 index 0000000..570e4ff --- /dev/null +++ b/src/jetzig/middleware/HtmxMiddleware.zig @@ -0,0 +1,44 @@ +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; +} + +/// 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 beforeRequest(self: *Self, request: *jetzig.http.Request) !void { + _ = self; + if (request.getHeader("HX-Target")) |target| { + request.server.logger.debug( + "[middleware-htmx] htmx request detected, disabling layout. (#{s})", + .{target}, + ); + request.setLayout(null); + } +} + +/// 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 afterRequest(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { + _ = self; + if (response.status_code != .moved_permanently and response.status_code != .found) return; + + if (response.headers.getFirstValue("Location")) |location| { + response.headers.remove("Location"); + response.status_code = .ok; + request.response_data.reset(); + 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/util.zig b/src/jetzig/util.zig new file mode 100644 index 0000000..495beaa --- /dev/null +++ b/src/jetzig/util.zig @@ -0,0 +1,9 @@ +const std = @import("std"); + +pub fn equalStringsCaseInsensitive(expected: []const u8, actual: []const u8) bool { + if (expected.len != actual.len) return false; + for (expected, actual) |expected_char, actual_char| { + if (std.ascii.toLower(expected_char) != std.ascii.toLower(actual_char)) return false; + } + return true; +} diff --git a/src/tests.zig b/src/tests.zig index 0a79db0..43c2047 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -2,5 +2,6 @@ test { _ = @import("jetzig/http/Query.zig"); _ = @import("jetzig/http/Headers.zig"); _ = @import("jetzig/http/Cookies.zig"); + _ = @import("jetzig/http/Path.zig"); @import("std").testing.refAllDeclsRecursive(@This()); }