From 60d27d9a6c62dfbc91d48319ce194496bc48ccfd Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Sat, 16 Mar 2024 09:49:27 +0000 Subject: [PATCH 1/3] 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()); } From 9b255eb19af8d086022ddb46f9658743a082e0c6 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Sun, 17 Mar 2024 14:22:59 +0000 Subject: [PATCH 2/3] Refactor path parsing Remove horrible path segments code, re-write as an abstracted, alloc-less `jetzig.http.Path`. --- src/jetzig/http.zig | 1 + src/jetzig/http/Path.zig | 96 +++++++++++++++++++++++++++-- src/jetzig/http/Request.zig | 118 +++--------------------------------- src/jetzig/http/Server.zig | 8 +-- src/jetzig/views/Route.zig | 8 +-- 5 files changed, 107 insertions(+), 124 deletions(-) diff --git a/src/jetzig/http.zig b/src/jetzig/http.zig index 66fe6fe..21fac26 100644 --- a/src/jetzig/http.zig +++ b/src/jetzig/http.zig @@ -8,6 +8,7 @@ pub const Session = @import("http/Session.zig"); pub const Cookies = @import("http/Cookies.zig"); pub const Headers = @import("http/Headers.zig"); pub const Query = @import("http/Query.zig"); +pub const Path = @import("http/Path.zig"); pub const status_codes = @import("http/status_codes.zig"); pub const middleware = @import("http/middleware.zig"); pub const mime = @import("http/mime.zig"); diff --git a/src/jetzig/http/Path.zig b/src/jetzig/http/Path.zig index 758eb27..defd4fb 100644 --- a/src/jetzig/http/Path.zig +++ b/src/jetzig/http/Path.zig @@ -1,7 +1,17 @@ +/// Abstraction of the path component of a URI. +/// Provides access to: +/// * Unaltered original path +/// * Base path (without extension and query string) +/// * Directory (parent path from base path) +/// * Resource ID (final component of base path) +/// * Extension (".json", ".html", etc.) +/// * Query (everything after first "?" character) const std = @import("std"); path: []const u8, base_path: []const u8, +directory: []const u8, +file_path: []const u8, resource_id: []const u8, extension: ?[]const u8, query: ?[]const u8, @@ -10,10 +20,14 @@ const Self = @This(); /// Initialize a new HTTP Path. pub fn init(path: []const u8) Self { + const base_path = getBasePath(path); + return .{ .path = path, - .base_path = getBasePath(path), - .resource_id = getResourceId(path), + .base_path = base_path, + .directory = getDirectory(base_path), + .file_path = getFilePath(path), + .resource_id = getResourceId(base_path), .extension = getExtension(path), .query = getQuery(path), }; @@ -42,13 +56,37 @@ fn getBasePath(path: []const u8) []const u8 { } } -// Extract `"baz"` from: +// Extract `"/foo/bar"` from: // * `"/foo/bar/baz"` +// Special case: +// * `"/"` returns `"/"` +pub fn getDirectory(base_path: []const u8) []const u8 { + if (std.mem.eql(u8, base_path, "/")) return "/"; + + if (std.mem.lastIndexOfScalar(u8, base_path, '/')) |index| { + return base_path[0..index]; + } else { + return "/"; + } +} + +// Extract `"/foo/bar/baz.html"` from: // * `"/foo/bar/baz.html"` // * `"/foo/bar/baz.html?qux=quux&corge=grault"` +// Special case: +// * `"/foo/bar/baz"` returns `"/foo/bar/baz"` +fn getFilePath(path: []const u8) []const u8 { + if (std.mem.indexOfScalar(u8, path, '?')) |query_index| { + return path[0..query_index]; + } else { + return path; + } +} + +// Extract `"baz"` from: +// * `"/foo/bar/baz"` // * `"/baz"` -fn getResourceId(path: []const u8) []const u8 { - const base_path = getBasePath(path); +fn getResourceId(base_path: []const u8) []const u8 { var it = std.mem.splitBackwardsScalar(u8, base_path, '/'); while (it.next()) |segment| return segment; return base_path; @@ -101,6 +139,30 @@ test ".base_path (without extension, without query)" { try std.testing.expectEqualStrings("/foo/bar/baz", path.base_path); } +test ".directory (with extension, with query)" { + const path = Self.init("/foo/bar/baz.html?qux=quux&corge=grault"); + + try std.testing.expectEqualStrings("/foo/bar", path.directory); +} + +test ".directory (with extension, without query)" { + const path = Self.init("/foo/bar/baz.html"); + + try std.testing.expectEqualStrings("/foo/bar", path.directory); +} + +test ".directory (without extension, without query)" { + const path = Self.init("/foo/bar/baz"); + + try std.testing.expectEqualStrings("/foo/bar", path.directory); +} + +test ".directory (without extension, without query, root path)" { + const path = Self.init("/"); + + try std.testing.expectEqualStrings("/", path.directory); +} + test ".resource_id (with extension, with query)" { const path = Self.init("/foo/bar/baz.html?qux=quux&corge=grault"); @@ -172,3 +234,27 @@ test ".query (with empty query)" { try std.testing.expect(path.query == null); } + +test ".file_path (with extension, with query)" { + const path = Self.init("/foo/bar/baz.json?qux=quux&corge=grault"); + + try std.testing.expectEqualStrings("/foo/bar/baz.json", path.file_path); +} + +test ".file_path (with extension, without query)" { + const path = Self.init("/foo/bar/baz.json"); + + try std.testing.expectEqualStrings("/foo/bar/baz.json", path.file_path); +} + +test ".file_path (without extension, without query)" { + const path = Self.init("/foo/bar/baz"); + + try std.testing.expectEqualStrings("/foo/bar/baz", path.file_path); +} + +test ".file_path (without extension, with query)" { + const path = Self.init("/foo/bar/baz?qux=quux&corge=grault"); + + try std.testing.expectEqualStrings("/foo/bar/baz", path.file_path); +} diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 01f15d3..f904420 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -10,10 +10,9 @@ pub const Modifier = enum { edit, new }; pub const Format = enum { HTML, JSON, UNKNOWN }; allocator: std.mem.Allocator, -path: []const u8, +path: jetzig.http.Path, method: Method, headers: jetzig.http.Headers, -segments: std.ArrayList([]const u8), server: *jetzig.http.Server, std_http_request: std.http.Server.Request, response: *jetzig.http.Response, @@ -50,23 +49,6 @@ pub fn init( _ => return error.JetzigUnsupportedHttpMethod, }; - // TODO: Replace all this with a `Path` type which exposes all components of the path in a - // sensible way: - // * Array of segments: "/foo/bar/baz" => .{ "foo", "bar", "baz" } - // * Resource ID: "/foo/bar/baz/1" => "1" - // * Extension: "/foo/bar/baz/1.json" => ".json" - // * Query params: "/foo/bar/baz?foo=bar&baz=qux" => .{ .foo = "bar", .baz => "qux" } - // * Anything else ? - var it = std.mem.splitScalar(u8, std_http_request.head.target, '/'); - var segments = std.ArrayList([]const u8).init(allocator); - while (it.next()) |segment| { - if (std.mem.indexOfScalar(u8, segment, '?')) |query_index| { - try segments.append(segment[0..query_index]); - } else { - try segments.append(segment); - } - } - const response_data = try allocator.create(jetzig.data.Data); response_data.* = jetzig.data.Data.init(allocator); @@ -77,11 +59,10 @@ pub fn init( return .{ .allocator = allocator, - .path = std_http_request.head.target, + .path = jetzig.http.Path.init(std_http_request.head.target), .method = method, .headers = jetzig.http.Headers.init(allocator), .server = server, - .segments = segments, .response = response, .response_data = response_data, .query_data = query_data, @@ -92,7 +73,6 @@ pub fn init( pub fn deinit(self: *Self) void { // self.session.deinit(); - self.segments.deinit(); self.allocator.destroy(self.cookies); self.allocator.destroy(self.session); if (self.processed) self.allocator.free(self.body); @@ -263,13 +243,10 @@ fn queryParams(self: *Self) !*jetzig.data.Value { } fn parseQueryString(self: *Self) !bool { - const delimiter_index = std.mem.indexOfScalar(u8, self.path, '?'); - if (delimiter_index) |index| { - if (self.path.len - 1 < index + 1) return false; - + if (self.path.query) |query| { self.query.* = jetzig.http.Query.init( self.allocator, - self.path[index + 1 ..], + query, self.query_data, ); try self.query.parse(); @@ -280,7 +257,7 @@ fn parseQueryString(self: *Self) !bool { } fn extensionFormat(self: *Self) ?jetzig.http.Request.Format { - const extension = std.fs.path.extension(self.path); + const extension = self.path.extension orelse return null; if (std.mem.eql(u8, extension, ".html")) { return .HTML; @@ -333,41 +310,6 @@ pub fn fmtMethod(self: *Self) []const u8 { }; } -pub fn resourceModifier(self: *Self) ?Modifier { - const basename = std.fs.path.basename(self.segments.items[self.segments.items.len - 1]); - const extension = std.fs.path.extension(basename); - const resource = basename[0 .. basename.len - extension.len]; - if (std.mem.eql(u8, resource, "edit")) return .edit; - if (std.mem.eql(u8, resource, "new")) return .new; - - return null; -} - -pub fn resourceName(self: *Self) []const u8 { - if (self.segments.items.len == 0) return "default"; // Should never happen ? - - const basename = std.fs.path.basename(self.segments.items[self.segments.items.len - 1]); - if (std.mem.indexOfScalar(u8, basename, '?')) |index| { - return basename[0..index]; - } - const extension = std.fs.path.extension(basename); - return basename[0 .. basename.len - extension.len]; -} - -pub fn resourcePath(self: *Self) ![]const u8 { - const path = try std.fs.path.join( - self.allocator, - self.segments.items[0 .. self.segments.items.len - 1], - ); - defer self.allocator.free(path); - return try std.mem.concat(self.allocator, u8, &[_][]const u8{ "/", path }); -} - -/// For a path `/foo/bar/baz/123.json`, returns `"123"`. -pub fn resourceId(self: *Self) []const u8 { - return self.resourceName(); -} - // Determine if a given route matches the current request. pub fn match(self: *Self, route: jetzig.views.Route) !bool { return switch (self.method) { @@ -398,55 +340,9 @@ pub fn match(self: *Self, route: jetzig.views.Route) !bool { fn isMatch(self: *Self, match_type: enum { exact, resource_id }, route: jetzig.views.Route) bool { const path = switch (match_type) { - .exact => self.pathWithoutExtension(), - .resource_id => self.pathWithoutExtensionAndResourceId(), + .exact => self.path.base_path, + .resource_id => self.path.directory, }; return std.mem.eql(u8, path, route.uri_path); } - -// TODO: Be a bit more deterministic in identifying extension, e.g. deal with `.` characters -// elsewhere in the path (e.g. in query string). -fn pathWithoutExtension(self: *Self) []const u8 { - const extension_index = std.mem.lastIndexOfScalar(u8, self.path, '.'); - if (extension_index) |capture| return self.path[0..capture]; - - const query_index = std.mem.indexOfScalar(u8, self.path, '?'); - if (query_index) |capture| return self.path[0..capture]; - - return self.path; -} - -fn pathWithoutExtensionAndResourceId(self: *Self) []const u8 { - const path = self.pathWithoutExtension(); - const index = std.mem.lastIndexOfScalar(u8, self.path, '/'); - if (index) |capture| { - if (capture == 0) return "/"; - return path[0..capture]; - } else { - return path; - } -} - -fn fullName(self: *Self) ![]const u8 { - return try self.name(true); -} - -fn fullNameWithStrippedResourceId(self: *Self) ![]const u8 { - return try self.name(false); -} - -fn name(self: *Self, with_resource_id: bool) ![]const u8 { - const dirname = try std.mem.join( - self.allocator, - "_", - self.segments.items[0 .. self.segments.items.len - 1], - ); - defer self.allocator.free(dirname); - - return std.mem.concat(self.allocator, u8, &[_][]const u8{ - dirname, - if (with_resource_id) "." else "", - if (with_resource_id) self.resourceName() else "", - }); -} diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index 569fde1..ea54355 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -348,7 +348,7 @@ fn requestLogMessage(self: *Self, request: *jetzig.http.Request) ![]const u8 { formatted_duration, request.fmtMethod(), status.format(), - request.path, + request.path.path, }); } @@ -390,7 +390,7 @@ fn matchStaticResource(self: *Self, request: *jetzig.http.Request) !?StaticResou } fn matchPublicContent(self: *Self, request: *jetzig.http.Request) !?StaticResource { - if (request.path.len < 2) return null; + if (request.path.file_path.len <= 1) return null; if (request.method != .GET) return null; var iterable_dir = std.fs.cwd().openDir( @@ -410,7 +410,7 @@ fn matchPublicContent(self: *Self, request: *jetzig.http.Request) !?StaticResour while (try walker.next()) |file| { if (file.kind != .file) continue; - if (std.mem.eql(u8, file.path, request.path[1..])) { + if (std.mem.eql(u8, file.path, request.path.file_path[1..])) { const content = try iterable_dir.readFileAlloc( request.allocator, file.path, @@ -476,7 +476,7 @@ fn staticPath(request: *jetzig.http.Request, route: jetzig.views.Route) !?[]cons if (try static_params.getValue("id")) |id| { switch (id.*) { .string => |capture| { - if (!std.mem.eql(u8, capture.value, request.resourceId())) continue; + if (!std.mem.eql(u8, capture.value, request.path.resource_id)) continue; }, // Should be unreachable - this means generated `routes.zig` is incoherent: inline else => return error.JetzigRouteError, diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig index 40bbf8d..3f4a7f1 100644 --- a/src/jetzig/views/Route.zig +++ b/src/jetzig/views/Route.zig @@ -80,11 +80,11 @@ fn renderFn(self: Self, request: *jetzig.http.Request) anyerror!jetzig.views.Vie switch (self.view.?.dynamic) { .index => |view| return try view(request, request.response_data), - .get => |view| return try view(request.resourceId(), 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.resourceId(), request, request.response_data), - .put => |view| return try view(request.resourceId(), request, request.response_data), - .delete => |view| return try view(request.resourceId(), 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), } } From 993caa5d3fe1441c96ce372f773da9c8e76e8867 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Sun, 17 Mar 2024 18:35:22 +0000 Subject: [PATCH 3/3] Implement redirects + htmx redirect support --- demo/src/app/middleware/DemoMiddleware.zig | 33 ++++++---- demo/src/app/views/{htmx.zig => redirect.zig} | 1 - .../app/views/{htmx => redirect}/index.zmpl | 0 src/jetzig/http/Request.zig | 16 ++++- src/jetzig/http/Server.zig | 10 ++- src/jetzig/http/middleware.zig | 65 ++++++++++++------- src/jetzig/middleware/HtmxMiddleware.zig | 5 +- 7 files changed, 87 insertions(+), 43 deletions(-) rename demo/src/app/views/{htmx.zig => redirect.zig} (83%) rename demo/src/app/views/{htmx => redirect}/index.zmpl (100%) diff --git a/demo/src/app/middleware/DemoMiddleware.zig b/demo/src/app/middleware/DemoMiddleware.zig index 06822c7..accf8b9 100644 --- a/demo/src/app/middleware/DemoMiddleware.zig +++ b/demo/src/app/middleware/DemoMiddleware.zig @@ -16,8 +16,8 @@ const std = @import("std"); const jetzig = @import("jetzig"); /// Define any custom data fields you want to store here. Assigning to these fields in the `init` -/// function allows you to access them in the `beforeRequest` and `afterRequest` functions, where -/// they can also be modified. +/// function allows you to access them in various middleware callbacks defined below, where they +/// can also be modified. my_custom_value: []const u8, const Self = @This(); @@ -29,25 +29,34 @@ pub fn init(request: *jetzig.http.Request) !*Self { return middleware; } -/// Invoked immediately after the request head has been processed, before relevant view function -/// is processed. This gives you access to request headers but not the request body. -pub fn beforeRequest(self: *Self, request: *jetzig.http.Request) !void { - request.server.logger.debug("[DemoMiddleware] my_custom_value: {s}", .{self.my_custom_value}); +/// Invoked immediately after the request is received but before it has started processing. +/// Any calls to `request.render` or `request.redirect` will prevent further processing of the +/// request, including any other middleware in the chain. +pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { + request.server.logger.debug("[DemoMiddleware:afterRequest] my_custom_value: {s}", .{self.my_custom_value}); self.my_custom_value = @tagName(request.method); } -/// Invoked immediately after the request has finished responding. Provides full access to the -/// response as well as the request. -pub fn afterRequest(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { +/// Invoked immediately before the response renders to the client. +/// The response can be modified here if needed. +pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { request.server.logger.debug( - "[DemoMiddleware] my_custom_value: {s}, response status: {s}", + "[DemoMiddleware:beforeResponse] my_custom_value: {s}, response status: {s}", .{ self.my_custom_value, @tagName(response.status_code) }, ); } -/// Invoked after `afterRequest` is called, use this function to do any clean-up. +/// Invoked immediately after the response has been finalized and sent to the client. +/// Response data can be accessed for logging, but any modifications will have no impact. +pub fn afterResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { + _ = self; + _ = response; + request.server.logger.debug("[DemoMiddleware:afterResponse] response completed", .{}); +} + +/// Invoked after `afterResponse` is called. Use this function to do any clean-up. /// Note that `request.allocator` is an arena allocator, so any allocations are automatically -/// done before the next request starts processing. +/// freed before the next request starts processing. pub fn deinit(self: *Self, request: *jetzig.http.Request) void { request.allocator.destroy(self); } diff --git a/demo/src/app/views/htmx.zig b/demo/src/app/views/redirect.zig similarity index 83% rename from demo/src/app/views/htmx.zig rename to demo/src/app/views/redirect.zig index 52263bd..efef2fd 100644 --- a/demo/src/app/views/htmx.zig +++ b/demo/src/app/views/redirect.zig @@ -5,7 +5,6 @@ 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); } diff --git a/demo/src/app/views/htmx/index.zmpl b/demo/src/app/views/redirect/index.zmpl similarity index 100% rename from demo/src/app/views/htmx/index.zmpl rename to demo/src/app/views/redirect/index.zmpl diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index f904420..b4088de 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -27,6 +27,7 @@ processed: bool = false, layout: ?[]const u8 = null, layout_disabled: bool = false, rendered: bool = false, +redirected: bool = false, rendered_multiple: bool = false, rendered_view: ?jetzig.views.View = null, @@ -127,7 +128,13 @@ pub fn respond(self: *Self) !void { try self.std_http_request.respond( self.response.content, - .{ .keep_alive = false, .extra_headers = std_response_headers.items }, + .{ + .keep_alive = false, + .status = switch (self.response.status_code) { + inline else => |tag| @field(std.http.Status, @tagName(tag)), + }, + .extra_headers = std_response_headers.items, + }, ); } @@ -149,10 +156,15 @@ pub fn render(self: *Self, status_code: jetzig.http.status_codes.StatusCode) jet /// 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 { +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; + self.redirected = true; const status_code = switch (redirect_status) { .moved_permanently => jetzig.http.status_codes.StatusCode.moved_permanently, diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index ea54355..ee9dd68 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -99,13 +99,16 @@ fn processNextRequest(self: *Self, allocator: std.mem.Allocator, std_http_server try request.process(); - var middleware_data = try jetzig.http.middleware.beforeMiddleware(&request); + var middleware_data = try jetzig.http.middleware.afterRequest(&request); try self.renderResponse(&request); try request.response.headers.append("content-type", response.content_type); + + try jetzig.http.middleware.beforeResponse(&middleware_data, &request); + try request.respond(); - try jetzig.http.middleware.afterMiddleware(&middleware_data, &request); + try jetzig.http.middleware.afterResponse(&middleware_data, &request); jetzig.http.middleware.deinit(&middleware_data, &request); const log_message = try self.requestLogMessage(&request); @@ -214,8 +217,9 @@ fn renderView( }; if (request.rendered_multiple) return error.JetzigMultipleRenderError; - if (request.rendered_view) |rendered_view| { + if (request.redirected) return .{ .view = rendered_view, .content = "" }; + if (template) |capture| { return .{ .view = rendered_view, diff --git a/src/jetzig/http/middleware.zig b/src/jetzig/http/middleware.zig index 2817f1b..965b612 100644 --- a/src/jetzig/http/middleware.zig +++ b/src/jetzig/http/middleware.zig @@ -10,7 +10,7 @@ else const MiddlewareData = std.BoundedArray(*anyopaque, middlewares.len); -pub fn beforeMiddleware(request: *jetzig.http.Request) !MiddlewareData { +pub fn afterRequest(request: *jetzig.http.Request) !MiddlewareData { var middleware_data = MiddlewareData.init(0) catch unreachable; inline for (middlewares, 0..) |middleware, index| { @@ -20,27 +20,6 @@ pub fn beforeMiddleware(request: *jetzig.http.Request) !MiddlewareData { middleware_data.insert(index, data) catch unreachable; } - inline for (middlewares, 0..) |middleware, index| { - if (comptime !@hasDecl(middleware, "beforeRequest")) continue; - if (comptime @hasDecl(middleware, "init")) { - const data = middleware_data.get(index); - try @call( - .always_inline, - middleware.beforeRequest, - .{ @as(*middleware, @ptrCast(@alignCast(data))), request }, - ); - } else { - try @call(.always_inline, middleware.beforeRequest, .{request}); - } - } - - return middleware_data; -} - -pub fn afterMiddleware( - middleware_data: *MiddlewareData, - request: *jetzig.http.Request, -) !void { inline for (middlewares, 0..) |middleware, index| { if (comptime !@hasDecl(middleware, "afterRequest")) continue; if (comptime @hasDecl(middleware, "init")) { @@ -48,10 +27,50 @@ pub fn afterMiddleware( try @call( .always_inline, middleware.afterRequest, + .{ @as(*middleware, @ptrCast(@alignCast(data))), request }, + ); + } else { + try @call(.always_inline, middleware.afterRequest, .{request}); + } + } + + return middleware_data; +} + +pub fn beforeResponse( + middleware_data: *MiddlewareData, + request: *jetzig.http.Request, +) !void { + inline for (middlewares, 0..) |middleware, index| { + if (comptime !@hasDecl(middleware, "beforeResponse")) continue; + if (comptime @hasDecl(middleware, "init")) { + const data = middleware_data.get(index); + try @call( + .always_inline, + middleware.beforeResponse, .{ @as(*middleware, @ptrCast(@alignCast(data))), request, request.response }, ); } else { - try @call(.always_inline, middleware.afterRequest, .{ request, request.response }); + try @call(.always_inline, middleware.beforeResponse, .{ request, request.response }); + } + } +} + +pub fn afterResponse( + middleware_data: *MiddlewareData, + request: *jetzig.http.Request, +) !void { + inline for (middlewares, 0..) |middleware, index| { + if (comptime !@hasDecl(middleware, "afterResponse")) continue; + if (comptime @hasDecl(middleware, "init")) { + const data = middleware_data.get(index); + try @call( + .always_inline, + middleware.afterResponse, + .{ @as(*middleware, @ptrCast(@alignCast(data))), request, request.response }, + ); + } else { + try @call(.always_inline, middleware.afterResponse, .{ request, request.response }); } } } diff --git a/src/jetzig/middleware/HtmxMiddleware.zig b/src/jetzig/middleware/HtmxMiddleware.zig index 570e4ff..453b5d0 100644 --- a/src/jetzig/middleware/HtmxMiddleware.zig +++ b/src/jetzig/middleware/HtmxMiddleware.zig @@ -13,7 +13,7 @@ pub fn init(request: *jetzig.http.Request) !*Self { /// 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 { +pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { _ = self; if (request.getHeader("HX-Target")) |target| { request.server.logger.debug( @@ -26,9 +26,10 @@ pub fn beforeRequest(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 afterRequest(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { +pub fn beforeResponse(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 (request.headers.getFirstValue("HX-Request") == null) return; if (response.headers.getFirstValue("Location")) |location| { response.headers.remove("Location");