diff --git a/README.md b/README.md index 2cd1b6a..2c3bbe2 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ If you are interested in _Jetzig_ you will probably find these tools interesting * [Zap](https://github.com/zigzap/zap) * [http.zig](https://github.com/karlseguin/http.zig) * [zig-router](https://github.com/Cloudef/zig-router) +* [zig-webui](https://github.com/webui-dev/zig-webui/) ## Checklist diff --git a/build.zig b/build.zig index fb3650c..9915cb0 100644 --- a/build.zig +++ b/build.zig @@ -39,7 +39,8 @@ pub fn build(b: *std.Build) !void { jetzig_module.addImport("zmpl", zmpl_dep.module("zmpl")); // This is the way to make it look nice in the zig build script - // If we would do it the other way around, we would have to do b.dependency("jetzig",.{}).builder.dependency("zmpl",.{}).module("zmpl"); + // If we would do it the other way around, we would have to do + // b.dependency("jetzig",.{}).builder.dependency("zmpl",.{}).module("zmpl"); b.modules.put("zmpl", zmpl_dep.module("zmpl")) catch @panic("Out of memory"); const main_tests = b.addTest(.{ @@ -48,6 +49,15 @@ pub fn build(b: *std.Build) !void { .optimize = optimize, }); + const docs_step = b.step("docs", "Generate documentation"); + const docs_install = b.addInstallDirectory(.{ + .source_dir = lib.getEmittedDocs(), + .install_dir = .prefix, + .install_subdir = "docs", + }); + + docs_step.dependOn(&docs_install.step); + main_tests.root_module.addImport("zmpl", zmpl_dep.module("zmpl")); const run_main_tests = b.addRunArtifact(main_tests); diff --git a/build.zig.zon b/build.zig.zon index dd0a464..403ae25 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,8 +3,8 @@ .version = "0.0.0", .dependencies = .{ .zmpl = .{ - .url = "https://github.com/jetzig-framework/zmpl/archive/621fcc531cb469788b9e887b58c7e76529a81d50.tar.gz", - .hash = "12200896800328d9e3335b4e3244b84b7664fe2ab9ff2930781be90b9ae658855846", + .url = "https://github.com/jetzig-framework/zmpl/archive/41ac97a026e124f93d316eb838fa015a5a52bb15.tar.gz", + .hash = "122053b4c76247572201afbbec8fbde8c014c25984a3ca780ed4f6d514cc939038df", }, }, diff --git a/demo/build.zig b/demo/build.zig index 3f79ec3..c0d560f 100644 --- a/demo/build.zig +++ b/demo/build.zig @@ -36,7 +36,7 @@ pub fn build(b: *std.Build) !void { b.installArtifact(exe); - var generate_routes = GenerateRoutes.init(b.allocator, "src/app/views"); + var generate_routes = try GenerateRoutes.init(b.allocator, "src/app/views"); try generate_routes.generateRoutes(); const write_files = b.addWriteFiles(); const routes_file = write_files.add("routes.zig", generate_routes.buffer.items); @@ -77,7 +77,7 @@ pub fn build(b: *std.Build) !void { run_step.dependOn(&run_cmd.step); const lib_unit_tests = b.addTest(.{ - .root_source_file = .{ .path = "src/root.zig" }, + .root_source_file = .{ .path = "src/main.zig" }, .target = target, .optimize = optimize, }); diff --git a/demo/build.zig.zon b/demo/build.zig.zon index e73a90a..2e43c0f 100644 --- a/demo/build.zig.zon +++ b/demo/build.zig.zon @@ -1,19 +1,7 @@ .{ .name = "sandbox-jetzig", - // This is a [Semantic Version](https://semver.org/). - // In a future version of Zig it will be used for package deduplication. .version = "0.0.0", - - // This field is optional. - // This is currently advisory only; Zig does not yet do anything - // with this value. - //.minimum_zig_version = "0.11.0", - - // This field is optional. - // Each dependency must either provide a `url` and `hash`, or a `path`. - // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. - // Once all dependencies are fetched, `zig build` no longer requires - // internet connectivity. + .minimum_zig_version = "0.12.0", .dependencies = .{ .jetzig = .{ .path = "../", diff --git a/demo/src/DemoMiddleware.zig b/demo/src/DemoMiddleware.zig new file mode 100644 index 0000000..ad8f60e --- /dev/null +++ b/demo/src/DemoMiddleware.zig @@ -0,0 +1,26 @@ +const std = @import("std"); +const jetzig = @import("jetzig"); + +my_data: u8, + +const Self = @This(); + +pub fn init(request: *jetzig.http.Request) !*Self { + var middleware = try request.allocator.create(Self); + middleware.my_data = 42; + return middleware; +} + +pub fn beforeRequest(self: *Self, request: *jetzig.http.Request) !void { + request.server.logger.debug("[middleware] Before request, custom data: {d}", .{self.my_data}); + self.my_data = 43; +} + +pub fn afterRequest(self: *Self, request: *jetzig.http.Request, result: *jetzig.caches.Result) !void { + request.server.logger.debug("[middleware] After request, custom data: {d}", .{self.my_data}); + request.server.logger.debug("[middleware] content-type: {s}", .{result.value.content_type}); +} + +pub fn deinit(self: *Self, request: *jetzig.http.Request) void { + request.allocator.destroy(self); +} diff --git a/demo/src/app/routes.zig b/demo/src/app/routes.zig deleted file mode 100644 index d2e49c2..0000000 --- a/demo/src/app/routes.zig +++ /dev/null @@ -1,21 +0,0 @@ -pub const routes = struct { - pub const static = .{ - .{ - .name = "root_index", - .action = "index", - .uri_path = "/", - .template = "root_index", - .function = @import("root.zig").index, - }, - }; - - pub const dynamic = .{ - .{ - .name = "quotes_get", - .action = "get", - .uri_path = "/quotes", - .template = "quotes_get", - .function = @import("quotes.zig").get, - }, - }; -}; \ No newline at end of file diff --git a/demo/src/app/views/quotes.zig b/demo/src/app/views/quotes.zig index f8f566e..d682c3c 100644 --- a/demo/src/app/views/quotes.zig +++ b/demo/src/app/views/quotes.zig @@ -1,11 +1,7 @@ const std = @import("std"); const jetzig = @import("jetzig"); -const Request = jetzig.http.Request; -const Data = jetzig.data.Data; -const View = jetzig.views.View; - -pub fn get(id: []const u8, request: *Request, data: *Data) !View { +pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { var body = try data.object(); const random_quote = try randomQuote(request.allocator); @@ -21,6 +17,13 @@ pub fn get(id: []const u8, request: *Request, data: *Data) !View { return request.render(.ok); } +pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { + _ = data; + const params = try request.params(); + std.debug.print("{}\n", .{params}); + return request.render(.ok); +} + const Quote = struct { quote: []const u8, author: []const u8, diff --git a/demo/src/app/views/quotes/post.zmpl b/demo/src/app/views/quotes/post.zmpl new file mode 100644 index 0000000..0c69b5d --- /dev/null +++ b/demo/src/app/views/quotes/post.zmpl @@ -0,0 +1 @@ +
{.param}
diff --git a/demo/src/app/views/root.zig b/demo/src/app/views/root.zig index 6c8e3ae..de8238d 100644 --- a/demo/src/app/views/root.zig +++ b/demo/src/app/views/root.zig @@ -1,6 +1,37 @@ +const std = @import("std"); const jetzig = @import("jetzig"); -pub fn index(request: *jetzig.http.StaticRequest, data: *jetzig.data.Data) !jetzig.views.View { - _ = data; +pub const static_params = .{ + .index = .{ + .{ .params = .{ .foo = "hi", .bar = "bye" } }, + .{ .params = .{ .foo = "hello", .bar = "goodbye" } }, + }, + .get = .{ + .{ .id = "1", .params = .{ .foo = "hi", .bar = "bye" } }, + .{ .id = "2", .params = .{ .foo = "hello", .bar = "goodbye" } }, + }, +}; + +pub fn index(request: *jetzig.StaticRequest, data: *jetzig.Data) !jetzig.View { + var root = try data.object(); + + const params = try request.params(); + + if (params.get("foo")) |foo| try root.put("foo", foo); + return request.render(.ok); } + +pub fn get(id: []const u8, request: *jetzig.StaticRequest, data: *jetzig.Data) !jetzig.View { + var root = try data.object(); + + const params = try request.params(); + + if (std.mem.eql(u8, id, "1")) { + try root.put("id", data.string("id is '1'")); + } + + if (params.get("foo")) |foo| try root.put("foo", foo); + + return request.render(.created); +} diff --git a/demo/src/app/views/root/index.zmpl b/demo/src/app/views/root/index.zmpl index 57ee5cc..fd884a1 100644 --- a/demo/src/app/views/root/index.zmpl +++ b/demo/src/app/views/root/index.zmpl @@ -14,6 +14,8 @@

{.message}

+
{.foo}
+
diff --git a/demo/src/app/views/routes.zig b/demo/src/app/views/routes.zig deleted file mode 100644 index d2e49c2..0000000 --- a/demo/src/app/views/routes.zig +++ /dev/null @@ -1,21 +0,0 @@ -pub const routes = struct { - pub const static = .{ - .{ - .name = "root_index", - .action = "index", - .uri_path = "/", - .template = "root_index", - .function = @import("root.zig").index, - }, - }; - - pub const dynamic = .{ - .{ - .name = "quotes_get", - .action = "get", - .uri_path = "/quotes", - .template = "quotes_get", - .function = @import("quotes.zig").get, - }, - }; -}; \ No newline at end of file diff --git a/demo/src/app/views/zmpl.manifest.zig b/demo/src/app/views/zmpl.manifest.zig index 68e3a4c..54152c0 100644 --- a/demo/src/app/views/zmpl.manifest.zig +++ b/demo/src/app/views/zmpl.manifest.zig @@ -3,5 +3,6 @@ // This file should _not_ be stored in version control. pub const templates = struct { pub const root_index = @import("root/.index.zmpl.compiled.zig"); + pub const quotes_post = @import("quotes/.post.zmpl.compiled.zig"); pub const quotes_get = @import("quotes/.get.zmpl.compiled.zig"); }; diff --git a/demo/src/main.zig b/demo/src/main.zig index fcbfd08..d5fcd2b 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -4,6 +4,10 @@ pub const jetzig = @import("jetzig"); pub const templates = @import("app/views/zmpl.manifest.zig").templates; pub const routes = @import("routes").routes; +pub const jetzig_options = struct { + pub const middleware: []const type = &.{@import("DemoMiddleware.zig")}; +}; + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer std.debug.assert(gpa.deinit() == .ok); diff --git a/src/GenerateRoutes.zig b/src/GenerateRoutes.zig index 7b897ae..6314dc2 100644 --- a/src/GenerateRoutes.zig +++ b/src/GenerateRoutes.zig @@ -1,18 +1,22 @@ const std = @import("std"); +const jetzig = @import("jetzig.zig"); +ast: std.zig.Ast = undefined, allocator: std.mem.Allocator, views_path: []const u8, buffer: std.ArrayList(u8), dynamic_routes: std.ArrayList(Function), static_routes: std.ArrayList(Function), +data: *jetzig.data.Data, const Self = @This(); const Function = struct { name: []const u8, - params: []Param, + args: []Arg, path: []const u8, source: []const u8, + params: std.ArrayList([]const u8), pub fn fullName(self: @This(), allocator: std.mem.Allocator) ![]const u8 { var path = try allocator.dupe(u8, self.path); @@ -46,7 +50,8 @@ const Function = struct { } }; -const Param = struct { +// An argument passed to a view function. +const Arg = struct { name: []const u8, type_name: []const u8, @@ -68,22 +73,28 @@ const Param = struct { } }; -pub fn init(allocator: std.mem.Allocator, views_path: []const u8) Self { +pub fn init(allocator: std.mem.Allocator, views_path: []const u8) !Self { + const data = try allocator.create(jetzig.data.Data); + data.* = jetzig.data.Data.init(allocator); + return .{ .allocator = allocator, .views_path = views_path, .buffer = std.ArrayList(u8).init(allocator), .static_routes = std.ArrayList(Function).init(allocator), .dynamic_routes = std.ArrayList(Function).init(allocator), + .data = data, }; } pub fn deinit(self: *Self) void { + self.ast.deinit(self.allocator); self.buffer.deinit(); self.static_routes.deinit(); self.dynamic_routes.deinit(); } +/// Generates the complete route set for the application pub fn generateRoutes(self: *Self) !void { const writer = self.buffer.writer(); @@ -104,29 +115,36 @@ pub fn generateRoutes(self: *Self) !void { if (std.mem.startsWith(u8, basename, ".")) continue; if (!std.mem.eql(u8, extension, ".zig")) continue; - const routes = try self.generateRoute(views_dir, entry.path); + const view_routes = try self.generateRoutesForView(views_dir, entry.path); - for (routes.static) |route| { - try self.static_routes.append(route); + for (view_routes.static) |view_route| { + try self.static_routes.append(view_route); } - for (routes.dynamic) |route| { - try self.dynamic_routes.append(route); + for (view_routes.dynamic) |view_route| { + try self.dynamic_routes.append(view_route); } } std.sort.pdq(Function, self.static_routes.items, {}, Function.lessThanFn); std.sort.pdq(Function, self.dynamic_routes.items, {}, Function.lessThanFn); - try writer.writeAll("pub const routes = struct {\n"); - try writer.writeAll(" pub const static = .{\n"); + try writer.writeAll( + \\pub const routes = struct { + \\ pub const static = .{ + \\ + ); for (self.static_routes.items) |static_route| { try self.writeRoute(writer, static_route); } - try writer.writeAll(" };\n\n"); - try writer.writeAll(" pub const dynamic = .{\n"); + try writer.writeAll( + \\ }; + \\ + \\ pub const dynamic = .{ + \\ + ); for (self.dynamic_routes.items) |dynamic_route| { try self.writeRoute(writer, dynamic_route); @@ -153,10 +171,26 @@ fn writeRoute(self: *Self, writer: std.ArrayList(u8).Writer, route: Function) !v \\ .uri_path = "{s}", \\ .template = "{s}", \\ .function = @import("{s}").{s}, + \\ .params = {s}, \\ }}, \\ ; + var params_buf = std.ArrayList(u8).init(self.allocator); + const params_writer = params_buf.writer(); + defer params_buf.deinit(); + if (route.params.items.len > 0) { + try params_writer.writeAll(".{\n"); + for (route.params.items) |item| { + try params_writer.writeAll(" "); + try params_writer.writeAll(item); + try params_writer.writeAll(",\n"); + } + try params_writer.writeAll(" }"); + } else { + try params_writer.writeAll(".{}"); + } + const output = try std.fmt.allocPrint(self.allocator, output_template, .{ full_name, route.name, @@ -164,6 +198,7 @@ fn writeRoute(self: *Self, writer: std.ArrayList(u8).Writer, route: Function) !v full_name, route.path, route.name, + params_buf.items, }); defer self.allocator.free(output); @@ -175,76 +210,246 @@ const RouteSet = struct { static: []Function, }; -fn generateRoute(self: *Self, views_dir: std.fs.Dir, path: []const u8) !RouteSet { - // REVIEW: Choose a sensible upper limit or allow user to take their own risks here ? +fn generateRoutesForView(self: *Self, views_dir: std.fs.Dir, path: []const u8) !RouteSet { const stat = try views_dir.statFile(path); const source = try views_dir.readFileAllocOptions(self.allocator, path, stat.size, null, @alignOf(u8), 0); defer self.allocator.free(source); - var ast = try std.zig.Ast.parse(self.allocator, source, .zig); - defer ast.deinit(self.allocator); + self.ast = try std.zig.Ast.parse(self.allocator, source, .zig); var static_routes = std.ArrayList(Function).init(self.allocator); var dynamic_routes = std.ArrayList(Function).init(self.allocator); + var static_params: ?*jetzig.data.Value = null; - for (ast.nodes.items(.tag), 0..) |tag, index| { - const function = try self.parseTag(ast, tag, index, path, source); - if (function) |capture| { - for (capture.params) |param| { - if (std.mem.eql(u8, try param.typeBasename(), "StaticRequest")) { - try static_routes.append(capture); + for (self.ast.nodes.items(.tag), 0..) |tag, index| { + switch (tag) { + .fn_proto_multi => { + const function = try self.parseFunction(index, path, source); + if (function) |capture| { + for (capture.args) |arg| { + if (std.mem.eql(u8, try arg.typeBasename(), "StaticRequest")) { + try static_routes.append(capture); + } + if (std.mem.eql(u8, try arg.typeBasename(), "Request")) { + try dynamic_routes.append(capture); + } + } } - if (std.mem.eql(u8, try param.typeBasename(), "Request")) { - try dynamic_routes.append(capture); + }, + .simple_var_decl => { + const decl = self.ast.simpleVarDecl(asNodeIndex(index)); + if (self.isStaticParamsDecl(decl)) { + const params = try self.data.object(); + try self.parseStaticParamsDecl(decl, params); + static_params = self.data.value; + } + }, + else => {}, + } + } + + for (static_routes.items) |*static_route| { + var encoded_params = std.ArrayList([]const u8).init(self.allocator); + defer encoded_params.deinit(); + + if (static_params) |capture| { + if (capture.get(static_route.name)) |params| { + for (params.array.array.items) |item| { // XXX: Use public interface for Data.Array here ? + var json_buf = std.ArrayList(u8).init(self.allocator); + defer json_buf.deinit(); + const json_writer = json_buf.writer(); + try item.toJson(json_writer); + var encoded_buf = std.ArrayList(u8).init(self.allocator); + defer encoded_buf.deinit(); + const writer = encoded_buf.writer(); + try std.json.encodeJsonString(json_buf.items, .{}, writer); + try static_route.params.append(try self.allocator.dupe(u8, encoded_buf.items)); } } } } - return .{ .dynamic = dynamic_routes.items, .static = static_routes.items }; + return .{ + .dynamic = dynamic_routes.items, + .static = static_routes.items, + }; } -fn parseTag( +// Parse the `pub const static_params` definition and into a `jetzig.data.Value`. +fn parseStaticParamsDecl(self: *Self, decl: std.zig.Ast.full.VarDecl, params: *jetzig.data.Value) !void { + const init_node = self.ast.nodes.items(.tag)[decl.ast.init_node]; + switch (init_node) { + .struct_init_dot_two, .struct_init_dot_two_comma => { + try self.parseStruct(decl.ast.init_node, params); + }, + else => return, + } +} +// Recursively parse a struct into a jetzig.data.Value so it can be serialized as JSON and stored +// in `routes.zig` - used for static param comparison at runtime. +fn parseStruct(self: *Self, node: std.zig.Ast.Node.Index, params: *jetzig.data.Value) anyerror!void { + var struct_buf: [2]std.zig.Ast.Node.Index = undefined; + const maybe_struct_init = self.ast.fullStructInit(&struct_buf, node); + + if (maybe_struct_init == null) { + std.debug.print("Expected struct node.\n", .{}); + return error.JetzigAstParserError; + } + + const struct_init = maybe_struct_init.?; + + for (struct_init.ast.fields) |field| try self.parseField(field, params); +} + +// Array of param sets for a route, e.g. `.{ .{ .foo = "bar" } } +fn parseArray(self: *Self, node: std.zig.Ast.Node.Index, params: *jetzig.data.Value) anyerror!void { + var array_buf: [2]std.zig.Ast.Node.Index = undefined; + const maybe_array = self.ast.fullArrayInit(&array_buf, node); + + if (maybe_array == null) { + std.debug.print("Expected array node.\n", .{}); + return error.JetzigAstParserError; + } + + const array = maybe_array.?; + + const main_token = self.ast.nodes.items(.main_token)[node]; + const field_name = self.ast.tokenSlice(main_token - 3); + + const params_array = try self.data.createArray(); + try params.put(field_name, params_array); + + for (array.ast.elements) |element| { + const elem = self.ast.nodes.items(.tag)[element]; + switch (elem) { + .struct_init_dot, .struct_init_dot_two, .struct_init_dot_two_comma => { + const route_params = try self.data.createObject(); + try params_array.append(route_params); + try self.parseStruct(element, route_params); + }, + .array_init_dot, .array_init_dot_two, .array_init_dot_comma, .array_init_dot_two_comma => { + const route_params = try self.data.createObject(); + try params_array.append(route_params); + try self.parseField(element, route_params); + }, + .string_literal => { + const string_token = self.ast.nodes.items(.main_token)[element]; + const string_value = self.ast.tokenSlice(string_token); + + // Strip quotes: `"foo"` -> `foo` + try params_array.append(self.data.string(string_value[1 .. string_value.len - 1])); + }, + .number_literal => { + const number_token = self.ast.nodes.items(.main_token)[element]; + const number_value = self.ast.tokenSlice(number_token); + try params_array.append(try parseNumber(number_value, self.data)); + }, + inline else => { + const tag = self.ast.nodes.items(.tag)[element]; + std.debug.print("Unexpected token: {}\n", .{tag}); + return error.JetzigStaticParamsParseError; + }, + } + } +} + +// Parse the value of a param field (recursively when field is a struct/array) +fn parseField(self: *Self, node: std.zig.Ast.Node.Index, params: *jetzig.data.Value) anyerror!void { + const tag = self.ast.nodes.items(.tag)[node]; + switch (tag) { + // Route params, e.g. `.index = .{ ... }` + .array_init_dot, .array_init_dot_two, .array_init_dot_comma, .array_init_dot_two_comma => { + try self.parseArray(node, params); + }, + .struct_init_dot, .struct_init_dot_two, .struct_init_dot_two_comma => { + const nested_params = try self.data.createObject(); + const main_token = self.ast.nodes.items(.main_token)[node]; + const field_name = self.ast.tokenSlice(main_token - 3); + try params.put(field_name, nested_params); + try self.parseStruct(node, nested_params); + }, + // Individual param in a params struct, e.g. `.foo = "bar"` + .string_literal => { + const main_token = self.ast.nodes.items(.main_token)[node]; + const field_name = self.ast.tokenSlice(main_token - 2); + const field_value = self.ast.tokenSlice(main_token); + + try params.put( + field_name, + // strip outer quotes + self.data.string(field_value[1 .. field_value.len - 1]), + ); + }, + .number_literal => { + const main_token = self.ast.nodes.items(.main_token)[node]; + const field_name = self.ast.tokenSlice(main_token - 2); + const field_value = self.ast.tokenSlice(main_token); + + try params.put(field_name, try parseNumber(field_value, self.data)); + }, + else => { + std.debug.print("Unexpected token: {}\n", .{tag}); + return error.JetzigStaticParamsParseError; + }, + } +} + +fn parseNumber(value: []const u8, data: *jetzig.data.Data) !*jetzig.data.Value { + if (std.mem.containsAtLeast(u8, value, 1, ".")) { + return data.float(try std.fmt.parseFloat(f64, value)); + } else { + return data.integer(try std.fmt.parseInt(i64, value, 10)); + } +} + +fn isStaticParamsDecl(self: *Self, decl: std.zig.Ast.full.VarDecl) bool { + if (decl.visib_token) |token_index| { + const visibility = self.ast.tokenSlice(token_index); + const mutability = self.ast.tokenSlice(decl.ast.mut_token); + const identifier = self.ast.tokenSlice(decl.ast.mut_token + 1); // FIXME + return (std.mem.eql(u8, visibility, "pub") and + std.mem.eql(u8, mutability, "const") and + std.mem.eql(u8, identifier, "static_params")); + } else { + return false; + } +} + +fn parseFunction( self: *Self, - ast: std.zig.Ast, - tag: std.zig.Ast.Node.Tag, index: usize, path: []const u8, source: []const u8, ) !?Function { - switch (tag) { - .fn_proto_multi => { - const fn_proto = ast.fnProtoMulti(@as(u32, @intCast(index))); - if (fn_proto.name_token) |token| { - const function_name = try self.allocator.dupe(u8, ast.tokenSlice(token)); - var it = fn_proto.iterate(&ast); - var params = std.ArrayList(Param).init(self.allocator); - defer params.deinit(); + const fn_proto = self.ast.fnProtoMulti(@as(u32, @intCast(index))); + 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(); - while (it.next()) |param| { - if (param.name_token) |param_token| { - const param_name = ast.tokenSlice(param_token); - const node = ast.nodes.get(param.type_expr); - const type_name = try self.parseTypeExpr(ast, node); - try params.append(.{ .name = param_name, .type_name = type_name }); - } - } - - return .{ - .name = function_name, - .path = try self.allocator.dupe(u8, path), - .params = try self.allocator.dupe(Param, params.items), - .source = try self.allocator.dupe(u8, source), - }; + while (it.next()) |arg| { + if (arg.name_token) |arg_token| { + const arg_name = self.ast.tokenSlice(arg_token); + const node = self.ast.nodes.get(arg.type_expr); + const type_name = try self.parseTypeExpr(node); + try args.append(.{ .name = arg_name, .type_name = type_name }); } - }, - else => {}, + } + + return .{ + .name = function_name, + .path = try self.allocator.dupe(u8, path), + .args = try self.allocator.dupe(Arg, args.items), + .source = try self.allocator.dupe(u8, source), + .params = std.ArrayList([]const u8).init(self.allocator), + }; } return null; } -fn parseTypeExpr(self: *Self, ast: std.zig.Ast, node: std.zig.Ast.Node) ![]const u8 { +fn parseTypeExpr(self: *Self, node: std.zig.Ast.Node) ![]const u8 { switch (node.tag) { // Currently all expected params are pointers, keeping this here in case that changes in future: .identifier => {}, @@ -252,11 +457,11 @@ fn parseTypeExpr(self: *Self, ast: std.zig.Ast, node: std.zig.Ast.Node) ![]const var buf = std.ArrayList([]const u8).init(self.allocator); defer buf.deinit(); - for (0..(ast.tokens.len - node.main_token)) |index| { - const token = ast.tokens.get(node.main_token + index); + for (0..(self.ast.tokens.len - node.main_token)) |index| { + const token = self.ast.tokens.get(node.main_token + index); switch (token.tag) { .asterisk, .period, .identifier => { - try buf.append(ast.tokenSlice(@as(u32, @intCast(node.main_token + index)))); + try buf.append(self.ast.tokenSlice(@as(u32, @intCast(node.main_token + index)))); }, else => return try std.mem.concat(self.allocator, u8, buf.items), } @@ -267,3 +472,7 @@ fn parseTypeExpr(self: *Self, ast: std.zig.Ast, node: std.zig.Ast.Node) ![]const return error.JetzigAstParserError; } + +fn asNodeIndex(index: usize) std.zig.Ast.Node.Index { + return @as(std.zig.Ast.Node.Index, @intCast(index)); +} diff --git a/src/compile_static_routes.zig b/src/compile_static_routes.zig index 207dc19..cf03fca 100644 --- a/src/compile_static_routes.zig +++ b/src/compile_static_routes.zig @@ -16,6 +16,8 @@ pub fn main() !void { } fn compileStaticRoutes(allocator: std.mem.Allocator) !void { + std.fs.cwd().deleteTree("static") catch {}; + inline for (routes.static) |static_route| { const static_view = jetzig.views.Route.ViewType{ .static = @unionInit( @@ -24,6 +26,12 @@ fn compileStaticRoutes(allocator: std.mem.Allocator) !void { static_route.function, ), }; + + comptime var static_params_len = 0; + inline for (static_route.params) |_| static_params_len += 1; + comptime var params: [static_params_len][]const u8 = undefined; + inline for (static_route.params, 0..) |json, index| params[index] = json; + const route = jetzig.views.Route{ .name = static_route.name, .action = @field(jetzig.views.Route.Action, static_route.action), @@ -31,40 +39,72 @@ fn compileStaticRoutes(allocator: std.mem.Allocator) !void { .static = true, .uri_path = static_route.uri_path, .template = static_route.template, + .json_params = ¶ms, }; - var request = try jetzig.http.StaticRequest.init(allocator); - defer request.deinit(); + if (static_params_len > 0) { + inline for (static_route.params, 0..) |json, index| { + var request = try jetzig.http.StaticRequest.init(allocator, json); + defer request.deinit(); + try writeContent(allocator, route, &request, index); + } + } - const view = try route.renderStatic(route, &request); - defer view.deinit(); - - var dir = try std.fs.cwd().makeOpenPath("static", .{}); - defer dir.close(); - - const json_path = try std.mem.concat( - allocator, - u8, - &[_][]const u8{ route.name, ".json" }, - ); - defer allocator.free(json_path); - const json_file = try dir.createFile(json_path, .{ .truncate = true }); - try json_file.writeAll(try view.data.toJson()); - defer json_file.close(); - std.debug.print("[jetzig] Compiled static route: {s}\n", .{json_path}); - - if (@hasDecl(templates, route.template)) { - const template = @field(templates, route.template); - const html_path = try std.mem.concat( - allocator, - u8, - &[_][]const u8{ route.name, ".html" }, - ); - defer allocator.free(html_path); - const html_file = try dir.createFile(html_path, .{ .truncate = true }); - try html_file.writeAll(try template.render(view.data)); - defer html_file.close(); - std.debug.print("[jetzig] Compiled static route: {s}\n", .{html_path}); + // Always provide a fallback for non-resource routes (i.e. `index`, `post`) if params + // do not match any of the configured param sets. + switch (route.action) { + .index, .post => { + var request = try jetzig.http.StaticRequest.init(allocator, "{}"); + defer request.deinit(); + try writeContent(allocator, route, &request, null); + }, + inline else => {}, } } } + +fn writeContent( + allocator: std.mem.Allocator, + comptime route: jetzig.views.Route, + request: *jetzig.http.StaticRequest, + index: ?usize, +) !void { + const index_suffix = if (index) |capture| + try std.fmt.allocPrint(allocator, "_{}", .{capture}) + else + try allocator.dupe(u8, ""); + defer allocator.free(index_suffix); + + const view = try route.renderStatic(route, request); + defer view.deinit(); + + var dir = try std.fs.cwd().makeOpenPath("static", .{}); + defer dir.close(); + + const json_path = try std.mem.concat( + allocator, + u8, + &[_][]const u8{ route.name, index_suffix, ".json" }, + ); + defer allocator.free(json_path); + + const json_file = try dir.createFile(json_path, .{ .truncate = true }); + try json_file.writeAll(try view.data.toJson()); + defer json_file.close(); + + std.debug.print("[jetzig] Compiled static route: {s}\n", .{json_path}); + + if (@hasDecl(templates, route.template)) { + const template = @field(templates, route.template); + const html_path = try std.mem.concat( + allocator, + u8, + &[_][]const u8{ route.name, index_suffix, ".html" }, + ); + defer allocator.free(html_path); + const html_file = try dir.createFile(html_path, .{ .truncate = true }); + try html_file.writeAll(try template.render(view.data)); + defer html_file.close(); + std.debug.print("[jetzig] Compiled static route: {s}\n", .{html_path}); + } +} diff --git a/src/init/src/app/views/index.zmpl b/src/init/src/app/views/index.zmpl deleted file mode 100644 index a8e4bfa..0000000 --- a/src/init/src/app/views/index.zmpl +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - -
-
- -
-

{.message}

-
- - - -
-
-
- -
Take a look at the src/app/ directory to see how this application works.
-
Visit jetzig.dev to get started.
-
- - diff --git a/src/jetzig.zig b/src/jetzig.zig index a2b8d77..1801eee 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -10,6 +10,12 @@ pub const views = @import("jetzig/views.zig"); pub const colors = @import("jetzig/colors.zig"); pub const App = @import("jetzig/App.zig"); +// Convenience for view function parameters. +pub const Request = http.Request; +pub const StaticRequest = http.StaticRequest; +pub const Data = data.Data; +pub const View = views.View; + pub const config = struct { pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 16); pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 16); @@ -97,6 +103,7 @@ pub fn route(comptime routes: anytype) []views.Route { .static = false, .uri_path = dynamic_route.uri_path, .template = dynamic_route.template, + .json_params = &.{}, }; index += 1; } @@ -110,6 +117,11 @@ pub fn route(comptime routes: anytype) []views.Route { ), }; + comptime var params_size = 0; + inline for (static_route.params) |_| params_size += 1; + comptime var static_params: [params_size][]const u8 = undefined; + inline for (static_route.params, 0..) |json, params_index| static_params[params_index] = json; + detected[index] = .{ .name = static_route.name, .action = @field(views.Route.Action, static_route.action), @@ -117,6 +129,7 @@ pub fn route(comptime routes: anytype) []views.Route { .static = true, .uri_path = static_route.uri_path, .template = static_route.template, + .json_params = &static_params, }; index += 1; } diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index 061c173..419660c 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -24,6 +24,15 @@ pub fn start(self: Self, routes: []jetzig.views.Route, templates: []jetzig.Templ templates, ); + for (routes) |*route| { + var mutable = @constCast(route); // FIXME + try mutable.initParams(self.allocator); + } + defer for (routes) |*route| { + var mutable = @constCast(route); // FIXME + mutable.deinitParams(); + }; + defer server.deinit(); defer self.allocator.free(self.root_path); defer self.allocator.free(self.host); diff --git a/src/jetzig/http.zig b/src/jetzig/http.zig index 1519888..98c2b63 100644 --- a/src/jetzig/http.zig +++ b/src/jetzig/http.zig @@ -7,4 +7,6 @@ pub const Response = @import("http/Response.zig"); pub const Session = @import("http/Session.zig"); pub const Cookies = @import("http/Cookies.zig"); pub const Headers = @import("http/Headers.zig"); +pub const Query = @import("http/Query.zig"); pub const status_codes = @import("http/status_codes.zig"); +pub const middleware = @import("http/middleware.zig"); diff --git a/src/jetzig/http/Query.zig b/src/jetzig/http/Query.zig new file mode 100644 index 0000000..5faaaff --- /dev/null +++ b/src/jetzig/http/Query.zig @@ -0,0 +1,157 @@ +const std = @import("std"); +const jetzig = @import("../../jetzig.zig"); + +const Self = @This(); + +allocator: std.mem.Allocator, +query_string: []const u8, +query_items: std.ArrayList(QueryItem), +data: *jetzig.data.Data, + +pub const QueryItem = struct { + key: []const u8, + value: []const u8, +}; + +pub fn init(allocator: std.mem.Allocator, query_string: []const u8, data: *jetzig.data.Data) Self { + return .{ + .allocator = allocator, + .query_string = query_string, + .query_items = std.ArrayList(QueryItem).init(allocator), + .data = data, + }; +} + +pub fn deinit(self: *Self) void { + self.query_items.deinit(); + self.data.deinit(); +} + +pub fn parse(self: *Self) !void { + var pairs_it = std.mem.splitScalar(u8, self.query_string, '&'); + + while (pairs_it.next()) |pair| { + var key_value_it = std.mem.splitScalar(u8, pair, '='); + var count: u2 = 0; + var key: []const u8 = undefined; + var value: []const u8 = undefined; + + while (key_value_it.next()) |key_or_value| { + switch (count) { + 0 => key = key_or_value, + 1 => value = key_or_value, + else => return error.JetzigQueryParseError, + } + count += 1; + } + try self.query_items.append(.{ .key = key, .value = value }); + } + + var params = try self.data.object(); + for (self.query_items.items) |item| { + // TODO: Allow nested array/mapping params (`foo[bar][baz]=abc`) + if (arrayParam(item.key)) |key| { + if (params.get(key)) |value| { + switch (value.*) { + .array => try value.array.append(self.data.string(item.value)), + else => return error.JetzigQueryParseError, + } + } else { + var array = try self.data.createArray(); + try array.append(self.data.string(item.value)); + try params.put(key, array); + } + } else if (mappingParam(item.key)) |mapping| { + if (params.get(mapping.key)) |value| { + switch (value.*) { + .object => try value.object.put(mapping.field, self.data.string(item.value)), + else => return error.JetzigQueryParseError, + } + } else { + var object = try self.data.createObject(); + try object.put(mapping.field, self.data.string(item.value)); + try params.put(mapping.key, object); + } + } else { + try params.put(item.key, self.data.string(item.value)); + } + } +} + +fn arrayParam(key: []const u8) ?[]const u8 { + if (key.len >= 3 and std.mem.eql(u8, key[key.len - 2 ..], "[]")) { + return key[0 .. key.len - 2]; + } else { + return null; + } +} + +fn mappingParam(input: []const u8) ?struct { key: []const u8, field: []const u8 } { + if (input.len < 4) return null; // Must be at least `a[b]` + + const open = std.mem.indexOfScalar(u8, input, '['); + const close = std.mem.lastIndexOfScalar(u8, input, ']'); + if (open == null or close == null) return null; + + const open_index = open.?; + const close_index = close.?; + if (close_index < open_index) return null; + + return .{ + .key = input[0..open_index], + .field = input[open_index + 1 .. close_index], + }; +} + +test "simple query string" { + const allocator = std.testing.allocator; + const query_string = "foo=bar&baz=qux"; + var data = jetzig.data.Data.init(allocator); + + var query = init(allocator, query_string, &data); + defer query.deinit(); + + try query.parse(); + try std.testing.expectEqualStrings((try data.get("foo")).string.value, "bar"); + try std.testing.expectEqualStrings((try data.get("baz")).string.value, "qux"); +} + +test "query string with array values" { + const allocator = std.testing.allocator; + const query_string = "foo[]=bar&foo[]=baz"; + var data = jetzig.data.Data.init(allocator); + + var query = init(allocator, query_string, &data); + defer query.deinit(); + + try query.parse(); + + const value = try data.get("foo"); + switch (value.*) { + .array => |array| { + try std.testing.expectEqualStrings(array.get(0).?.string.value, "bar"); + try std.testing.expectEqualStrings(array.get(1).?.string.value, "baz"); + }, + else => unreachable, + } +} + +test "query string with mapping values" { + const allocator = std.testing.allocator; + const query_string = "foo[bar]=baz&foo[qux]=quux"; + var data = jetzig.data.Data.init(allocator); + + var query = init(allocator, query_string, &data); + defer query.deinit(); + + try query.parse(); + + const value = try data.get("foo"); + switch (value.*) { + .object => |object| { + try std.testing.expectEqualStrings(object.get("bar").?.string.value, "baz"); + try std.testing.expectEqualStrings(object.get("qux").?.string.value, "quux"); + }, + else => unreachable, + } +} diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 76dc220..cda2934 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -18,29 +18,18 @@ server: *jetzig.http.Server, session: *jetzig.http.Session, status_code: jetzig.http.status_codes.StatusCode = undefined, response_data: *jetzig.data.Data, +query_data: *jetzig.data.Data, +query: *jetzig.http.Query, cookies: *jetzig.http.Cookies, +body: []const u8, pub fn init( allocator: std.mem.Allocator, server: *jetzig.http.Server, - response: ?*std.http.Server.Response, + response: *std.http.Server.Response, + body: []const u8, ) !Self { - if (response) |_| {} else { - return .{ - .allocator = allocator, - .path = "/", - .method = .GET, - .headers = jetzig.http.Headers.init(allocator, std.http.Headers{ .allocator = allocator }), - .server = server, - .segments = std.ArrayList([]const u8).init(allocator), - .cookies = try allocator.create(jetzig.http.Cookies), - .session = try allocator.create(jetzig.http.Session), - .response_data = try allocator.create(jetzig.data.Data), - }; - } - - const resp = response.?; - const method = switch (resp.request.method) { + const method = switch (response.request.method) { .DELETE => Method.DELETE, .GET => Method.GET, .PATCH => Method.PATCH, @@ -53,14 +42,14 @@ pub fn init( _ => return error.JetzigUnsupportedHttpMethod, }; - var it = std.mem.splitScalar(u8, resp.request.target, '/'); + var it = std.mem.splitScalar(u8, response.request.target, '/'); var segments = std.ArrayList([]const u8).init(allocator); while (it.next()) |segment| try segments.append(segment); var cookies = try allocator.create(jetzig.http.Cookies); cookies.* = jetzig.http.Cookies.init( allocator, - resp.request.headers.getFirstValue("Cookie") orelse "", + response.request.headers.getFirstValue("Cookie") orelse "", ); try cookies.parse(); @@ -79,16 +68,24 @@ pub fn init( const response_data = try allocator.create(jetzig.data.Data); response_data.* = jetzig.data.Data.init(allocator); + const query_data = try allocator.create(jetzig.data.Data); + query_data.* = jetzig.data.Data.init(allocator); + + const query = try allocator.create(jetzig.http.Query); + return .{ .allocator = allocator, - .path = resp.request.target, + .path = response.request.target, .method = method, - .headers = jetzig.http.Headers.init(allocator, resp.request.headers), + .headers = jetzig.http.Headers.init(allocator, response.request.headers), .server = server, .segments = segments, .cookies = cookies, .session = session, .response_data = response_data, + .query_data = query_data, + .query = query, + .body = body, }; } @@ -111,6 +108,56 @@ 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 +/// 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 +/// ignored. +pub fn params(self: *Self) !*jetzig.data.Value { + switch (self.requestFormat()) { + .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| { + switch (err) { + error.UnexpectedEndOfInput => return error.JetzigBodyParseError, + else => return err, + } + }; + return data.value.?; + }, + .HTML, .UNKNOWN => return self.queryParams(), + } +} + +fn queryParams(self: *Self) !*jetzig.data.Value { + if (!try self.parseQueryString()) { + self.query.data = try self.allocator.create(jetzig.data.Data); + self.query.data.* = jetzig.data.Data.init(self.allocator); + _ = try self.query.data.object(); + } + return self.query.data.value.?; +} + +fn parseQueryString(self: *Self) !bool { + const delimiter_index = std.mem.indexOfScalar(u8, self.path, '?'); + if (delimiter_index) |index| { + if (self.path.len - 1 < index + 1) return false; + + self.query.* = jetzig.http.Query.init( + self.server.allocator, + self.path[index + 1 ..], + self.query_data, + ); + try self.query.parse(); + return true; + } + + return false; +} + fn extensionFormat(self: *Self) ?jetzig.http.Request.Format { const extension = std.fs.path.extension(self.path); @@ -168,6 +215,9 @@ pub fn resourceName(self: *Self) []const u8 { if (self.segments.items.len == 0) return "default"; const basename = std.fs.path.basename(self.segments.items[self.segments.items.len - 1]); + if (std.mem.indexOfScalar(u8, basename, '?')) |index| { + return basename[0..index]; + } const extension = std.fs.path.extension(basename); return basename[0 .. basename.len - extension.len]; } @@ -181,35 +231,53 @@ pub fn resourcePath(self: *Self) ![]const u8 { return try std.mem.concat(self.allocator, u8, &[_][]const u8{ "/", path }); } +/// For a path `/foo/bar/baz/123.json`, returns `"123"`. pub fn resourceId(self: *Self) []const u8 { return self.resourceName(); } +// Determine if a given route matches the current request. pub fn match(self: *Self, route: jetzig.views.Route) !bool { switch (self.method) { .GET => { return switch (route.action) { - .index => std.mem.eql(u8, self.pathWithoutExtension(), route.uri_path), - .get => std.mem.eql(u8, self.pathWithoutResourceId(), route.uri_path), + .index => self.isMatch(.exact, route), + .get => self.isMatch(.resource_id, route), else => false, }; }, - .POST => return route.action == .post, - .PUT => return route.action == .put, - .PATCH => return route.action == .patch, - .DELETE => return route.action == .delete, + .POST => return self.isMatch(.exact, route), + .PUT => return self.isMatch(.resource_id, route), + .PATCH => return self.isMatch(.resource_id, route), + .DELETE => return self.isMatch(.resource_id, route), else => return false, } return false; } -fn pathWithoutExtension(self: *Self) []const u8 { - const index = std.mem.indexOfScalar(u8, self.path, '.'); - if (index) |capture| return self.path[0..capture] else return self.path; +fn isMatch(self: *Self, match_type: enum { exact, resource_id }, route: jetzig.views.Route) bool { + const path = switch (match_type) { + .exact => self.pathWithoutExtension(), + .resource_id => self.pathWithoutExtensionAndResourceId(), + }; + + return (std.mem.eql(u8, path, route.uri_path)); } -fn pathWithoutResourceId(self: *Self) []const u8 { +// TODO: Be a bit more deterministic in identifying extension, e.g. deal with `.` characters +// elsewhere in the path (e.g. in query string). +fn pathWithoutExtension(self: *Self) []const u8 { + const extension_index = std.mem.lastIndexOfScalar(u8, self.path, '.'); + if (extension_index) |capture| return self.path[0..capture]; + + const query_index = std.mem.indexOfScalar(u8, self.path, '?'); + if (query_index) |capture| return self.path[0..capture]; + + return self.path; +} + +fn pathWithoutExtensionAndResourceId(self: *Self) []const u8 { const path = self.pathWithoutExtension(); const index = std.mem.lastIndexOfScalar(u8, self.path, '/'); if (index) |capture| { diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index 5a2ae5c..670f7a5 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -3,8 +3,11 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); const root_file = @import("root"); -const jetzig_server_options = if (@hasDecl(root_file, "jetzig_options")) root_file.jetzig_options else struct {}; -const middlewares: []const type = if (@hasDecl(jetzig_server_options, "middlewares")) jetzig_server_options.middlewares else &.{}; + +pub const jetzig_server_options = if (@hasDecl(root_file, "jetzig_options")) + root_file.jetzig_options +else + struct {}; pub const ServerOptions = struct { cache: jetzig.caches.Cache, @@ -94,38 +97,15 @@ fn processNextRequest(self: *Self, response: *std.http.Server.Response) !void { var arena = std.heap.ArenaAllocator.init(self.allocator); defer arena.deinit(); - var request = try jetzig.http.Request.init(arena.allocator(), self, response); + var request = try jetzig.http.Request.init(arena.allocator(), self, response, body); defer request.deinit(); - var middleware_data = std.BoundedArray(*anyopaque, middlewares.len).init(0) catch unreachable; - inline for (middlewares, 0..) |middleware, index| { - if (comptime !@hasDecl(middleware, "init")) continue; - const data = try @call(.always_inline, middleware.init, .{&request}); - middleware_data.insert(index, data) catch unreachable; // We cannot overflow here because we know the length of the array - } - - inline for (middlewares, 0..) |middleware, index| { - if (comptime !@hasDecl(middleware, "beforeRequest")) continue; - if (comptime @hasDecl(middleware, "init")) { - const data = middleware_data.get(index); - try @call(.always_inline, middleware.beforeRequest, .{ @as(*middleware, @ptrCast(@alignCast(data))), &request }); - } else { - try @call(.always_inline, middleware.beforeRequest, .{&request}); - } - } + var middleware_data = try jetzig.http.middleware.beforeMiddleware(&request); var result = try self.pageContent(&request); defer result.deinit(); - inline for (middlewares, 0..) |middleware, index| { - if (comptime !@hasDecl(middleware, "afterRequest")) continue; - if (comptime @hasDecl(middleware, "init")) { - const data = middleware_data.get(index); - try @call(.always_inline, middleware.afterRequest, .{ @as(*middleware, @ptrCast(@alignCast(data))), &request, &result }); - } else { - try @call(.always_inline, middleware.afterRequest, .{ &request, &result }); - } - } + try jetzig.http.middleware.afterMiddleware(&middleware_data, &request, &result); response.transfer_encoding = .{ .content_length = result.value.content.len }; var cookie_it = request.cookies.headerIterator(); @@ -133,28 +113,22 @@ fn processNextRequest(self: *Self, response: *std.http.Server.Response) !void { // FIXME: Skip setting cookies that are already present ? try response.headers.append("Set-Cookie", header); } + try response.headers.append("Content-Type", result.value.content_type); + response.status = switch (result.value.status_code) { inline else => |status_code| @field(std.http.Status, @tagName(status_code)), }; try response.send(); try response.writeAll(result.value.content); - try response.finish(); const log_message = try self.requestLogMessage(&request, result); defer self.allocator.free(log_message); self.logger.debug("{s}", .{log_message}); - inline for (middlewares, 0..) |middleware, index| { - if (comptime @hasDecl(middleware, "init")) { - if (comptime @hasDecl(middleware, "deinit")) { - const data = middleware_data.get(index); - @call(.always_inline, middleware.deinit, .{ @as(*middleware, @ptrCast(@alignCast(data))), &request }); - } - } - } + jetzig.http.middleware.deinit(&middleware_data, &request); } fn pageContent(self: *Self, request: *jetzig.http.Request) !jetzig.caches.Result { @@ -171,7 +145,7 @@ fn pageContent(self: *Self, request: *jetzig.http.Request) !jetzig.caches.Result fn renderResponse(self: *Self, request: *jetzig.http.Request) !jetzig.http.Response { const static = self.matchStaticResource(request) catch |err| { if (isUnhandledError(err)) return err; - const rendered = try self.internalServerError(request, err); + const rendered = try self.renderInternalServerError(request, err); return .{ .allocator = self.allocator, .status_code = .internal_server_error, @@ -182,7 +156,7 @@ fn renderResponse(self: *Self, request: *jetzig.http.Request) !jetzig.http.Respo if (static) |resource| return try self.renderStatic(request, resource); - const route = try self.matchRoute(request); + const route = try self.matchRoute(request, false); switch (request.requestFormat()) { .HTML => return self.renderHTML(request, route), @@ -273,13 +247,21 @@ fn renderView( const view = route.render(route, request) catch |err| { self.logger.debug("Encountered error: {s}", .{@errorName(err)}); if (isUnhandledError(err)) return err; - return try self.internalServerError(request, err); + if (isBadRequest(err)) return try self.renderBadRequest(request); + return try self.renderInternalServerError(request, err); }; const content = if (template) |capture| try capture.render(view.data) else ""; return .{ .view = view, .content = content }; } +fn isBadRequest(err: anyerror) bool { + return switch (err) { + error.JetzigBodyParseError, error.JetzigQueryParseError => true, + else => false, + }; +} + fn isUnhandledError(err: anyerror) bool { return switch (err) { error.OutOfMemory => true, @@ -287,7 +269,7 @@ fn isUnhandledError(err: anyerror) bool { }; } -fn internalServerError(self: *Self, request: *jetzig.http.Request, err: anyerror) !RenderedView { +fn renderInternalServerError(self: *Self, request: *jetzig.http.Request, err: anyerror) !RenderedView { request.response_data.reset(); var object = try request.response_data.object(); @@ -301,6 +283,20 @@ fn internalServerError(self: *Self, request: *jetzig.http.Request, err: anyerror .content = "Internal Server Error\n", }; } + +fn renderBadRequest(self: *Self, request: *jetzig.http.Request) !RenderedView { + _ = self; + request.response_data.reset(); + + var object = try request.response_data.object(); + try object.put("error", request.response_data.string("Bad Request")); + + return .{ + .view = jetzig.views.View{ .data = request.response_data, .status_code = .bad_request }, + .content = "Bad Request\n", + }; +} + fn logStackTrace( self: *Self, stack: *std.builtin.StackTrace, @@ -341,18 +337,31 @@ fn duration(self: *Self) i64 { return @intCast(std.time.nanoTimestamp() - self.start_time); } -fn matchRoute(self: *Self, request: *jetzig.http.Request) !?jetzig.views.Route { +fn matchRoute(self: *Self, request: *jetzig.http.Request, static: bool) !?jetzig.views.Route { for (self.routes) |route| { - if (route.action == .index and try request.match(route)) return route; + // .index routes always take precedence. + if (route.static == static and route.action == .index and try request.match(route)) return route; } for (self.routes) |route| { - if (try request.match(route)) return route; + if (route.static == static and try request.match(route)) return route; } return null; } +fn matchStaticParams(self: *Self, request: *jetzig.http.Request, route: jetzig.views.Route) !?usize { + _ = self; + const params = try request.params(); + + for (route.params.items, 0..) |static_params, index| { + if (try static_params.getValue("params")) |expected_params| { + if (expected_params.eql(params)) return index; + } + } + return null; +} + const StaticResource = struct { content: []const u8, mime_type: []const u8 = "application/octet-stream" }; fn matchStaticResource(self: *Self, request: *jetzig.http.Request) !?StaticResource { @@ -383,8 +392,10 @@ fn matchPublicContent(self: *Self, request: *jetzig.http.Request) !?[]const u8 { else => return err, } }; + defer iterable_dir.close(); var walker = try iterable_dir.walk(request.allocator); + defer walker.deinit(); while (try walker.next()) |file| { if (file.kind != .file) continue; @@ -410,19 +421,14 @@ fn matchStaticContent(self: *Self, request: *jetzig.http.Request) !?[]const u8 { }; defer static_dir.close(); - // TODO: Use a hashmap to avoid O(n) - for (self.routes) |route| { - if (route.static and try request.match(route)) { - const extension = switch (request.requestFormat()) { - .HTML, .UNKNOWN => ".html", - .JSON => ".json", - }; - - const path = try std.mem.concat(request.allocator, u8, &[_][]const u8{ route.name, extension }); + const matched_route = try self.matchRoute(request, true); + if (matched_route) |route| { + const static_path = try self.staticPath(request, route); + if (static_path) |capture| { return static_dir.readFileAlloc( request.allocator, - path, + capture, jetzig.config.max_bytes_static_content, ) catch |err| { switch (err) { @@ -430,8 +436,55 @@ fn matchStaticContent(self: *Self, request: *jetzig.http.Request) !?[]const u8 { else => return err, } }; - } + } else return null; } return null; } + +fn staticPath(self: *Self, request: *jetzig.http.Request, route: jetzig.views.Route) !?[]const u8 { + _ = self; + const params = try request.params(); + const extension = switch (request.requestFormat()) { + .HTML, .UNKNOWN => ".html", + .JSON => ".json", + }; + + for (route.params.items, 0..) |static_params, index| { + if (try static_params.getValue("params")) |expected_params| { + switch (route.action) { + .index, .post => {}, + inline else => { + if (try static_params.getValue("id")) |id| { + switch (id.*) { + .string => |capture| { + if (!std.mem.eql(u8, capture.value, request.resourceId())) continue; + }, + // Should be unreachable but we want to avoid a runtime panic. + inline else => continue, + } + } + }, + } + if (!expected_params.eql(params)) continue; + + const index_fmt = try std.fmt.allocPrint(request.allocator, "{}", .{index}); + defer request.allocator.free(index_fmt); + + return try std.mem.concat( + request.allocator, + u8, + &[_][]const u8{ route.name, "_", index_fmt, extension }, + ); + } + } + + switch (route.action) { + .index, .post => return try std.mem.concat( + request.allocator, + u8, + &[_][]const u8{ route.name, "_", extension }, + ), + else => return null, + } +} diff --git a/src/jetzig/http/Session.zig b/src/jetzig/http/Session.zig index 777e1ed..9dd55e3 100644 --- a/src/jetzig/http/Session.zig +++ b/src/jetzig/http/Session.zig @@ -65,21 +65,21 @@ pub fn deinit(self: *Self) void { if (self.cookie) |*ptr| self.allocator.free(ptr.*.value); } -pub fn get(self: *Self, key: []const u8) !?jetzig.data.Value { +pub fn get(self: *Self, key: []const u8) !?*jetzig.data.Value { if (self.state != .parsed) return error.UnparsedSessionCookie; - return switch (self.data.value.?) { + return switch (self.data.value.?.*) { .object => self.data.value.?.object.get(key), else => unreachable, }; } -pub fn put(self: *Self, key: []const u8, value: jetzig.data.Value) !void { +pub fn put(self: *Self, key: []const u8, value: *jetzig.data.Value) !void { if (self.state != .parsed) return error.UnparsedSessionCookie; - switch (self.data.value.?) { + switch (self.data.value.?.*) { .object => |*object| { - try object.put(key, value); + try object.*.put(key, value); }, else => unreachable, } diff --git a/src/jetzig/http/StaticRequest.zig b/src/jetzig/http/StaticRequest.zig index 674283b..261d73c 100644 --- a/src/jetzig/http/StaticRequest.zig +++ b/src/jetzig/http/StaticRequest.zig @@ -4,11 +4,13 @@ const jetzig = @import("../../jetzig.zig"); response_data: *jetzig.data.Data, allocator: std.mem.Allocator, +json: []const u8, -pub fn init(allocator: std.mem.Allocator) !Self { +pub fn init(allocator: std.mem.Allocator, json: []const u8) !Self { return .{ .allocator = allocator, .response_data = try allocator.create(jetzig.data.Data), + .json = json, }; } @@ -20,7 +22,23 @@ pub fn render(self: *Self, status_code: jetzig.http.status_codes.StatusCode) jet return .{ .data = self.response_data, .status_code = status_code }; } -pub fn resourceId(self: *Self) []const u8 { - _ = self; - return "TODO"; +pub fn resourceId(self: *Self) ![]const u8 { + var data = try self.allocator.create(jetzig.data.Data); + data.* = jetzig.data.Data.init(self.allocator); + defer self.allocator.destroy(data); + defer data.deinit(); + + try data.fromJson(self.json); + // Routes generator rejects missing `.id` option so this should always be present. + // Note that static requests are never rendered at runtime so we can be unsafe here and risk + // failing a build (which would not be coherent if we allowed it to complete). + return data.value.?.get("id").?.string.value; +} + +/// Returns the static params defined by `pub const static_params` in the relevant view. +pub fn params(self: *Self) !*jetzig.data.Value { + var data = try self.allocator.create(jetzig.data.Data); + data.* = jetzig.data.Data.init(self.allocator); + try data.fromJson(self.json); + return data.value.?.get("params") orelse data.object(); } diff --git a/src/jetzig/http/middleware.zig b/src/jetzig/http/middleware.zig new file mode 100644 index 0000000..f0f817f --- /dev/null +++ b/src/jetzig/http/middleware.zig @@ -0,0 +1,73 @@ +const std = @import("std"); +const jetzig = @import("../../jetzig.zig"); + +const server_options = jetzig.http.Server.jetzig_server_options; + +const middlewares: []const type = if (@hasDecl(server_options, "middleware")) + server_options.middleware +else + &.{}; + +const MiddlewareData = std.BoundedArray(*anyopaque, middlewares.len); + +pub fn beforeMiddleware(request: *jetzig.http.Request) !MiddlewareData { + var middleware_data = MiddlewareData.init(0) catch unreachable; + + inline for (middlewares, 0..) |middleware, index| { + if (comptime !@hasDecl(middleware, "init")) continue; + const data = try @call(.always_inline, middleware.init, .{request}); + // We cannot overflow here because we know the length of the array + middleware_data.insert(index, data) catch unreachable; + } + + inline for (middlewares, 0..) |middleware, index| { + if (comptime !@hasDecl(middleware, "beforeRequest")) continue; + if (comptime @hasDecl(middleware, "init")) { + const data = middleware_data.get(index); + try @call( + .always_inline, + middleware.beforeRequest, + .{ @as(*middleware, @ptrCast(@alignCast(data))), request }, + ); + } else { + try @call(.always_inline, middleware.beforeRequest, .{request}); + } + } + + return middleware_data; +} + +pub fn afterMiddleware( + middleware_data: *MiddlewareData, + request: *jetzig.http.Request, + result: *jetzig.caches.Result, +) !void { + inline for (middlewares, 0..) |middleware, index| { + if (comptime !@hasDecl(middleware, "afterRequest")) continue; + if (comptime @hasDecl(middleware, "init")) { + const data = middleware_data.get(index); + try @call( + .always_inline, + middleware.afterRequest, + .{ @as(*middleware, @ptrCast(@alignCast(data))), request, result }, + ); + } else { + try @call(.always_inline, middleware.afterRequest, .{ request, result }); + } + } +} + +pub fn deinit(middleware_data: *MiddlewareData, request: *jetzig.http.Request) void { + inline for (middlewares, 0..) |middleware, index| { + if (comptime @hasDecl(middleware, "init")) { + if (comptime @hasDecl(middleware, "deinit")) { + const data = middleware_data.get(index); + @call( + .always_inline, + middleware.deinit, + .{ @as(*middleware, @ptrCast(@alignCast(data))), request }, + ); + } + } + } +} diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig index 91f7b3c..75061e5 100644 --- a/src/jetzig/views/Route.zig +++ b/src/jetzig/views/Route.zig @@ -45,6 +45,25 @@ static: bool, render: RenderFn = renderFn, renderStatic: RenderStaticFn = renderStaticFn, template: []const u8, +json_params: [][]const u8, +params: std.ArrayList(*jetzig.data.Data) = undefined, + +/// Initializes a route's static params on server launch. Converts static params (JSON strings) +/// to `jetzig.data.Data` values. Memory is owned by caller (`App.start()`). +pub fn initParams(self: *Self, allocator: std.mem.Allocator) !void { + self.params = std.ArrayList(*jetzig.data.Data).init(allocator); + for (self.json_params) |params| { + var data = try allocator.create(jetzig.data.Data); + data.* = jetzig.data.Data.init(allocator); + try self.params.append(data); + try data.fromJson(params); + } +} + +pub fn deinitParams(self: *const Self) void { + for (self.params.items) |data| data.deinit(); + self.params.deinit(); +} fn renderFn(self: Self, request: *jetzig.http.Request) anyerror!jetzig.views.View { switch (self.view.?) { @@ -70,10 +89,10 @@ fn renderStaticFn(self: Self, request: *jetzig.http.StaticRequest) anyerror!jetz switch (self.view.?.static) { .index => |view| return try view(request, request.response_data), - .get => |view| return try view(request.resourceId(), request, request.response_data), + .get => |view| return try view(try request.resourceId(), request, request.response_data), .post => |view| return try view(request, request.response_data), - .patch => |view| return try view(request.resourceId(), request, request.response_data), - .put => |view| return try view(request.resourceId(), request, request.response_data), - .delete => |view| return try view(request.resourceId(), request, request.response_data), + .patch => |view| return try view(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), } } diff --git a/src/main.zig b/src/main.zig deleted file mode 100644 index 228e1dd..0000000 --- a/src/main.zig +++ /dev/null @@ -1,59 +0,0 @@ -const std = @import("std"); - -pub const jetzig = @import("jetzig.zig"); -pub const templates = @import("app/views/zmpl.manifest.zig").templates; -pub const routes = @import("routes").routes; - -pub const jetzig_options = struct { - pub const middleware: []const type = &.{ TestMiddleware, IncompleteMiddleware, IncompleteMiddleware2 }; -}; - -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer std.debug.assert(gpa.deinit() == .ok); - const allocator = gpa.allocator(); - - const app = try jetzig.init(allocator); - defer app.deinit(); - - try app.start( - comptime jetzig.route(routes), - comptime jetzig.loadTemplates(templates), - ); -} - -const TestMiddleware = struct { - my_data: u8, - pub fn init(request: *jetzig.http.Request) !*TestMiddleware { - var mw = try request.allocator.create(TestMiddleware); - mw.my_data = 42; - return mw; - } - - pub fn beforeRequest(middleware: *TestMiddleware, request: *jetzig.http.Request) !void { - request.server.logger.debug("Before request, custom data: {d}", .{middleware.my_data}); - middleware.my_data = 43; - } - - pub fn afterRequest(middleware: *TestMiddleware, request: *jetzig.http.Request, result: *jetzig.caches.Result) !void { - request.server.logger.debug("After request, custom data: {d}", .{middleware.my_data}); - request.server.logger.debug("{s}", .{result.value.content_type}); - } - - pub fn deinit(middleware: *TestMiddleware, request: *jetzig.http.Request) void { - request.allocator.destroy(middleware); - } -}; - -const IncompleteMiddleware = struct { - pub fn beforeRequest(request: *jetzig.http.Request) !void { - request.server.logger.debug("Before request", .{}); - } -}; - -const IncompleteMiddleware2 = struct { - pub fn afterRequest(request: *jetzig.http.Request, result: *jetzig.caches.Result) !void { - request.server.logger.debug("After request", .{}); - _ = result; - } -}; diff --git a/src/tests.zig b/src/tests.zig index 8b51ac2..6bf0a52 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -1,4 +1,5 @@ test { _ = @import("jetzig.zig"); + _ = @import("jetzig/http/Query.zig"); @import("std").testing.refAllDeclsRecursive(@This()); }