diff --git a/build.zig.zon b/build.zig.zon index cd300e7..c3617ae 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,8 +7,8 @@ .hash = "1220d0e8734628fd910a73146e804d10a3269e3e7d065de6bb0e3e88d5ba234eb163", }, .zmpl = .{ - .url = "https://github.com/jetzig-framework/zmpl/archive/7f2817df78404b8a46c637c212ec1a27a66306fa.tar.gz", - .hash = "12203a2ef05a4c3a76c1436e96c0a0aa5fc8e8406d56e50b1e9c94c394225c113b0e", + .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", diff --git a/cli/commands/generate/view.zig b/cli/commands/generate/view.zig index 9d1b483..edcbc97 100644 --- a/cli/commands/generate/view.zig +++ b/cli/commands/generate/view.zig @@ -116,8 +116,7 @@ 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} + \\pub fn {s}({s}request: *jetzig.{s}) !jetzig.View {{ \\ return request.render({s}); \\}} \\ diff --git a/demo/src/app/views/params.zig b/demo/src/app/views/params.zig index 16a47b0..1eff17b 100644 --- a/demo/src/app/views/params.zig +++ b/demo/src/app/views/params.zig @@ -2,13 +2,79 @@ 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" { +test "post query params" { var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); defer app.deinit(); - const response = try app.request(.POST, "/params", .{}); - try response.expectStatus(.created); + 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/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/Request.zig b/src/jetzig/http/Request.zig index a7fe119..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,24 +340,45 @@ 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(), } } -// TODO -// pub fn expectParams(self: Request, T: type) ?T { -// -// } +/// 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 { @@ -364,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.?; @@ -395,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(); 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/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/view_types.zig b/src/jetzig/views/view_types.zig index 5eea082..b23ed69 100644 --- a/src/jetzig/views/view_types.zig +++ b/src/jetzig/views/view_types.zig @@ -34,7 +34,7 @@ pub const StaticViewWithArgs = *const fn ( // `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.responseData(.object)` and `request.responseData(.array)`. +// `request.data(.object)` and `request.data(.array)`. pub const LegacyViewWithoutId = *const fn ( *jetzig.http.Request, *jetzig.data.Data,