From be85c133697b2afe0e4deed96602f3167b772567 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Sun, 25 Feb 2024 14:20:22 +0000 Subject: [PATCH] Provide interface for adding response headers --- README.md | 5 +- demo/src/app/views/root.zig | 33 ++------------ demo/src/app/views/root/index.zmpl | 2 - demo/src/app/views/static.zig | 68 ++++++++++++++++++++++++++++ demo/src/app/views/zmpl.manifest.zig | 2 + src/GenerateRoutes.zig | 2 + src/jetzig.zig | 16 ++++++- src/jetzig/App.zig | 3 ++ src/jetzig/http/Headers.zig | 36 +++++++++++++-- src/jetzig/http/Request.zig | 21 +++++++-- src/jetzig/http/Response.zig | 20 ++++---- src/jetzig/http/Server.zig | 61 ++++++++++--------------- 12 files changed, 177 insertions(+), 92 deletions(-) create mode 100644 demo/src/app/views/static.zig diff --git a/README.md b/README.md index 2c3bbe2..f580af0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Jetzig Logo](demo/public/jetzig.png) -_Jetzig_ is a web framework written in [Zig](https://ziglang.org) :lizard:. +_Jetzig_ is a web framework written in 100% pure [Zig](https://ziglang.org) :lizard: for _Linux_, _OS X_, _Windows_, and any _OS_ that can compile _Zig_ code. The framework is under active development and is currently in an alpha state. @@ -16,6 +16,7 @@ If you are interested in _Jetzig_ you will probably find these tools interesting * [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/) +* [ZTS](https://github.com/zigster64/zts) ## Checklist @@ -28,7 +29,7 @@ If you are interested in _Jetzig_ you will probably find these tools interesting * :white_check_mark: Cookies. * :white_check_mark: Error handling. * :white_check_mark: Static content from /public directory. -* :white_check_mark: Headers (available but not yet wrapped). +* :white_check_mark: Request/response headers. * :white_check_mark: Stack trace output on error. * :white_check_mark: Static content generation. * :x: Param/JSON payload parsing/abstracting. diff --git a/demo/src/app/views/root.zig b/demo/src/app/views/root.zig index de8238d..e0ade83 100644 --- a/demo/src/app/views/root.zig +++ b/demo/src/app/views/root.zig @@ -1,37 +1,10 @@ -const std = @import("std"); const jetzig = @import("jetzig"); -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 { +pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { var root = try data.object(); + try root.put("message", data.string("Welcome to Jetzig!")); - const params = try request.params(); - - if (params.get("foo")) |foo| try root.put("foo", foo); + try request.response.headers.append("x-example-header", "example header value"); 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 fd884a1..57ee5cc 100644 --- a/demo/src/app/views/root/index.zmpl +++ b/demo/src/app/views/root/index.zmpl @@ -14,8 +14,6 @@

{.message}

-
{.foo}
-
diff --git a/demo/src/app/views/static.zig b/demo/src/app/views/static.zig new file mode 100644 index 0000000..e0d3b1c --- /dev/null +++ b/demo/src/app/views/static.zig @@ -0,0 +1,68 @@ +/// This example demonstrates static site generation (SSG). +/// +/// Any view function that receives `*jetzig.StaticRequest` is considered as a SSG view, which +/// will be invoked at build time and its content (both JSON and HTML) rendered to `static/` in +/// the root project directory. +/// +/// Define `pub const static_params` as a struct with fields named after each view function, with +/// the value for each field being an array of structs with fields `params` and, where +/// applicable (i.e. `get`, `put`, `patch`, and `delete`), `id`. +/// +/// For each item in the provided array, a separate JSON and HTML output will be generated. At +/// run time, requests are matched to the relevant content by comparing the request params and +/// resource ID to locate the relevant content. +/// +/// Launch the demo app and try the following requests: +/// +/// ```console +/// curl -H "Accept: application/json" \ +/// --data-bin '{"foo":"hello", "bar":"goodbye"}' \ +/// --request GET \ +/// 'http://localhost:8080/static' +/// ``` +/// +/// ```console +/// curl 'http://localhost:8080/static.html?foo=hi&bar=bye' +/// ``` +/// +/// ```console +/// curl 'http://localhost:8080/static/123.html?foo=hi&bar=bye' +/// ``` +const std = @import("std"); +const jetzig = @import("jetzig"); + +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); + if (params.get("bar")) |bar| try root.put("bar", bar); + + return request.render(.created); +} diff --git a/demo/src/app/views/zmpl.manifest.zig b/demo/src/app/views/zmpl.manifest.zig index 54152c0..eb2409b 100644 --- a/demo/src/app/views/zmpl.manifest.zig +++ b/demo/src/app/views/zmpl.manifest.zig @@ -2,6 +2,8 @@ // This file is automatically generated at build time. Manual edits will be discarded. // This file should _not_ be stored in version control. pub const templates = struct { + pub const static_index = @import("static/.index.zmpl.compiled.zig"); + pub const static_get = @import("static/.get.zmpl.compiled.zig"); 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/src/GenerateRoutes.zig b/src/GenerateRoutes.zig index 6314dc2..3ebbf6e 100644 --- a/src/GenerateRoutes.zig +++ b/src/GenerateRoutes.zig @@ -155,6 +155,8 @@ pub fn generateRoutes(self: *Self) !void { try writer.writeAll(" };\n"); try writer.writeAll("};"); + + // std.debug.print("routes.zig\n{s}\n", .{self.buffer.items}); } fn writeRoute(self: *Self, writer: std.ArrayList(u8).Writer, route: Function) !void { diff --git a/src/jetzig.zig b/src/jetzig.zig index 1801eee..fd8b756 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -8,12 +8,26 @@ pub const data = @import("jetzig/data.zig"); pub const caches = @import("jetzig/caches.zig"); pub const views = @import("jetzig/views.zig"); pub const colors = @import("jetzig/colors.zig"); + +/// The primary interface for a Jetzig application. Create an `App` in your application's +/// `src/main.zig` and call `start` to launch the application. pub const App = @import("jetzig/App.zig"); -// Convenience for view function parameters. +/// An HTTP request which is passed to (dynamic) view functions and provides access to params, +/// headers, and functions to render a response. pub const Request = http.Request; + +/// A build-time request. Provides a similar interface to a `Request` but outputs are generated +/// when building the application and then returned immediately to the client for matching +/// requests. pub const StaticRequest = http.StaticRequest; + +/// Generic, JSON-compatible data type. Provides `Value` which in turn provides `Object`, +/// `Array`, `String`, `Integer`, `Float`, `Boolean`, and `NullType`. pub const Data = data.Data; + +/// The return value of all view functions. Call `request.render(.ok)` in a view function to +/// generate a `View`. pub const View = views.View; pub const config = struct { diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index 419660c..2dee724 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -14,6 +14,9 @@ pub fn deinit(self: Self) void { _ = self; } +/// Starts an application. `routes` should be `@import("routes").routes`, a generated file +/// automatically created at build time. `templates` should be +/// `@import("src/app/views/zmpl.manifest.zig").templates`, created by Zmpl at compile time. pub fn start(self: Self, routes: []jetzig.views.Route, templates: []jetzig.TemplateFn) !void { var server = jetzig.http.Server.init( self.allocator, diff --git a/src/jetzig/http/Headers.zig b/src/jetzig/http/Headers.zig index 783d678..98167e3 100644 --- a/src/jetzig/http/Headers.zig +++ b/src/jetzig/http/Headers.zig @@ -13,14 +13,42 @@ pub fn deinit(self: *Self) void { self.std_headers.deinit(); } -pub fn getFirstValue(self: *Self, key: []const u8) ?[]const u8 { - return self.std_headers.getFirstValue(key); +// Gets the first value for a given header identified by `name`. +pub fn getFirstValue(self: *Self, name: []const u8) ?[]const u8 { + return self.std_headers.getFirstValue(name); } -pub fn append(self: *Self, key: []const u8, value: []const u8) !void { - try self.std_headers.append(key, value); +/// Appends `name` and `value` to headers. +pub fn append(self: *Self, name: []const u8, value: []const u8) !void { + try self.std_headers.append(name, value); } +/// Returns an iterator which implements `next()` returning each name/value of the stored headers. +pub fn iterator(self: *Self) Iterator { + return Iterator{ .std_headers = self.std_headers }; +} + +const Iterator = struct { + std_headers: std.http.Headers, + index: usize = 0, + + const Header = struct { + name: []const u8, + value: []const u8, + }; + + /// Returns the next item in the current iteration of headers. + pub fn next(self: *Iterator) ?Header { + if (self.std_headers.list.items.len > self.index) { + const std_header = self.std_headers.list.items[self.index]; + self.index += 1; + return .{ .name = std_header.name, .value = std_header.value }; + } else { + return null; + } + } +}; + test { const allocator = std.testing.allocator; var headers = std.http.Headers.init(allocator); diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 4236b9e..2346595 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -43,9 +43,22 @@ pub fn init( _ => return error.JetzigUnsupportedHttpMethod, }; + // TODO: Replace all this with a `Path` type which exposes all components of the path in a + // sensible way: + // * Array of segments: "/foo/bar/baz" => .{ "foo", "bar", "baz" } + // * Resource ID: "/foo/bar/baz/1" => "1" + // * Extension: "/foo/bar/baz/1.json" => ".json" + // * Query params: "/foo/bar/baz?foo=bar&baz=qux" => .{ .foo = "bar", .baz => "qux" } + // * Anything else ? var it = std.mem.splitScalar(u8, response.std_response.request.target, '/'); var segments = std.ArrayList([]const u8).init(allocator); - while (it.next()) |segment| try segments.append(segment); + while (it.next()) |segment| { + if (std.mem.indexOfScalar(u8, segment, '?')) |query_index| { + try segments.append(segment[0..query_index]); + } else { + try segments.append(segment); + } + } var cookies = try allocator.create(jetzig.http.Cookies); cookies.* = jetzig.http.Cookies.init( @@ -149,7 +162,7 @@ fn parseQueryString(self: *Self) !bool { if (self.path.len - 1 < index + 1) return false; self.query.* = jetzig.http.Query.init( - self.server.allocator, + self.allocator, self.path[index + 1 ..], self.query_data, ); @@ -214,7 +227,7 @@ pub fn resourceModifier(self: *Self) ?Modifier { } pub fn resourceName(self: *Self) []const u8 { - if (self.segments.items.len == 0) return "default"; + if (self.segments.items.len == 0) return "default"; // Should never happen ? const basename = std.fs.path.basename(self.segments.items[self.segments.items.len - 1]); if (std.mem.indexOfScalar(u8, basename, '?')) |index| { @@ -264,7 +277,7 @@ fn isMatch(self: *Self, match_type: enum { exact, resource_id }, route: jetzig.v .resource_id => self.pathWithoutExtensionAndResourceId(), }; - return (std.mem.eql(u8, path, route.uri_path)); + return std.mem.eql(u8, path, route.uri_path); } // TODO: Be a bit more deterministic in identifying extension, e.g. deal with `.` characters diff --git a/src/jetzig/http/Response.zig b/src/jetzig/http/Response.zig index 848e6c2..a40bf1d 100644 --- a/src/jetzig/http/Response.zig +++ b/src/jetzig/http/Response.zig @@ -30,8 +30,8 @@ pub fn init( pub fn deinit(self: *const Self) void { self.headers.deinit(); - // self.allocator.free(self.content); - // self.allocator.free(self.content_type); + self.allocator.destroy(self.headers); + self.std_response.deinit(); } const ResetState = enum { reset, closing }; @@ -49,11 +49,18 @@ pub fn wait(self: *const Self) !void { try self.std_response.wait(); } +/// Finalizes a request. Appends any stored headers, sets the response status code, and writes +/// the response body. pub fn finish(self: *const Self) !void { self.std_response.status = switch (self.status_code) { inline else => |status_code| @field(std.http.Status, @tagName(status_code)), }; + var it = self.headers.iterator(); + while (it.next()) |header| { + try self.std_response.headers.append(header.name, header.value); + } + try self.std_response.send(); try self.std_response.writeAll(self.content); try self.std_response.finish(); @@ -76,12 +83,3 @@ pub fn setTransferEncoding(self: *const Self, transfer_encoding: TransferEncodin // TODO: Chunked encoding self.std_response.transfer_encoding = .{ .content_length = transfer_encoding.content_length }; } - -pub fn dupe(self: *const Self) !Self { - return .{ - .allocator = self.allocator, - .status_code = self.status_code, - .content_type = try self.allocator.dupe(u8, self.content_type), - .content = try self.allocator.dupe(u8, self.content), - }; -} diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index e3aa3f2..f95b47e 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -67,18 +67,22 @@ pub fn listen(self: *Self) !void { fn processRequests(self: *Self) !void { while (true) { - var std_response = try self.server.accept(.{ .allocator = self.allocator }); + var arena = std.heap.ArenaAllocator.init(self.allocator); + const allocator = arena.allocator(); + + var std_response = try self.server.accept(.{ .allocator = allocator }); var response = try jetzig.http.Response.init( - self.allocator, + allocator, &std_response, ); errdefer response.deinit(); + errdefer arena.deinit(); try response.headers.append("Connection", "close"); while (response.reset() != .closing) { - self.processNextRequest(&response) catch |err| { + self.processNextRequest(allocator, &response) catch |err| { switch (err) { error.EndOfStream, error.ConnectionResetByPeer => continue, error.UnknownHttpMethod => continue, // TODO: Render 400 Bad Request here ? @@ -88,10 +92,11 @@ fn processRequests(self: *Self) !void { } response.deinit(); + arena.deinit(); } } -fn processNextRequest(self: *Self, response: *jetzig.http.Response) !void { +fn processNextRequest(self: *Self, allocator: std.mem.Allocator, response: *jetzig.http.Response) !void { try response.wait(); self.start_time = std.time.nanoTimestamp(); @@ -99,10 +104,7 @@ fn processNextRequest(self: *Self, response: *jetzig.http.Response) !void { const body = try response.read(); defer self.allocator.free(body); - var arena = std.heap.ArenaAllocator.init(self.allocator); - defer arena.deinit(); - - var request = try jetzig.http.Request.init(arena.allocator(), self, response, body); + var request = try jetzig.http.Request.init(allocator, self, response, body); defer request.deinit(); var middleware_data = try jetzig.http.middleware.beforeMiddleware(&request); @@ -180,16 +182,11 @@ fn renderHTML( return; } } - - response.content = ""; - response.status_code = .not_found; - response.content_type = "text/html"; - return; - } else { - response.content = ""; - response.status_code = .not_found; - response.content_type = "text/html"; } + + response.content = ""; + response.status_code = .not_found; + response.content_type = "text/html"; } fn renderJSON( @@ -329,22 +326,10 @@ fn matchRoute(self: *Self, request: *jetzig.http.Request, static: bool) !?jetzig 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 { - const public_content = try self.matchPublicContent(request); + const public_content = try matchPublicContent(request); if (public_content) |content| return .{ .content = content }; const static_content = try self.matchStaticContent(request); @@ -359,9 +344,7 @@ fn matchStaticResource(self: *Self, request: *jetzig.http.Request) !?StaticResou return null; } -fn matchPublicContent(self: *Self, request: *jetzig.http.Request) !?[]const u8 { - _ = self; - +fn matchPublicContent(request: *jetzig.http.Request) !?[]const u8 { if (request.path.len < 2) return null; if (request.method != .GET) return null; @@ -403,7 +386,8 @@ fn matchStaticContent(self: *Self, request: *jetzig.http.Request) !?[]const u8 { const matched_route = try self.matchRoute(request, true); if (matched_route) |route| { - const static_path = try self.staticPath(request, route); + const static_path = try staticPath(request, route); + if (static_path) |capture| { return static_dir.readFileAlloc( request.allocator, @@ -421,9 +405,10 @@ fn matchStaticContent(self: *Self, request: *jetzig.http.Request) !?[]const u8 { return null; } -fn staticPath(self: *Self, request: *jetzig.http.Request, route: jetzig.views.Route) !?[]const u8 { - _ = self; +fn staticPath(request: *jetzig.http.Request, route: jetzig.views.Route) !?[]const u8 { const params = try request.params(); + defer params.deinit(); + const extension = switch (request.requestFormat()) { .HTML, .UNKNOWN => ".html", .JSON => ".json", @@ -439,8 +424,8 @@ fn staticPath(self: *Self, request: *jetzig.http.Request, route: jetzig.views.Ro .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, + // Should be unreachable - this means generated `routes.zig` is incoherent: + inline else => return error.JetzigRouteError, } } },