diff --git a/build.zig.zon b/build.zig.zon
index 2e8f224..c3617ae 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -7,20 +7,20 @@
.hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163",
},
.zmpl = .{
- .url = "https://github.com/jetzig-framework/zmpl/archive/369193322cc572197b57a1a2cebd85b317bf92c4.tar.gz",
- .hash = "12205982592aa38f37bba8d7410f62263827a49b350c1029a2eb79e8cc12b7247e9c",
+ .url = "https://github.com/jetzig-framework/zmpl/archive/7b7452bc7fdb0bd2f8a6a9c4e9312900b486aeba.tar.gz",
+ .hash = "1220ed127f38fa51df53a85b3cc2030a7555e34058db7fd374ebaef817abb43d35f7",
},
.jetkv = .{
.url = "https://github.com/jetzig-framework/jetkv/archive/2b1130a48979ea2871c8cf6ca89c38b1e7062839.tar.gz",
.hash = "12201d75d73aad5e1c996de4d5ae87a00e58479c8d469bc2eeb5fdeeac8857bc09af",
},
.jetquery = .{
- .url = "https://github.com/jetzig-framework/jetquery/archive/91ab3139ff7914d9bf40b7a08d7b120bac7a2c7d.tar.gz",
- .hash = "1220f1473c69e2b3fbdfc61860a451d1729f384e31097c32acdd3c54054e94387000",
+ .url = "https://github.com/jetzig-framework/jetquery/archive/a31db467c4af1c97bc7c806e1cc1a81a39162954.tar.gz",
+ .hash = "12203af0466ccc3a9ab57fcdf57c92c57989fa7e827d81bc98d0a5787d65402c73c3",
},
.jetcommon = .{
- .url = "https://github.com/jetzig-framework/jetcommon/archive/a248776ba56d6cc2b160d593ac3305756adcd26e.tar.gz",
- .hash = "1220a61e8650f84b28baf31fae5da31712aec4b711b3a41d11ed07c908bac96648d8",
+ .url = "https://github.com/jetzig-framework/jetcommon/archive/86f24cfdf2aaa0e8ada4539a6edef882708ced2b.tar.gz",
+ .hash = "12200439fc28aa7fa08f0e8fea100f6724c34c9dbfaaae4feec482c80e5ac08ea4f6",
},
.args = .{
.url = "https://github.com/ikskuh/zig-args/archive/0abdd6947a70e6d8cc83b66228cea614aa856206.tar.gz",
diff --git a/cli/build.zig.zon b/cli/build.zig.zon
index 46d3bc7..b0ace3a 100644
--- a/cli/build.zig.zon
+++ b/cli/build.zig.zon
@@ -9,8 +9,8 @@
.hash = "1220411a8c46d95bbf3b6e2059854bcb3c5159d428814099df5294232b9980517e9c",
},
.jetquery = .{
- .url = "https://github.com/jetzig-framework/jetquery/archive/91ab3139ff7914d9bf40b7a08d7b120bac7a2c7d.tar.gz",
- .hash = "1220f1473c69e2b3fbdfc61860a451d1729f384e31097c32acdd3c54054e94387000",
+ .url = "https://github.com/jetzig-framework/jetquery/archive/a31db467c4af1c97bc7c806e1cc1a81a39162954.tar.gz",
+ .hash = "12203af0466ccc3a9ab57fcdf57c92c57989fa7e827d81bc98d0a5787d65402c73c3",
},
},
.paths = .{
diff --git a/cli/commands/generate/view.zig b/cli/commands/generate/view.zig
index 9d1b483..c2c847e 100644
--- a/cli/commands/generate/view.zig
+++ b/cli/commands/generate/view.zig
@@ -116,9 +116,8 @@ fn parseAction(arg: []const u8) ?Action {
fn writeAction(allocator: std.mem.Allocator, writer: anytype, action: Action) !void {
const function = try std.fmt.allocPrint(
allocator,
- \\pub fn {s}({s}request: *jetzig.{s}, data: *jetzig.Data) !jetzig.View {{
- \\ _ = data;{s}
- \\ return request.render({s});
+ \\pub fn {s}({s}request: *jetzig.{s}) !jetzig.View {{
+ \\ {s}return request.render({s});
\\}}
\\
\\
@@ -132,7 +131,7 @@ fn writeAction(allocator: std.mem.Allocator, writer: anytype, action: Action) !v
if (action.static) "StaticRequest" else "Request",
switch (action.method) {
.index, .post, .new => "",
- .get, .put, .patch, .delete => "\n _ = id;",
+ .get, .put, .patch, .delete => "_ = id;\n ",
},
switch (action.method) {
.index, .get, .new => ".ok",
diff --git a/demo/src/app/views/params.zig b/demo/src/app/views/params.zig
new file mode 100644
index 0000000..1eff17b
--- /dev/null
+++ b/demo/src/app/views/params.zig
@@ -0,0 +1,80 @@
+const std = @import("std");
+const jetzig = @import("jetzig");
+
+pub fn post(request: *jetzig.Request) !jetzig.View {
+ const Params = struct {
+ // Required param - `expectParams` returns `null` if not present:
+ name: []const u8,
+ // Enum params are converted from string, `expectParams` returns `null` if no match:
+ favorite_animal: enum { cat, dog, raccoon },
+ // Optional params are not required. Numbers are coerced from strings. `expectParams`
+ // returns `null` if a type coercion fails.
+ age: ?u8 = 100,
+ };
+ const params = try request.expectParams(Params) orelse {
+ // Inspect information about the failed params with `request.paramsInfo()`:
+ // std.debug.print("{?}\n", .{try request.paramsInfo()});
+ return request.fail(.unprocessable_entity);
+ };
+
+ var root = try request.data(.object);
+ try root.put("info", params);
+
+ return request.render(.created);
+}
+
+test "post query params" {
+ var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
+ defer app.deinit();
+
+ const response1 = try app.request(.POST, "/params", .{
+ .params = .{
+ .name = "Bob",
+ .favorite_animal = "raccoon",
+ },
+ });
+ try response1.expectStatus(.created);
+
+ const response2 = try app.request(.POST, "/params", .{
+ .params = .{
+ .name = "Bob",
+ .favorite_animal = "platypus",
+ },
+ });
+ try response2.expectStatus(.unprocessable_entity);
+}
+
+test "post json" {
+ var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
+ defer app.deinit();
+
+ const response1 = try app.request(.POST, "/params", .{
+ .json = .{
+ .name = "Bob",
+ .favorite_animal = "raccoon",
+ },
+ });
+ try response1.expectJson("$.info.name", "Bob");
+ try response1.expectJson("$.info.favorite_animal", "raccoon");
+ try response1.expectJson("$.info.age", 100);
+
+ const response2 = try app.request(.POST, "/params", .{
+ .json = .{
+ .name = "Hercules",
+ .favorite_animal = "cat",
+ .age = 11,
+ },
+ });
+ try response2.expectJson("$.info.name", "Hercules");
+ try response2.expectJson("$.info.favorite_animal", "cat");
+ try response2.expectJson("$.info.age", 11);
+
+ const response3 = try app.request(.POST, "/params", .{
+ .json = .{
+ .name = "Hercules",
+ .favorite_animal = "platypus",
+ .age = 11,
+ },
+ });
+ try response3.expectStatus(.unprocessable_entity);
+}
diff --git a/demo/src/app/views/params/post.zmpl b/demo/src/app/views/params/post.zmpl
new file mode 100644
index 0000000..76457d0
--- /dev/null
+++ b/demo/src/app/views/params/post.zmpl
@@ -0,0 +1,3 @@
+
+ Content goes here
+
diff --git a/src/Routes.zig b/src/Routes.zig
index b46d833..eadedb2 100644
--- a/src/Routes.zig
+++ b/src/Routes.zig
@@ -24,6 +24,7 @@ const Function = struct {
path: []const u8,
source: []const u8,
params: std.ArrayList([]const u8),
+ legacy: bool = false,
static: bool = false,
/// The full name of a route. This **must** match the naming convention used by static route
@@ -179,7 +180,6 @@ pub fn generateRoutes(self: *Routes) ![]const u8 {
);
return try self.buffer.toOwnedSlice();
- // std.debug.print("routes.zig\n{s}\n", .{self.buffer.items});
}
pub fn relativePathFrom(
@@ -232,7 +232,10 @@ fn writeRoutes(self: *Routes, writer: anytype) !void {
const realpath = try dir.realpathAlloc(self.allocator, entry.path);
defer self.allocator.free(realpath);
- const view_routes = try self.generateRoutesForView(dir, try self.allocator.dupe(u8, realpath));
+ const view_routes = try self.generateRoutesForView(
+ dir,
+ try self.allocator.dupe(u8, realpath),
+ );
for (view_routes.static) |view_route| {
try self.static_routes.append(view_route);
@@ -272,7 +275,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
\\ .name = "{0s}",
\\ .action = .{1s},
\\ .view_name = "{2s}",
- \\ .view = jetzig.Route.ViewType{{ .{3s} = .{{ .{1s} = @import("{7s}").{1s} }} }},
+ \\ .view = jetzig.Route.View{{ .{3s} = @import("{7s}").{1s} }},
\\ .path = "{7s}",
\\ .static = {4s},
\\ .uri_path = "{5s}",
@@ -296,6 +299,36 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
&[_][]const u8{ view_name, "/", route.name },
);
+ const with_id = std.StaticStringMap(bool).initComptime(.{
+ .{ "index", false },
+ .{ "post", false },
+ .{ "new", false },
+ .{ "get", true },
+ .{ "edit", true },
+ .{ "put", true },
+ .{ "patch", true },
+ .{ "delete", true },
+ }).get(route.name).?;
+
+ const tag = if (!route.legacy and !route.static and with_id)
+ "with_id"
+ else if (!route.legacy and !route.static and !with_id)
+ "without_id"
+ else if (!route.legacy and route.static and with_id)
+ "static_with_id"
+ else if (!route.legacy and route.static and !with_id)
+ "static_without_id"
+ else if (route.legacy and !route.static and with_id)
+ "legacy_with_id"
+ else if (route.legacy and !route.static and !with_id)
+ "legacy_without_id"
+ else if (route.legacy and route.static and with_id)
+ "legacy_static_with_id"
+ else if (route.legacy and route.static and !with_id)
+ "legacy_static_without_id"
+ else
+ unreachable;
+
std.mem.replaceScalar(u8, module_path, '\\', '/');
try self.module_paths.append(try self.allocator.dupe(u8, module_path));
@@ -305,7 +338,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
full_name,
route.name,
view_name,
- if (route.static) "static" else "dynamic",
+ tag,
if (route.static) "true" else "false",
uri_path,
template,
@@ -325,7 +358,14 @@ const RouteSet = struct {
fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !RouteSet {
const stat = try dir.statFile(path);
- const source = try dir.readFileAllocOptions(self.allocator, path, @intCast(stat.size), null, @alignOf(u8), 0);
+ const source = try dir.readFileAllocOptions(
+ self.allocator,
+ path,
+ @intCast(stat.size),
+ null,
+ @alignOf(u8),
+ 0,
+ );
defer self.allocator.free(source);
self.ast = try std.zig.Ast.parse(self.allocator, source, .zig);
@@ -336,15 +376,25 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout
for (self.ast.nodes.items(.tag), 0..) |tag, index| {
switch (tag) {
- .fn_proto_multi => {
- const function = try self.parseFunction(index, path, source);
+ .fn_proto_multi, .fn_proto_one, .fn_proto_simple => |function_tag| {
+ var function = try self.parseFunction(function_tag, index, path, source);
if (function) |*capture| {
- for (capture.args) |arg| {
+ if (capture.args.len == 0) {
+ std.debug.print(
+ "Expected at least 1 argument for view function `{s}` in `{s}`",
+ .{ capture.name, path },
+ );
+ return error.JetzigMissingViewArgument;
+ }
+
+ for (capture.args, 0..) |arg, arg_index| {
if (std.mem.eql(u8, try arg.typeBasename(), "StaticRequest")) {
- @constCast(capture).static = true;
+ capture.static = true;
+ capture.legacy = arg_index + 1 < capture.args.len;
try static_routes.append(capture.*);
- }
- if (std.mem.eql(u8, try arg.typeBasename(), "Request")) {
+ } else if (std.mem.eql(u8, try arg.typeBasename(), "Request")) {
+ capture.static = false;
+ capture.legacy = arg_index + 1 < capture.args.len;
try dynamic_routes.append(capture.*);
}
}
@@ -529,14 +579,21 @@ fn isStaticParamsDecl(self: *Routes, decl: std.zig.Ast.full.VarDecl) bool {
fn parseFunction(
self: *Routes,
+ function_type: std.zig.Ast.Node.Tag,
index: usize,
path: []const u8,
source: []const u8,
) !?Function {
- const fn_proto = self.ast.fnProtoMulti(@as(u32, @intCast(index)));
+ var buf: [1]std.zig.Ast.Node.Index = undefined;
+
+ const fn_proto = switch (function_type) {
+ .fn_proto_multi => self.ast.fnProtoMulti(@as(u32, @intCast(index))),
+ .fn_proto_one => self.ast.fnProtoOne(&buf, @as(u32, @intCast(index))),
+ .fn_proto_simple => self.ast.fnProtoSimple(&buf, @as(u32, @intCast(index))),
+ else => unreachable,
+ };
if (fn_proto.name_token) |token| {
const function_name = try self.allocator.dupe(u8, self.ast.tokenSlice(token));
- var it = fn_proto.iterate(&self.ast);
var args = std.ArrayList(Arg).init(self.allocator);
defer args.deinit();
@@ -545,6 +602,7 @@ fn parseFunction(
return null;
}
+ var it = fn_proto.iterate(&self.ast);
while (it.next()) |arg| {
if (arg.name_token) |arg_token| {
const arg_name = self.ast.tokenSlice(arg_token);
diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig
index 22c57ca..474b1fd 100644
--- a/src/jetzig/App.zig
+++ b/src/jetzig/App.zig
@@ -166,6 +166,9 @@ pub fn route(
@memcpy(&view_name, module_name);
std.mem.replaceScalar(u8, &view_name, '.', '/');
+ const args_fields = std.meta.fields(std.meta.ArgsTuple(@TypeOf(viewFn)));
+ const legacy = args_fields.len > 0 and args_fields[args_fields.len - 1].type == *jetzig.Data;
+
self.custom_routes.append(.{
.id = "custom",
.name = member,
@@ -175,9 +178,18 @@ pub fn route(
.uri_path = path,
.layout = if (@hasDecl(module, "layout")) module.layout else null,
.view = comptime switch (viewType(path)) {
- .with_id => .{ .custom = .{ .with_id = viewFn } },
- .with_args => .{ .custom = .{ .with_args = viewFn } },
- .without_id => .{ .custom = .{ .without_id = viewFn } },
+ .with_id => if (legacy)
+ .{ .legacy_with_id = viewFn }
+ else
+ .{ .with_id = viewFn },
+ .without_id => if (legacy)
+ .{ .legacy_without_id = viewFn }
+ else
+ .{ .without_id = viewFn },
+ .with_args => if (legacy)
+ .{ .legacy_with_args = viewFn }
+ else
+ .{ .with_args = viewFn },
},
.template = self.allocator.dupe(u8, &template) catch @panic("OOM"),
.json_params = &.{},
diff --git a/src/jetzig/colors.zig b/src/jetzig/colors.zig
index cc776d3..d62379e 100644
--- a/src/jetzig/colors.zig
+++ b/src/jetzig/colors.zig
@@ -3,6 +3,7 @@ const std = @import("std");
const builtin = @import("builtin");
const types = @import("types.zig");
+const jetzig = @import("../jetzig.zig");
// Must be consistent with `std.io.tty.Color` for Windows compatibility.
pub const codes = .{
@@ -108,6 +109,8 @@ pub fn colorize(color: std.io.tty.Color, buf: []u8, input: []const u8, is_colori
}
fn wrap(comptime attribute: []const u8, comptime message: []const u8) []const u8 {
+ if (comptime jetzig.environment == .production) return message;
+
return codes.escape ++ attribute ++ message ++ codes.escape ++ codes.reset;
}
diff --git a/src/jetzig/http.zig b/src/jetzig/http.zig
index 03c21e2..3426606 100644
--- a/src/jetzig/http.zig
+++ b/src/jetzig/http.zig
@@ -16,6 +16,7 @@ pub const status_codes = @import("http/status_codes.zig");
pub const StatusCode = status_codes.StatusCode;
pub const middleware = @import("http/middleware.zig");
pub const mime = @import("http/mime.zig");
+pub const params = @import("http/params.zig");
pub const SimplifiedRequest = struct {
location: ?[]const u8,
diff --git a/src/jetzig/http/Query.zig b/src/jetzig/http/Query.zig
index b1c07c4..b7a8bcb 100644
--- a/src/jetzig/http/Query.zig
+++ b/src/jetzig/http/Query.zig
@@ -61,7 +61,7 @@ pub fn parse(self: *Query) !void {
else => return error.JetzigQueryParseError,
}
} else {
- var array = try self.data.createArray();
+ var array = try jetzig.zmpl.Data.createArray(self.data.allocator());
try array.append(self.dataValue(item.value));
try params.put(key, array);
}
@@ -72,7 +72,7 @@ pub fn parse(self: *Query) !void {
else => return error.JetzigQueryParseError,
}
} else {
- var object = try self.data.createObject();
+ var object = try jetzig.zmpl.Data.createObject(self.data.allocator());
try object.put(mapping.field, self.dataValue(item.value));
try params.put(mapping.key, object);
}
diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig
index 0b046b3..6fb97d3 100644
--- a/src/jetzig/http/Request.zig
+++ b/src/jetzig/http/Request.zig
@@ -23,6 +23,7 @@ status_code: jetzig.http.status_codes.StatusCode = .not_found,
response_data: *jetzig.data.Data,
query_params: ?*jetzig.http.Query = null,
query_body: ?*jetzig.http.Query = null,
+_params_info: ?jetzig.http.params.ParamsInfo = null,
multipart: ?jetzig.http.MultipartQuery = null,
parsed_multipart: ?*jetzig.data.Data = null,
_cookies: ?*jetzig.http.Cookies = null,
@@ -172,6 +173,15 @@ pub fn respond(self: *Request) !void {
self.httpz_response.body = self.response.content;
}
+/// Set the root value for response data.
+/// ```zig
+/// var root = request.data(.object)
+/// var root = request.data(.array)
+/// ```
+pub fn data(self: Request, comptime root: @TypeOf(.enum_literal)) !*jetzig.Data.Value {
+ return try self.response_data.root(root);
+}
+
/// Render a response. This function can only be called once per request (repeat calls will
/// trigger an error).
pub fn render(self: *Request, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View {
@@ -330,20 +340,46 @@ pub fn params(self: *Request) !*jetzig.data.Value {
.JSON => {
if (self.body.len == 0) return self.queryParams();
- var data = try self.allocator.create(jetzig.data.Data);
- data.* = jetzig.data.Data.init(self.allocator);
- data.fromJson(self.body) catch |err| {
+ var params_data = try self.allocator.create(jetzig.data.Data);
+ params_data.* = jetzig.data.Data.init(self.allocator);
+ params_data.fromJson(self.body) catch |err| {
switch (err) {
error.SyntaxError, error.UnexpectedEndOfInput => return error.JetzigBodyParseError,
else => return err,
}
};
- return data.value.?;
+ return params_data.value.?;
},
.HTML, .UNKNOWN => return self.parseQuery(),
}
}
+/// Expect params that match a struct defined by `T`. If any required params are missing (or
+/// cannot be matched to an enum param) then `null` is returned, otherwise a new `T` is returned
+/// with values populated from matched params.
+/// In both cases, `request.paramsInfo()` returns a hashmap keyed by all fields specified by `T`
+/// and with values of `ParamInfo`.
+/// A field in `T` can be an `enum`. In this case, if the param is a string (always true for
+/// query params) then it is coerced to the target enum type if possible.
+pub fn expectParams(self: *Request, T: type) !?T {
+ return try jetzig.http.params.expectParams(self, T);
+}
+
+/// Information about request parameters after a call to `request.expectParams`. Call
+/// `request.paramsInfo()` to initialize.
+///
+/// Use `get` to fetch information about a specific param.
+///
+/// ```zig
+/// const params = try request.expectParams(struct { foo: []const u8 });
+/// const params_info = try request.paramsInfo();
+/// const foo_info = params_info.get("foo");
+/// ```
+pub fn paramsInfo(self: Request) !?jetzig.http.params.ParamsInfo {
+ const params_info = self._params_info orelse return null;
+ return try params_info.init(self.allocator);
+}
+
/// Retrieve a file from a `multipart/form-data`-encoded request body, if present.
pub fn file(self: *Request, name: []const u8) !?jetzig.http.File {
_ = try self.parseQuery();
@@ -359,13 +395,13 @@ pub fn file(self: *Request, name: []const u8) !?jetzig.http.File {
pub fn queryParams(self: *Request) !*jetzig.data.Value {
if (self.query_params) |parsed| return parsed.data.value.?;
- const data = try self.allocator.create(jetzig.data.Data);
- data.* = jetzig.data.Data.init(self.allocator);
+ const params_data = try self.allocator.create(jetzig.data.Data);
+ params_data.* = jetzig.data.Data.init(self.allocator);
self.query_params = try self.allocator.create(jetzig.http.Query);
self.query_params.?.* = jetzig.http.Query.init(
self.allocator,
self.path.query orelse "",
- data,
+ params_data,
);
try self.query_params.?.parse();
return self.query_params.?.data.value.?;
@@ -390,13 +426,13 @@ fn parseQuery(self: *Request) !*jetzig.data.Value {
return self.parsed_multipart.?.value.?;
}
- const data = try self.allocator.create(jetzig.data.Data);
- data.* = jetzig.data.Data.init(self.allocator);
+ const params_data = try self.allocator.create(jetzig.data.Data);
+ params_data.* = jetzig.data.Data.init(self.allocator);
self.query_body = try self.allocator.create(jetzig.http.Query);
self.query_body.?.* = jetzig.http.Query.init(
self.allocator,
self.body,
- data,
+ params_data,
);
try self.query_body.?.parse();
@@ -692,12 +728,17 @@ pub fn match(self: *Request, route: jetzig.views.Route) !bool {
};
}
-fn isMatch(self: *Request, match_type: enum { exact, resource_id }, route: jetzig.views.Route) bool {
+fn isMatch(
+ self: *Request,
+ match_type: enum { exact, resource_id },
+ route: jetzig.views.Route,
+) bool {
const path = switch (match_type) {
.exact => self.path.base_path,
.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;
return std.mem.eql(u8, path, route.uri_path);
diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig
index 991805a..ba84f78 100644
--- a/src/jetzig/http/Server.zig
+++ b/src/jetzig/http/Server.zig
@@ -567,7 +567,9 @@ fn matchMiddlewareRoute(request: *const jetzig.http.Request) ?jetzig.middleware.
fn matchRoute(self: *Server, request: *jetzig.http.Request, static: bool) !?jetzig.views.Route {
for (self.routes) |route| {
// .index routes always take precedence.
- if (route.static == static and route.action == .index and try request.match(route.*)) return route.*;
+ if (route.static == static and route.action == .index and try request.match(route.*)) {
+ return route.*;
+ }
}
for (self.routes) |route| {
diff --git a/src/jetzig/http/params.zig b/src/jetzig/http/params.zig
new file mode 100644
index 0000000..53c6c16
--- /dev/null
+++ b/src/jetzig/http/params.zig
@@ -0,0 +1,161 @@
+const std = @import("std");
+
+const jetzig = @import("../../jetzig.zig");
+
+/// See `Request.expectParams`.
+pub fn expectParams(request: *jetzig.http.Request, T: type) !?T {
+ const actual_params = try request.params();
+
+ var t: T = undefined;
+
+ const fields = std.meta.fields(T);
+ var statuses: [fields.len]ParamInfo = undefined;
+ var failed = false;
+
+ inline for (fields, 0..) |field, index| {
+ if (actual_params.get(field.name)) |value| {
+ switch (@typeInfo(field.type)) {
+ .optional => |info| if (value.coerce(info.child)) |coerced| {
+ @field(t, field.name) = coerced;
+ statuses[index] = .{ .present = value.* };
+ } else |err| {
+ failed = true;
+ statuses[index] = .{ .failed = .{ .err = err, .value = value.* } };
+ },
+ // coerce value to target type, null on coerce error (e.g. numeric expected)
+ else => {
+ if (value.coerce(field.type)) |coerced| {
+ @field(t, field.name) = coerced;
+ statuses[index] = .{ .present = value.* };
+ } else |err| {
+ failed = true;
+ statuses[index] = .{ .failed = .{ .err = err, .value = value.* } };
+ }
+ },
+ }
+ } else if (@typeInfo(field.type) == .optional) {
+ // if no matching param found and params struct provides a default value, use it,
+ // otherwise set value to null
+ @field(t, field.name) = if (field.default_value) |default_value|
+ @as(*field.type, @ptrCast(@alignCast(@constCast(default_value)))).*
+ else
+ null;
+ statuses[index] = .blank;
+ // We don't set `failed = true` here because optional values are not required.
+ } else {
+ statuses[index] = .blank;
+ failed = true;
+ }
+ }
+
+ request._params_info = .{
+ .fields = try std.BoundedArray([]const u8, 1024).init(@intCast(fields.len)),
+ .params = try std.BoundedArray(ParamInfo, 1024).init(@intCast(fields.len)),
+ .required = try std.BoundedArray(bool, 1024).init(@intCast(fields.len)),
+ };
+ inline for (fields, 0..) |field, index| {
+ request._params_info.?.fields.set(index, field.name);
+ request._params_info.?.params.set(index, statuses[index]);
+ request._params_info.?.required.set(index, @typeInfo(field.type) != .optional);
+ }
+
+ if (failed) {
+ return null;
+ }
+
+ return t;
+}
+
+/// See `Request.paramsInfo`.
+pub const ParamsInfo = struct {
+ params: std.BoundedArray(ParamInfo, 1024),
+ fields: std.BoundedArray([]const u8, 1024),
+ required: std.BoundedArray(bool, 1024),
+ state: enum { initial, ready } = .initial,
+ hashmap: std.StringHashMap(ParamInfo) = undefined,
+
+ pub fn init(self: ParamsInfo, allocator: std.mem.Allocator) !ParamsInfo {
+ var hashmap = std.StringHashMap(ParamInfo).init(allocator);
+ try hashmap.ensureTotalCapacity(@intCast(self.params.len));
+ for (self.fields.constSlice(), self.params.constSlice()) |field, param_info| {
+ hashmap.putAssumeCapacity(field, param_info);
+ }
+ return .{
+ .params = self.params,
+ .fields = self.fields,
+ .required = self.required,
+ .hashmap = hashmap,
+ .state = .ready,
+ };
+ }
+
+ /// Get a information about a param. Provides param status (present/blank/failed) and
+ /// original values. See `ParamInfo`.
+ pub fn get(self: ParamsInfo, key: []const u8) ?ParamInfo {
+ std.debug.assert(self.state == .ready);
+ return self.hashmap.get(key);
+ }
+
+ /// Detect if any required params are blank or if any errors occurred when coercing params to
+ /// their target type.
+ pub fn isValid(self: ParamsInfo) bool {
+ for (self.params.constSlice(), self.required.constSlice()) |param, required| {
+ if (required and param == .blank) return false;
+ if (param == .failed) return false;
+ }
+ return true;
+ }
+
+ pub fn format(self: ParamsInfo, _: anytype, _: anytype, writer: anytype) !void {
+ std.debug.assert(self.state == .ready);
+ var it = self.hashmap.iterator();
+ try writer.print("{s}{{ ", .{
+ if (self.isValid())
+ jetzig.colors.green(@typeName(@TypeOf(self)))
+ else
+ jetzig.colors.red(@typeName(@TypeOf(self))),
+ });
+ while (it.next()) |entry| {
+ try writer.print("[" ++ jetzig.colors.blue("{s}") ++ ":{}] ", .{ entry.key_ptr.*, entry.value_ptr.* });
+ }
+ try writer.writeByte('}');
+ }
+};
+
+/// Status of a param as defined by the last call to `expectParams`.
+pub const ParamInfo = union(enum) {
+ /// The field was matched and coerced correctly. `value` is the original param value.
+ present: jetzig.Data.Value,
+ /// The field was not present (regardless of whether the field was optional)
+ blank: void,
+ /// The field was present but could not be coerced to the required type
+ failed: ParamError,
+
+ /// `err` is the error triggered by the type coercion attempt, `value` is the original param
+ /// value.
+ pub const ParamError = struct {
+ err: anyerror,
+ value: jetzig.Data.Value,
+
+ pub fn format(self: ParamError, _: anytype, _: anytype, writer: anytype) !void {
+ try writer.print(
+ jetzig.colors.red("{s}") ++ ":\"" ++ jetzig.colors.yellow("{}") ++ "\"",
+ .{ @errorName(self.err), self.value },
+ );
+ }
+ };
+
+ pub fn format(self: ParamInfo, _: anytype, _: anytype, writer: anytype) !void {
+ switch (self) {
+ .present => |present| try writer.print(
+ jetzig.colors.green("present") ++ ":\"" ++ jetzig.colors.cyan("{}") ++ "\"",
+ .{present},
+ ),
+ .blank => try writer.writeAll(jetzig.colors.yellow("blank")),
+ .failed => |failed| try writer.print(
+ jetzig.colors.red("failed") ++ ":" ++ jetzig.colors.cyan("{}") ++ "",
+ .{failed},
+ ),
+ }
+ }
+};
diff --git a/src/jetzig/mail/Job.zig b/src/jetzig/mail/Job.zig
index f9580e6..68915c6 100644
--- a/src/jetzig/mail/Job.zig
+++ b/src/jetzig/mail/Job.zig
@@ -129,7 +129,10 @@ fn defaultHtml(
params: *jetzig.data.Value,
) !?[]const u8 {
var data = jetzig.data.Data.init(allocator);
- data.value = if (params.get("params")) |capture| capture else try data.createObject();
+ data.value = if (params.get("params")) |capture|
+ capture
+ else
+ try jetzig.zmpl.Data.createObject(data.allocator());
try data.addConst("jetzig_view", data.string(""));
try data.addConst("jetzig_action", data.string(""));
return if (jetzig.zmpl.findPrefixed("mailers", mailer.html_template)) |template|
@@ -144,7 +147,10 @@ fn defaultText(
params: *jetzig.data.Value,
) !?[]const u8 {
var data = jetzig.data.Data.init(allocator);
- data.value = if (params.get("params")) |capture| capture else try data.createObject();
+ data.value = if (params.get("params")) |capture|
+ capture
+ else
+ try jetzig.zmpl.Data.createObject(data.allocator());
try data.addConst("jetzig_view", data.string(""));
try data.addConst("jetzig_action", data.string(""));
return if (jetzig.zmpl.findPrefixed("mailers", mailer.text_template)) |template|
diff --git a/src/jetzig/testing.zig b/src/jetzig/testing.zig
index e0b6908..b52957a 100644
--- a/src/jetzig/testing.zig
+++ b/src/jetzig/testing.zig
@@ -151,7 +151,7 @@ pub fn expectJson(expected_path: []const u8, expected_value: anytype, response:
const json_banner = "\n{s}";
- if (data.ref(std.mem.trimLeft(u8, expected_path, &.{'.'}))) |value| {
+ if (data.ref(expected_path)) |value| {
switch (value.*) {
.string => |string| switch (@typeInfo(@TypeOf(expected_value))) {
.pointer, .array => {
diff --git a/src/jetzig/testing/App.zig b/src/jetzig/testing/App.zig
index 9df1e04..9b99e42 100644
--- a/src/jetzig/testing/App.zig
+++ b/src/jetzig/testing/App.zig
@@ -345,7 +345,16 @@ fn buildOptions(allocator: std.mem.Allocator, app: *const App, args: anytype) !R
if (std.mem.eql(u8, field.name, "body")) continue;
}
- @compileError("Unrecognized request option: " ++ field.name);
+ @compileError(std.fmt.comptimePrint(
+ "Unrecognized request option `{s}`. Expected: {{ {s}, {s}, {s}, {s} }}",
+ .{
+ jetzig.colors.yellow(field.name),
+ jetzig.colors.cyan("headers"),
+ jetzig.colors.cyan("json"),
+ jetzig.colors.cyan("params"),
+ jetzig.colors.cyan("body"),
+ },
+ ));
}
return .{
diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig
index 2ed4104..50a89a7 100644
--- a/src/jetzig/views/Route.zig
+++ b/src/jetzig/views/Route.zig
@@ -1,19 +1,33 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
+const view_types = @import("view_types.zig");
const Route = @This();
pub const Action = enum { index, get, new, post, put, patch, delete, custom };
+
+pub const View = union(enum) {
+ with_id: view_types.ViewWithId,
+ without_id: view_types.ViewWithoutId,
+ with_args: view_types.ViewWithArgs,
+
+ static_with_id: view_types.StaticViewWithId,
+ static_without_id: view_types.StaticViewWithoutId,
+ static_with_args: view_types.StaticViewWithArgs,
+
+ legacy_with_id: view_types.LegacyViewWithId,
+ legacy_without_id: view_types.LegacyViewWithoutId,
+ legacy_with_args: view_types.LegacyViewWithArgs,
+
+ legacy_static_with_id: view_types.LegacyStaticViewWithId,
+ legacy_static_without_id: view_types.LegacyStaticViewWithoutId,
+ legacy_static_with_args: view_types.LegacyStaticViewWithArgs,
+};
+
pub const RenderFn = *const fn (Route, *jetzig.http.Request) anyerror!jetzig.views.View;
pub const RenderStaticFn = *const fn (Route, *jetzig.http.StaticRequest) anyerror!jetzig.views.View;
-pub const ViewWithoutId = *const fn (*jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
-pub const ViewWithId = *const fn (id: []const u8, *jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
-const StaticViewWithoutId = *const fn (*jetzig.http.StaticRequest, *jetzig.data.Data) anyerror!jetzig.views.View;
-pub const ViewWithArgs = *const fn ([]const []const u8, *jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
-const StaticViewWithId = *const fn (id: []const u8, *jetzig.http.StaticRequest, *jetzig.data.Data) anyerror!jetzig.views.View;
-
pub const Formats = struct {
index: ?[]const ResponseFormat = null,
get: ?[]const ResponseFormat = null,
@@ -26,47 +40,13 @@ pub const Formats = struct {
};
const ResponseFormat = enum { html, json };
-pub const DynamicViewType = union(Action) {
- index: ViewWithoutId,
- get: ViewWithId,
- new: ViewWithoutId,
- post: ViewWithoutId,
- put: ViewWithId,
- patch: ViewWithId,
- delete: ViewWithId,
- custom: CustomViewType,
-};
-
-pub const StaticViewType = union(Action) {
- index: StaticViewWithoutId,
- get: StaticViewWithId,
- new: StaticViewWithoutId,
- post: StaticViewWithoutId,
- put: StaticViewWithId,
- patch: StaticViewWithId,
- delete: StaticViewWithId,
- custom: void,
-};
-
-pub const CustomViewType = union(enum) {
- with_id: ViewWithId,
- without_id: ViewWithoutId,
- with_args: ViewWithArgs,
-};
-
-pub const ViewType = union(enum) {
- static: StaticViewType,
- dynamic: DynamicViewType,
- custom: CustomViewType,
-};
-
name: []const u8,
action: Action,
method: jetzig.http.Request.Method = undefined, // Used by custom routes only
view_name: []const u8,
uri_path: []const u8,
path: ?[]const u8 = null,
-view: ViewType,
+view: View,
render: RenderFn = renderFn,
renderStatic: RenderStaticFn = renderStaticFn,
static: bool = false,
@@ -97,6 +77,14 @@ pub fn deinitParams(self: *const Route) void {
self.params.deinit();
}
+pub fn format(self: Route, _: []const u8, _: anytype, writer: anytype) !void {
+ try writer.print(
+ \\Route{{ .name = "{s}", .action = .{s}, .view_name = "{s}", .static = {} }}
+ ,
+ .{ self.name, @tagName(self.action), self.view_name, self.static },
+ );
+}
+
/// Match a **custom** route to a request - not used by auto-generated route matching.
pub fn match(self: Route, request: *const jetzig.http.Request) bool {
if (self.method != request.method) return false;
@@ -140,46 +128,40 @@ pub fn validateFormat(self: Route, request: *const jetzig.http.Request) bool {
}
fn renderFn(self: Route, request: *jetzig.http.Request) anyerror!jetzig.views.View {
- switch (self.view) {
- .dynamic => {},
- .custom => |view_type| switch (view_type) {
- .with_id => |view| return try view(request.path.resourceId(self), request, request.response_data),
- .without_id => |view| return try view(request, request.response_data),
- .with_args => |view| return try view(
- try request.path.resourceArgs(self, request.allocator),
- request,
- request.response_data,
- ),
- },
- // We only end up here if a static route is defined but its output is not found in the
- // file system (e.g. if it was manually deleted after build). This should be avoidable by
- // including the content as an artifact in the compiled executable (TODO):
- .static => return error.JetzigMissingStaticContent,
- }
-
- switch (self.view.dynamic) {
- .index => |view| return try view(request, request.response_data),
- .get => |view| return try view(request.path.resource_id, request, request.response_data),
- .new => |view| return try view(request, request.response_data),
- .post => |view| return try view(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),
- .custom => unreachable,
- }
+ return switch (self.view) {
+ .with_id => |func| try func(request.path.resource_id, request),
+ .without_id => |func| try func(request),
+ .with_args => |func| try func(
+ try request.path.resourceArgs(self, request.allocator),
+ request,
+ ),
+ .legacy_with_id => |func| try func(
+ request.path.resource_id,
+ request,
+ request.response_data,
+ ),
+ .legacy_without_id => |func| try func(request, request.response_data),
+ .legacy_with_args => |func| try func(
+ try request.path.resourceArgs(self, request.allocator),
+ request,
+ request.response_data,
+ ),
+ else => unreachable, // renderStaticFn is called for static routes, we can never get here.
+ };
}
fn renderStaticFn(self: Route, request: *jetzig.http.StaticRequest) anyerror!jetzig.views.View {
request.response_data.* = jetzig.data.Data.init(request.allocator);
- switch (self.view.static) {
- .index => |view| return try view(request, request.response_data),
- .get => |view| return try view(try request.resourceId(), request, request.response_data),
- .new => |view| return try view(request, request.response_data),
- .post => |view| return try view(request, request.response_data),
- .patch => |view| return try view(try request.resourceId(), request, request.response_data),
- .put => |view| return try view(try request.resourceId(), request, request.response_data),
- .delete => |view| return try view(try request.resourceId(), request, request.response_data),
- .custom => unreachable,
- }
+ return switch (self.view) {
+ .static_without_id => |func| try func(request),
+ .legacy_static_without_id => |func| try func(request, request.response_data),
+ .static_with_id => |func| try func(try request.resourceId(), request),
+ .legacy_static_with_id => |func| try func(
+ try request.resourceId(),
+ request,
+ request.response_data,
+ ),
+ else => unreachable,
+ };
}
diff --git a/src/jetzig/views/view_types.zig b/src/jetzig/views/view_types.zig
new file mode 100644
index 0000000..b23ed69
--- /dev/null
+++ b/src/jetzig/views/view_types.zig
@@ -0,0 +1,69 @@
+const jetzig = @import("../../jetzig.zig");
+
+pub const ViewWithoutId = *const fn (
+ *jetzig.http.Request,
+) anyerror!jetzig.views.View;
+
+pub const ViewWithId = *const fn (
+ id: []const u8,
+ *jetzig.http.Request,
+) anyerror!jetzig.views.View;
+
+pub const ViewWithArgs = *const fn (
+ []const []const u8,
+ *jetzig.http.Request,
+) anyerror!jetzig.views.View;
+
+pub const StaticViewWithoutId = *const fn (
+ *jetzig.http.StaticRequest,
+) anyerror!jetzig.views.View;
+
+pub const StaticViewWithId = *const fn (
+ id: []const u8,
+ *jetzig.http.StaticRequest,
+) anyerror!jetzig.views.View;
+
+pub const StaticViewWithArgs = *const fn (
+ []const []const u8,
+ *jetzig.http.StaticRequest,
+) anyerror!jetzig.views.View;
+
+// Legacy view types receive a `data` argument. This made sense when `data.string(...)` etc. were
+// needed to create a string, but now we use type inference/coercion when adding values to
+// response data.
+// `Array.append(.array)`, `Array.append(.object)`, `Object.put(key, .array)`, and
+// `Object.put(key, .object)` also remove the need to use `data.array()` and `data.object()`.
+// The only remaining use is `data.root(.object)` and `data.root(.array)` which we can move to
+// `request.data(.object)` and `request.data(.array)`.
+pub const LegacyViewWithoutId = *const fn (
+ *jetzig.http.Request,
+ *jetzig.data.Data,
+) anyerror!jetzig.views.View;
+
+pub const LegacyViewWithId = *const fn (
+ id: []const u8,
+ *jetzig.http.Request,
+ *jetzig.data.Data,
+) anyerror!jetzig.views.View;
+
+pub const LegacyStaticViewWithoutId = *const fn (
+ *jetzig.http.StaticRequest,
+ *jetzig.data.Data,
+) anyerror!jetzig.views.View;
+
+pub const LegacyViewWithArgs = *const fn (
+ []const []const u8,
+ *jetzig.http.Request,
+ *jetzig.data.Data,
+) anyerror!jetzig.views.View;
+
+pub const LegacyStaticViewWithId = *const fn (
+ id: []const u8,
+ *jetzig.http.StaticRequest,
+ *jetzig.data.Data,
+) anyerror!jetzig.views.View;
+
+pub const LegacyStaticViewWithArgs = *const fn (
+ []const []const u8,
+ *jetzig.http.StaticRequest,
+) anyerror!jetzig.views.View;
diff --git a/src/tests.zig b/src/tests.zig
index 544934c..2746893 100644
--- a/src/tests.zig
+++ b/src/tests.zig
@@ -1,4 +1,9 @@
+const std = @import("std");
+const jetzig = @import("jetzig.zig");
+
test {
+ std.debug.assert(jetzig.jetquery.jetcommon == jetzig.zmpl.jetcommon);
+ std.debug.assert(jetzig.zmpl.jetcommon == jetzig.jetcommon);
_ = @import("jetzig/http/Query.zig");
_ = @import("jetzig/http/Headers.zig");
_ = @import("jetzig/http/Cookies.zig");