Provide interface for adding response headers

This commit is contained in:
Bob Farrell 2024-02-25 14:20:22 +00:00
parent a07c71e725
commit be85c13369
12 changed files with 177 additions and 92 deletions

View File

@ -1,6 +1,6 @@
![Jetzig Logo](demo/public/jetzig.png) ![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. 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) * [http.zig](https://github.com/karlseguin/http.zig)
* [zig-router](https://github.com/Cloudef/zig-router) * [zig-router](https://github.com/Cloudef/zig-router)
* [zig-webui](https://github.com/webui-dev/zig-webui/) * [zig-webui](https://github.com/webui-dev/zig-webui/)
* [ZTS](https://github.com/zigster64/zts)
## Checklist ## 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: Cookies.
* :white_check_mark: Error handling. * :white_check_mark: Error handling.
* :white_check_mark: Static content from /public directory. * :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: Stack trace output on error.
* :white_check_mark: Static content generation. * :white_check_mark: Static content generation.
* :x: Param/JSON payload parsing/abstracting. * :x: Param/JSON payload parsing/abstracting.

View File

@ -1,37 +1,10 @@
const std = @import("std");
const jetzig = @import("jetzig"); const jetzig = @import("jetzig");
pub const static_params = .{ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
.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(); var root = try data.object();
try root.put("message", data.string("Welcome to Jetzig!"));
const params = try request.params(); try request.response.headers.append("x-example-header", "example header value");
if (params.get("foo")) |foo| try root.put("foo", foo);
return request.render(.ok); 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);
}

View File

@ -14,8 +14,6 @@
<h1 class="text-3xl text-center p-3 pb-6 font-bold">{.message}</h1> <h1 class="text-3xl text-center p-3 pb-6 font-bold">{.message}</h1>
</div> </div>
<div>{.foo}</div>
<button hx-get="/quotes/random" hx-trigger="click" hx-target="#quote" class="bg-[#39b54a] text-white font-bold py-2 px-4 rounded">Click Me</button> <button hx-get="/quotes/random" hx-trigger="click" hx-target="#quote" class="bg-[#39b54a] text-white font-bold py-2 px-4 rounded">Click Me</button>
<div id="quote" class="p-7 mx-auto w-1/2"> <div id="quote" class="p-7 mx-auto w-1/2">

View File

@ -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);
}

View File

@ -2,6 +2,8 @@
// This file is automatically generated at build time. Manual edits will be discarded. // This file is automatically generated at build time. Manual edits will be discarded.
// This file should _not_ be stored in version control. // This file should _not_ be stored in version control.
pub const templates = struct { 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 root_index = @import("root/.index.zmpl.compiled.zig");
pub const quotes_post = @import("quotes/.post.zmpl.compiled.zig"); pub const quotes_post = @import("quotes/.post.zmpl.compiled.zig");
pub const quotes_get = @import("quotes/.get.zmpl.compiled.zig"); pub const quotes_get = @import("quotes/.get.zmpl.compiled.zig");

View File

@ -155,6 +155,8 @@ pub fn generateRoutes(self: *Self) !void {
try writer.writeAll(" };\n"); try writer.writeAll(" };\n");
try writer.writeAll("};"); 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 { fn writeRoute(self: *Self, writer: std.ArrayList(u8).Writer, route: Function) !void {

View File

@ -8,12 +8,26 @@ pub const data = @import("jetzig/data.zig");
pub const caches = @import("jetzig/caches.zig"); pub const caches = @import("jetzig/caches.zig");
pub const views = @import("jetzig/views.zig"); pub const views = @import("jetzig/views.zig");
pub const colors = @import("jetzig/colors.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"); 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; 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; 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; 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 View = views.View;
pub const config = struct { pub const config = struct {

View File

@ -14,6 +14,9 @@ pub fn deinit(self: Self) void {
_ = self; _ = 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 { pub fn start(self: Self, routes: []jetzig.views.Route, templates: []jetzig.TemplateFn) !void {
var server = jetzig.http.Server.init( var server = jetzig.http.Server.init(
self.allocator, self.allocator,

View File

@ -13,14 +13,42 @@ pub fn deinit(self: *Self) void {
self.std_headers.deinit(); self.std_headers.deinit();
} }
pub fn getFirstValue(self: *Self, key: []const u8) ?[]const u8 { // Gets the first value for a given header identified by `name`.
return self.std_headers.getFirstValue(key); 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 { /// Appends `name` and `value` to headers.
try self.std_headers.append(key, value); 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 { test {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
var headers = std.http.Headers.init(allocator); var headers = std.http.Headers.init(allocator);

View File

@ -43,9 +43,22 @@ pub fn init(
_ => return error.JetzigUnsupportedHttpMethod, _ => 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 it = std.mem.splitScalar(u8, response.std_response.request.target, '/');
var segments = std.ArrayList([]const u8).init(allocator); 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); var cookies = try allocator.create(jetzig.http.Cookies);
cookies.* = jetzig.http.Cookies.init( cookies.* = jetzig.http.Cookies.init(
@ -149,7 +162,7 @@ fn parseQueryString(self: *Self) !bool {
if (self.path.len - 1 < index + 1) return false; if (self.path.len - 1 < index + 1) return false;
self.query.* = jetzig.http.Query.init( self.query.* = jetzig.http.Query.init(
self.server.allocator, self.allocator,
self.path[index + 1 ..], self.path[index + 1 ..],
self.query_data, self.query_data,
); );
@ -214,7 +227,7 @@ pub fn resourceModifier(self: *Self) ?Modifier {
} }
pub fn resourceName(self: *Self) []const u8 { 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]); const basename = std.fs.path.basename(self.segments.items[self.segments.items.len - 1]);
if (std.mem.indexOfScalar(u8, basename, '?')) |index| { 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(), .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 // TODO: Be a bit more deterministic in identifying extension, e.g. deal with `.` characters

View File

@ -30,8 +30,8 @@ pub fn init(
pub fn deinit(self: *const Self) void { pub fn deinit(self: *const Self) void {
self.headers.deinit(); self.headers.deinit();
// self.allocator.free(self.content); self.allocator.destroy(self.headers);
// self.allocator.free(self.content_type); self.std_response.deinit();
} }
const ResetState = enum { reset, closing }; const ResetState = enum { reset, closing };
@ -49,11 +49,18 @@ pub fn wait(self: *const Self) !void {
try self.std_response.wait(); 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 { pub fn finish(self: *const Self) !void {
self.std_response.status = switch (self.status_code) { self.std_response.status = switch (self.status_code) {
inline else => |status_code| @field(std.http.Status, @tagName(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.send();
try self.std_response.writeAll(self.content); try self.std_response.writeAll(self.content);
try self.std_response.finish(); try self.std_response.finish();
@ -76,12 +83,3 @@ pub fn setTransferEncoding(self: *const Self, transfer_encoding: TransferEncodin
// TODO: Chunked encoding // TODO: Chunked encoding
self.std_response.transfer_encoding = .{ .content_length = transfer_encoding.content_length }; 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),
};
}

View File

@ -67,18 +67,22 @@ pub fn listen(self: *Self) !void {
fn processRequests(self: *Self) !void { fn processRequests(self: *Self) !void {
while (true) { 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( var response = try jetzig.http.Response.init(
self.allocator, allocator,
&std_response, &std_response,
); );
errdefer response.deinit(); errdefer response.deinit();
errdefer arena.deinit();
try response.headers.append("Connection", "close"); try response.headers.append("Connection", "close");
while (response.reset() != .closing) { while (response.reset() != .closing) {
self.processNextRequest(&response) catch |err| { self.processNextRequest(allocator, &response) catch |err| {
switch (err) { switch (err) {
error.EndOfStream, error.ConnectionResetByPeer => continue, error.EndOfStream, error.ConnectionResetByPeer => continue,
error.UnknownHttpMethod => continue, // TODO: Render 400 Bad Request here ? error.UnknownHttpMethod => continue, // TODO: Render 400 Bad Request here ?
@ -88,10 +92,11 @@ fn processRequests(self: *Self) !void {
} }
response.deinit(); 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(); try response.wait();
self.start_time = std.time.nanoTimestamp(); self.start_time = std.time.nanoTimestamp();
@ -99,10 +104,7 @@ fn processNextRequest(self: *Self, response: *jetzig.http.Response) !void {
const body = try response.read(); const body = try response.read();
defer self.allocator.free(body); defer self.allocator.free(body);
var arena = std.heap.ArenaAllocator.init(self.allocator); var request = try jetzig.http.Request.init(allocator, self, response, body);
defer arena.deinit();
var request = try jetzig.http.Request.init(arena.allocator(), self, response, body);
defer request.deinit(); defer request.deinit();
var middleware_data = try jetzig.http.middleware.beforeMiddleware(&request); var middleware_data = try jetzig.http.middleware.beforeMiddleware(&request);
@ -180,16 +182,11 @@ fn renderHTML(
return; 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( fn renderJSON(
@ -329,22 +326,10 @@ fn matchRoute(self: *Self, request: *jetzig.http.Request, static: bool) !?jetzig
return null; 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" }; const StaticResource = struct { content: []const u8, mime_type: []const u8 = "application/octet-stream" };
fn matchStaticResource(self: *Self, request: *jetzig.http.Request) !?StaticResource { 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 }; if (public_content) |content| return .{ .content = content };
const static_content = try self.matchStaticContent(request); const static_content = try self.matchStaticContent(request);
@ -359,9 +344,7 @@ fn matchStaticResource(self: *Self, request: *jetzig.http.Request) !?StaticResou
return null; return null;
} }
fn matchPublicContent(self: *Self, request: *jetzig.http.Request) !?[]const u8 { fn matchPublicContent(request: *jetzig.http.Request) !?[]const u8 {
_ = self;
if (request.path.len < 2) return null; if (request.path.len < 2) return null;
if (request.method != .GET) 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); const matched_route = try self.matchRoute(request, true);
if (matched_route) |route| { if (matched_route) |route| {
const static_path = try self.staticPath(request, route); const static_path = try staticPath(request, route);
if (static_path) |capture| { if (static_path) |capture| {
return static_dir.readFileAlloc( return static_dir.readFileAlloc(
request.allocator, request.allocator,
@ -421,9 +405,10 @@ fn matchStaticContent(self: *Self, request: *jetzig.http.Request) !?[]const u8 {
return null; return null;
} }
fn staticPath(self: *Self, request: *jetzig.http.Request, route: jetzig.views.Route) !?[]const u8 { fn staticPath(request: *jetzig.http.Request, route: jetzig.views.Route) !?[]const u8 {
_ = self;
const params = try request.params(); const params = try request.params();
defer params.deinit();
const extension = switch (request.requestFormat()) { const extension = switch (request.requestFormat()) {
.HTML, .UNKNOWN => ".html", .HTML, .UNKNOWN => ".html",
.JSON => ".json", .JSON => ".json",
@ -439,8 +424,8 @@ fn staticPath(self: *Self, request: *jetzig.http.Request, route: jetzig.views.Ro
.string => |capture| { .string => |capture| {
if (!std.mem.eql(u8, capture.value, request.resourceId())) continue; if (!std.mem.eql(u8, capture.value, request.resourceId())) continue;
}, },
// Should be unreachable but we want to avoid a runtime panic. // Should be unreachable - this means generated `routes.zig` is incoherent:
inline else => continue, inline else => return error.JetzigRouteError,
} }
} }
}, },