Merge pull request #122 from jetzig-framework/pseudo-http-verbs-and-edit-action

Routing updates
This commit is contained in:
bobf 2024-11-24 20:53:48 +00:00 committed by GitHub
commit 937efd9121
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 186 additions and 65 deletions

View File

@ -7,16 +7,16 @@
.hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163",
},
.zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/af75c8b842c3957eb97b4fc4bc49c7b2243968fa.tar.gz",
.hash = "1220ecac93d295dafd2f034a86f0979f6108d40e5ea1a39e3a2b9977c35147cac684",
.url = "https://github.com/jetzig-framework/zmpl/archive/ef1930b08e1f174ddb02a3a0a01b35aa8a4af235.tar.gz",
.hash = "1220a7bacb828f12cd013b0906da61a17fac6819ab8cee81e00d9ae1aa0faa992720",
},
.jetkv = .{
.url = "https://github.com/jetzig-framework/jetkv/archive/2b1130a48979ea2871c8cf6ca89c38b1e7062839.tar.gz",
.hash = "12201d75d73aad5e1c996de4d5ae87a00e58479c8d469bc2eeb5fdeeac8857bc09af",
},
.jetquery = .{
.url = "https://github.com/jetzig-framework/jetquery/archive/a31db467c4af1c97bc7c806e1cc1a81a39162954.tar.gz",
.hash = "12203af0466ccc3a9ab57fcdf57c92c57989fa7e827d81bc98d0a5787d65402c73c3",
.url = "https://github.com/jetzig-framework/jetquery/archive/5394d7cf5d7360bd5052cd13902e26f08610423b.tar.gz",
.hash = "1220119a1ee89d8d4b7e984a82bc70fe5d57aa412b821c561ce80a93fd8806bc4b8a",
},
.jetcommon = .{
.url = "https://github.com/jetzig-framework/jetcommon/archive/86f24cfdf2aaa0e8ada4539a6edef882708ced2b.tar.gz",
@ -26,10 +26,7 @@
.url = "https://github.com/ikskuh/zig-args/archive/0abdd6947a70e6d8cc83b66228cea614aa856206.tar.gz",
.hash = "1220411a8c46d95bbf3b6e2059854bcb3c5159d428814099df5294232b9980517e9c",
},
.pg = .{
.url = "https://github.com/karlseguin/pg.zig/archive/f376f4b30c63f1fdf90bc3afe246d3bc4175cd46.tar.gz",
.hash = "12200a55304988e942015b6244570b2dc0e87e5764719c9e7d5c812cd7ad34f6b138"
},
.pg = .{ .url = "https://github.com/karlseguin/pg.zig/archive/f376f4b30c63f1fdf90bc3afe246d3bc4175cd46.tar.gz", .hash = "12200a55304988e942015b6244570b2dc0e87e5764719c9e7d5c812cd7ad34f6b138" },
.smtp_client = .{
.url = "https://github.com/karlseguin/smtp_client.zig/archive/3cbe8f269e4c3a6bce407e7ae48b2c76307c559f.tar.gz",
.hash = "1220de146446d0cae4396e346cb8283dd5e086491f8577ddbd5e03ad0928111d8bc6",

View File

@ -43,7 +43,7 @@ pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8, he
const action_args = if (args.len > 1)
args[1..]
else
&[_][]const u8{ "index", "get", "new", "post", "put", "patch", "delete" };
&[_][]const u8{ "index", "get", "new", "edit", "post", "put", "patch", "delete" };
var actions = std.ArrayList(Action).init(allocator);
defer actions.deinit();
@ -92,7 +92,7 @@ pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8, he
std.debug.print("Generated view: {s}\n", .{realpath});
}
const Method = enum { index, get, new, post, put, patch, delete };
const Method = enum { index, get, new, edit, post, put, patch, delete };
const Action = struct {
method: Method,
static: bool,
@ -126,15 +126,15 @@ fn writeAction(allocator: std.mem.Allocator, writer: anytype, action: Action) !v
@tagName(action.method),
switch (action.method) {
.index, .post, .new => "",
.get, .put, .patch, .delete => "id: []const u8, ",
.get, .edit, .put, .patch, .delete => "id: []const u8, ",
},
if (action.static) "StaticRequest" else "Request",
switch (action.method) {
.index, .post, .new => "",
.get, .put, .patch, .delete => "_ = id;\n ",
.get, .edit, .put, .patch, .delete => "_ = id;\n ",
},
switch (action.method) {
.index, .get, .new => ".ok",
.index, .get, .edit, .new => ".ok",
.post => ".created",
.put, .patch, .delete => ".ok",
},
@ -164,17 +164,18 @@ fn writeTest(allocator: std.mem.Allocator, writer: anytype, name: []const u8, ac
.{
@tagName(action.method),
switch (action.method) {
.index, .get, .new => "GET",
.index, .get, .edit, .new => "GET",
.put, .patch, .delete, .post => action_upper,
},
name,
switch (action.method) {
.index, .post => "",
.edit => "/example-id/edit",
.new => "/new",
.get, .put, .patch, .delete => "/example-id",
},
switch (action.method) {
.index, .get, .new => ".ok",
.index, .get, .new, .edit => ".ok",
.post => ".created",
.put, .patch, .delete => ".ok",
},
@ -208,7 +209,7 @@ fn writeStaticParams(allocator: std.mem.Allocator, actions: []Action, writer: an
defer allocator.free(output);
try writer.writeAll(output);
},
.get, .put, .patch, .delete => {
.get, .put, .patch, .delete, .edit => {
const output = try std.fmt.allocPrint(
allocator,
\\ .{s} = .{{

View File

@ -16,6 +16,12 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
return request.render(.ok);
}
pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);
try root.put("id", id);
return request.render(.ok);
}
fn customFunction(a: i32, b: i32, c: i32) i32 {
return a + b + c;
}

View File

@ -15,6 +15,11 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
return request.render(.ok);
}
pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
try request.server.logger.INFO("id: {s}", .{id});
return request.render(.ok);
}
pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
const params = try request.params();

View File

@ -54,10 +54,20 @@ const Function = struct {
defer self.routes.allocator.free(relative_path);
const path = relative_path[0 .. relative_path.len - std.fs.path.extension(relative_path).len];
if (std.mem.eql(u8, path, "root")) return try self.routes.allocator.dupe(u8, "/");
const is_root = std.mem.eql(u8, path, "root");
const is_new = std.mem.eql(u8, self.name, "new");
const is_edit = std.mem.eql(u8, self.name, "edit");
if (is_root) {
if (is_edit) return try self.routes.allocator.dupe(u8, "/edit");
if (is_new) return try self.routes.allocator.dupe(u8, "/new");
return try self.routes.allocator.dupe(u8, "/");
}
const maybe_new = if (std.mem.eql(u8, self.name, "new")) "/new" else "";
return try std.mem.concat(self.routes.allocator, u8, &[_][]const u8{ "/", path, maybe_new });
const maybe_new = if (is_new) ("/new") else "";
// jetzig.http.Path.actionPath translates `/foo/bar/1/edit` to `/foo/bar/edit`
const maybe_edit = if (is_edit) ("/edit") else "";
return try std.mem.concat(self.routes.allocator, u8, &[_][]const u8{ "/", path, maybe_new, maybe_edit });
}
pub fn lessThanFn(context: void, lhs: Function, rhs: Function) bool {
@ -305,6 +315,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
.{ "index", false },
.{ "post", false },
.{ "new", false },
.{ "edit", true },
.{ "get", true },
.{ "edit", true },
.{ "put", true },

View File

@ -16,11 +16,12 @@ file_path: []const u8,
resource_id: []const u8,
extension: ?[]const u8,
query: ?[]const u8,
method: ?jetzig.Request.Method,
const Self = @This();
const Path = @This();
/// Initialize a new HTTP Path.
pub fn init(path: []const u8) Self {
pub fn init(path: []const u8) Path {
const base_path = getBasePath(path);
return .{
@ -31,18 +32,19 @@ pub fn init(path: []const u8) Self {
.resource_id = getResourceId(base_path),
.extension = getExtension(path),
.query = getQuery(path),
.method = getMethod(path),
};
}
/// No-op - no allocations currently performed.
pub fn deinit(self: *Self) void {
pub fn deinit(self: *Path) void {
_ = self;
}
/// For a given route with a possible `:id` placeholder, return the matching URL segment for that
/// placeholder. e.g. route with path `/foo/:id/bar` and request path `/foo/1234/bar` returns
/// `"1234"`.
pub fn resourceId(self: Self, route: jetzig.views.Route) []const u8 {
pub fn resourceId(self: Path, route: jetzig.views.Route) []const u8 {
var route_uri_path_it = std.mem.splitScalar(u8, route.uri_path, '/');
var base_path_it = std.mem.splitScalar(u8, self.base_path, '/');
@ -54,7 +56,7 @@ 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 {
pub fn resourceArgs(self: Path, 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, '/');
@ -82,21 +84,41 @@ pub fn resourceArgs(self: Self, route: jetzig.views.Route, allocator: std.mem.Al
// * `"/foo/bar/baz"`
// * `"/foo/bar/baz.html"`
// * `"/foo/bar/baz.html?qux=quux&corge=grault"`
// * `"/foo/bar/baz/_PATCH"`
fn getBasePath(path: []const u8) []const u8 {
if (std.mem.indexOfScalar(u8, path, '?')) |query_index| {
const base = if (std.mem.indexOfScalar(u8, path, '?')) |query_index| blk: {
if (std.mem.lastIndexOfScalar(u8, path[0..query_index], '.')) |extension_index| {
return path[0..extension_index];
break :blk path[0..extension_index];
} else {
return path[0..query_index];
break :blk path[0..query_index];
}
} else if (std.mem.lastIndexOfScalar(u8, path, '.')) |extension_index| {
return if (isRootPath(path[0..extension_index]))
} else if (std.mem.lastIndexOfScalar(u8, path, '.')) |extension_index| blk: {
break :blk if (isRootPath(path[0..extension_index]))
path[0..extension_index]
else
std.mem.trimRight(u8, path[0..extension_index], "/");
} else {
return if (isRootPath(path)) path else std.mem.trimRight(u8, path, "/");
} else blk: {
break :blk if (isRootPath(path)) path else std.mem.trimRight(u8, path, "/");
};
if (std.mem.lastIndexOfScalar(u8, base, '/')) |last_index| {
if (std.mem.startsWith(u8, base[last_index..], "/_")) {
return base[0..last_index];
} else {
return base;
}
} else return base;
}
fn getMethod(path: []const u8) ?jetzig.Request.Method {
var it = std.mem.splitBackwardsScalar(u8, path, '/');
const last_segment = it.next() orelse return null;
inline for (comptime std.enums.values(jetzig.Request.Method)) |method| {
if (std.mem.startsWith(u8, last_segment, "_" ++ @tagName(method))) {
return method;
}
}
return null;
}
// Extract `"/foo/bar"` from:
@ -131,6 +153,9 @@ fn getFilePath(path: []const u8) []const u8 {
// * `"/baz"`
fn getResourceId(base_path: []const u8) []const u8 {
var it = std.mem.splitBackwardsScalar(u8, base_path, '/');
if (std.mem.endsWith(u8, base_path, "/edit")) _ = it.next();
while (it.next()) |segment| return segment;
return base_path;
}
@ -164,169 +189,228 @@ fn getQuery(path: []const u8) ?[]const u8 {
}
}
// Extract `/foo/bar/edit` from `/foo/bar/1/edit`
// Extract `/foo/bar` from `/foo/bar/1`
pub fn actionPath(self: Path, buf: *[2048]u8) []const u8 {
if (self.path.len > 2048) return self.path; // Should never happen but we don't want to panic or overflow.
if (std.mem.endsWith(u8, self.path, "/edit")) {
var it = std.mem.tokenizeScalar(u8, self.path, '/');
var cursor: usize = 0;
const count = std.mem.count(u8, self.path, "/");
var index: usize = 0;
buf[0] = '/';
cursor += 1;
while (it.next()) |segment| : (index += 1) {
if (index + 2 == count) continue; // Skip ID - we special-case this in `resourceId`
@memcpy(buf[cursor .. cursor + segment.len], segment);
cursor += segment.len;
if (index + 1 < count) {
@memcpy(buf[cursor .. cursor + 1], "/");
cursor += 1;
}
}
return buf[0..cursor];
} else return self.path;
}
inline fn isRootPath(path: []const u8) bool {
return std.mem.eql(u8, path, "/");
}
test ".base_path (with extension, with query)" {
const path = Self.init("/foo/bar/baz.html?qux=quux&corge=grault");
const path = Path.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");
const path = Path.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");
const path = Path.init("/foo/bar/baz");
try std.testing.expectEqualStrings("/foo/bar/baz", path.base_path);
}
test ".base_path (with trailing slash)" {
const path = Self.init("/foo/bar/");
const path = Path.init("/foo/bar/");
try std.testing.expectEqualStrings("/foo/bar", path.base_path);
}
test ".base_path (root path)" {
const path = Self.init("/");
const path = Path.init("/");
try std.testing.expectEqualStrings("/", path.base_path);
}
test ".base_path (root path with extension)" {
const path = Self.init("/.json");
const path = Path.init("/.json");
try std.testing.expectEqualStrings("/", path.base_path);
try std.testing.expectEqualStrings(".json", path.extension.?);
}
test ".directory (with extension, with query)" {
const path = Self.init("/foo/bar/baz.html?qux=quux&corge=grault");
const path = Path.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");
const path = Path.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");
const path = Path.init("/foo/bar/baz");
try std.testing.expectEqualStrings("/foo/bar", path.directory);
}
test ".directory (without extension, without query, root path)" {
const path = Self.init("/");
const path = Path.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");
const path = Path.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");
const path = Path.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");
const path = Path.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");
const path = Path.init("/baz");
try std.testing.expectEqualStrings("baz", path.resource_id);
}
test ".resource_id (with trailing slash)" {
const path = Self.init("/foo/bar/");
const path = Path.init("/foo/bar/");
try std.testing.expectEqualStrings("bar", path.resource_id);
}
test ".extension (with query)" {
const path = Self.init("/foo/bar/baz.html?qux=quux&corge=grault");
const path = Path.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");
const path = Path.init("/foo/bar/baz.html");
try std.testing.expectEqualStrings(".html", path.extension.?);
}
test ".extension (without extension)" {
const path = Self.init("/foo/bar/baz");
const path = Path.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");
const path = Path.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");
const path = Path.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");
const path = Path.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");
const path = Path.init("/foo/bar/baz");
try std.testing.expect(path.query == null);
}
test ".query (with empty query)" {
const path = Self.init("/foo/bar/baz?");
const path = Path.init("/foo/bar/baz?");
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");
const path = Path.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");
const path = Path.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");
const path = Path.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");
const path = Path.init("/foo/bar/baz?qux=quux&corge=grault");
try std.testing.expectEqualStrings("/foo/bar/baz", path.file_path);
}
test ".resource_id (/foo/bar/123/edit)" {
const path = Path.init("/foo/bar/123/edit");
try std.testing.expectEqualStrings("123", path.resource_id);
}
test ".actionPath (/foo/bar/123/edit)" {
var buf: [2048]u8 = undefined;
const path = Path.init("/foo/bar/123/edit").actionPath(&buf);
try std.testing.expectEqualStrings("/foo/bar/edit", path);
}
test ".actionPath (/foo/bar)" {
var buf: [2048]u8 = undefined;
const path = Path.init("/foo/bar").actionPath(&buf);
try std.testing.expectEqualStrings("/foo/bar", path);
}
test ".base_path (/foo/bar/1/_PATCH" {
const path = Path.init("/foo/bar/1/_PATCH");
try std.testing.expectEqualStrings("/foo/bar/1", path.base_path);
try std.testing.expectEqualStrings("1", path.resource_id);
}
test ".method (/foo/bar/1/_PATCH" {
const path = Path.init("/foo/bar/1/_PATCH");
try std.testing.expect(path.method.? == .PATCH);
}

View File

@ -119,7 +119,11 @@ pub fn init(
response: *jetzig.http.Response,
repo: *jetzig.database.Repo,
) !Request {
const method = switch (httpz_request.method) {
const path = jetzig.http.Path.init(httpz_request.url.raw);
// We can fake the HTTP method by appending `/_PATCH` (e.g.) to the end of the URL.
// This allows using PATCH, PUT, DELETE from HTML forms.
const method = path.method orelse switch (httpz_request.method) {
.DELETE => Method.DELETE,
.GET => Method.GET,
.PATCH => Method.PATCH,
@ -134,7 +138,7 @@ pub fn init(
return .{
.allocator = allocator,
.path = jetzig.http.Path.init(httpz_request.url.raw),
.path = path,
.method = method,
.headers = jetzig.http.Headers.init(allocator, httpz_request.headers),
.server = server,
@ -764,6 +768,7 @@ pub fn match(self: *Request, route: jetzig.views.Route) !bool {
.index => self.isMatch(.exact, route),
.get => self.isMatch(.resource_id, route),
.new => self.isMatch(.exact, route),
.edit => self.isMatch(.exact, route),
else => false,
},
.POST => switch (route.action) {
@ -796,8 +801,18 @@ fn isMatch(
.resource_id => self.path.directory,
};
// Special case for `/foobar/1/new` -> render `new()`
if (route.action == .get and std.mem.eql(u8, self.path.resource_id, "new")) return false;
if (route.action == .get) {
// Special case for `/foobar/1/new` -> render `new()` - prevent matching `get`
if (std.mem.eql(u8, self.path.resource_id, "new")) return false;
// Special case for `/foobar/1/edit` -> render `edit()` - prevent matching `get`
if (std.mem.eql(u8, self.path.resource_id, "edit")) return false;
}
if (route.action == .edit and std.mem.endsWith(u8, self.path.path, "/edit")) {
var buf: [2048]u8 = undefined;
const action_path = self.path.actionPath(&buf);
if (std.mem.eql(u8, action_path, route.uri_path)) return true;
}
return std.mem.eql(u8, path, route.uri_path);
}

View File

@ -786,7 +786,7 @@ fn matchStaticContent(self: *Server, request: *jetzig.http.Request) !?[]const u8
self.decoded_static_route_params[index].get("params"),
route,
request,
params,
params.*,
)) return switch (request_format) {
.HTML, .UNKNOWN => static_output.output.html,
.JSON => static_output.output.json,
@ -822,7 +822,7 @@ fn matchStaticOutput(
maybe_expected_params: ?*jetzig.data.Value,
route: jetzig.views.Route,
request: *const jetzig.http.Request,
params: *jetzig.data.Value,
params: jetzig.data.Value,
) bool {
return if (maybe_expected_params) |expected_params| blk: {
const params_match = expected_params.count() == 0 or expected_params.eql(params);

View File

@ -5,7 +5,7 @@ const view_types = @import("view_types.zig");
const Route = @This();
pub const Action = enum { index, get, new, post, put, patch, delete, custom };
pub const Action = enum { index, get, new, edit, post, put, patch, delete, custom };
pub const View = union(enum) {
with_id: view_types.ViewWithId,
@ -32,6 +32,7 @@ pub const Formats = struct {
index: ?[]const ResponseFormat = null,
get: ?[]const ResponseFormat = null,
new: ?[]const ResponseFormat = null,
edit: ?[]const ResponseFormat = null,
post: ?[]const ResponseFormat = null,
put: ?[]const ResponseFormat = null,
patch: ?[]const ResponseFormat = null,
@ -114,6 +115,7 @@ pub fn validateFormat(self: Route, request: *const jetzig.http.Request) bool {
.index => formats.index orelse return true,
.get => formats.get orelse return true,
.new => formats.new orelse return true,
.edit => formats.edit orelse return true,
.post => formats.post orelse return true,
.put => formats.put orelse return true,
.patch => formats.patch orelse return true,