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 @@
+
+
-
-
-
+ // 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.
-
-
-
-
+
Take a look at the /demo/src/app/ directory to see how this application works.
+
+
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());
}