mirror of
https://github.com/jetzig-framework/jetzig.git
synced 2025-05-14 22:16:08 +00:00
513 lines
17 KiB
Zig
513 lines
17 KiB
Zig
const std = @import("std");
|
|
|
|
const jetzig = @import("../../jetzig.zig");
|
|
const zmpl = @import("zmpl");
|
|
|
|
const root_file = @import("root");
|
|
|
|
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,
|
|
logger: jetzig.loggers.Logger,
|
|
secret: []const u8,
|
|
};
|
|
|
|
allocator: std.mem.Allocator,
|
|
port: u16,
|
|
host: []const u8,
|
|
cache: jetzig.caches.Cache,
|
|
logger: jetzig.loggers.Logger,
|
|
options: ServerOptions,
|
|
start_time: i128 = undefined,
|
|
routes: []*jetzig.views.Route,
|
|
mime_map: *jetzig.http.mime.MimeMap,
|
|
std_net_server: std.net.Server = undefined,
|
|
|
|
const Self = @This();
|
|
|
|
pub fn init(
|
|
allocator: std.mem.Allocator,
|
|
host: []const u8,
|
|
port: u16,
|
|
options: ServerOptions,
|
|
routes: []*jetzig.views.Route,
|
|
mime_map: *jetzig.http.mime.MimeMap,
|
|
) Self {
|
|
return .{
|
|
.allocator = allocator,
|
|
.host = host,
|
|
.port = port,
|
|
.cache = options.cache,
|
|
.logger = options.logger,
|
|
.options = options,
|
|
.routes = routes,
|
|
.mime_map = mime_map,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Self) void {
|
|
self.std_net_server.deinit();
|
|
}
|
|
|
|
pub fn listen(self: *Self) !void {
|
|
const address = try std.net.Address.parseIp("127.0.0.1", 8080);
|
|
self.std_net_server = try address.listen(.{ .reuse_port = true });
|
|
|
|
const cache_status = if (self.options.cache == .null_cache) "disabled" else "enabled";
|
|
self.logger.debug("Listening on http://{s}:{} [cache:{s}]", .{ self.host, self.port, cache_status });
|
|
try self.processRequests();
|
|
}
|
|
|
|
fn processRequests(self: *Self) !void {
|
|
// TODO: Keepalive
|
|
while (true) {
|
|
var arena = std.heap.ArenaAllocator.init(self.allocator);
|
|
errdefer arena.deinit();
|
|
const allocator = arena.allocator();
|
|
|
|
const connection = try self.std_net_server.accept();
|
|
|
|
var buf: [jetzig.config.http_buffer_size]u8 = undefined;
|
|
var std_http_server = std.http.Server.init(connection, &buf);
|
|
errdefer std_http_server.connection.stream.close();
|
|
|
|
self.processNextRequest(allocator, &std_http_server) catch |err| {
|
|
if (isBadHttpError(err)) {
|
|
std.debug.print("Encountered HTTP error: {s}\n", .{@errorName(err)});
|
|
std_http_server.connection.stream.close();
|
|
continue;
|
|
} else return err;
|
|
};
|
|
|
|
std_http_server.connection.stream.close();
|
|
arena.deinit();
|
|
}
|
|
}
|
|
|
|
fn processNextRequest(self: *Self, allocator: std.mem.Allocator, std_http_server: *std.http.Server) !void {
|
|
self.start_time = std.time.nanoTimestamp();
|
|
|
|
const std_http_request = try std_http_server.receiveHead();
|
|
if (std_http_server.state == .receiving_head) return error.JetzigParseHeadError;
|
|
|
|
var response = try jetzig.http.Response.init(allocator);
|
|
var request = try jetzig.http.Request.init(allocator, self, std_http_request, &response);
|
|
|
|
try request.process();
|
|
|
|
var middleware_data = try jetzig.http.middleware.afterRequest(&request);
|
|
|
|
try self.renderResponse(&request);
|
|
try request.response.headers.append("content-type", response.content_type);
|
|
|
|
try jetzig.http.middleware.beforeResponse(&middleware_data, &request);
|
|
|
|
try request.respond();
|
|
|
|
try jetzig.http.middleware.afterResponse(&middleware_data, &request);
|
|
jetzig.http.middleware.deinit(&middleware_data, &request);
|
|
|
|
const log_message = try self.requestLogMessage(&request);
|
|
defer self.allocator.free(log_message);
|
|
self.logger.debug("{s}", .{log_message});
|
|
}
|
|
|
|
fn renderResponse(self: *Self, request: *jetzig.http.Request) !void {
|
|
const static_resource = self.matchStaticResource(request) catch |err| {
|
|
if (isUnhandledError(err)) return err;
|
|
|
|
const rendered = try self.renderInternalServerError(request, err);
|
|
|
|
request.response.content = rendered.content;
|
|
request.response.status_code = rendered.view.status_code;
|
|
request.response.content_type = "text/html";
|
|
|
|
return;
|
|
};
|
|
|
|
if (static_resource) |resource| {
|
|
try renderStatic(resource, request.response);
|
|
return;
|
|
}
|
|
|
|
const route = try self.matchRoute(request, false);
|
|
|
|
switch (request.requestFormat()) {
|
|
.HTML => try self.renderHTML(request, route),
|
|
.JSON => try self.renderJSON(request, route),
|
|
.UNKNOWN => try self.renderHTML(request, route),
|
|
}
|
|
}
|
|
|
|
fn renderStatic(resource: StaticResource, response: *jetzig.http.Response) !void {
|
|
response.status_code = .ok;
|
|
response.content = resource.content;
|
|
response.content_type = resource.mime_type;
|
|
}
|
|
|
|
fn renderHTML(
|
|
self: *Self,
|
|
request: *jetzig.http.Request,
|
|
route: ?*jetzig.views.Route,
|
|
) !void {
|
|
if (route) |matched_route| {
|
|
if (zmpl.find(matched_route.template)) |template| {
|
|
const rendered = self.renderView(matched_route, request, template) catch |err| {
|
|
if (isUnhandledError(err)) return err;
|
|
const rendered_error = try self.renderInternalServerError(request, err);
|
|
request.response.content = rendered_error.content;
|
|
request.response.status_code = rendered_error.view.status_code;
|
|
request.response.content_type = "text/html";
|
|
return;
|
|
};
|
|
request.response.content = rendered.content;
|
|
request.response.status_code = rendered.view.status_code;
|
|
request.response.content_type = "text/html";
|
|
return;
|
|
}
|
|
}
|
|
|
|
request.response.content = "";
|
|
request.response.status_code = .not_found;
|
|
request.response.content_type = "text/html";
|
|
}
|
|
|
|
fn renderJSON(
|
|
self: *Self,
|
|
request: *jetzig.http.Request,
|
|
route: ?*jetzig.views.Route,
|
|
) !void {
|
|
if (route) |matched_route| {
|
|
const rendered = try self.renderView(matched_route, request, null);
|
|
var data = rendered.view.data;
|
|
|
|
if (data.value) |_| {} else _ = try data.object();
|
|
try request.headers.append("Content-Type", "application/json");
|
|
|
|
request.response.content = try data.toJson();
|
|
request.response.status_code = rendered.view.status_code;
|
|
request.response.content_type = "application/json";
|
|
} else {
|
|
request.response.content = "";
|
|
request.response.status_code = .not_found;
|
|
request.response.content_type = "application/json";
|
|
}
|
|
}
|
|
|
|
const RenderedView = struct { view: jetzig.views.View, content: []const u8 };
|
|
|
|
fn renderView(
|
|
self: *Self,
|
|
route: *jetzig.views.Route,
|
|
request: *jetzig.http.Request,
|
|
template: ?zmpl.manifest.Template,
|
|
) !RenderedView {
|
|
// View functions return a `View` to help encourage users to return from a view function with
|
|
// `return request.render(.ok)`, but the actual rendered view is stored in
|
|
// `request.rendered_view`.
|
|
_ = route.render(route.*, request) catch |err| {
|
|
self.logger.debug("Encountered error: {s}", .{@errorName(err)});
|
|
if (isUnhandledError(err)) return err;
|
|
if (isBadRequest(err)) return try self.renderBadRequest(request);
|
|
return try self.renderInternalServerError(request, err);
|
|
};
|
|
|
|
if (request.rendered_multiple) return error.JetzigMultipleRenderError;
|
|
if (request.rendered_view) |rendered_view| {
|
|
if (request.redirected) return .{ .view = rendered_view, .content = "" };
|
|
|
|
if (template) |capture| {
|
|
return .{
|
|
.view = rendered_view,
|
|
.content = try self.renderTemplateWithLayout(request, capture, rendered_view, route),
|
|
};
|
|
} else {
|
|
// We are rendering JSON, content is the result of `toJson` on view data.
|
|
return .{ .view = rendered_view, .content = "" };
|
|
}
|
|
} else {
|
|
self.logger.debug("`request.render` was not invoked. Rendering empty content.", .{});
|
|
request.response_data.reset();
|
|
return .{
|
|
.view = .{ .data = request.response_data, .status_code = .no_content },
|
|
.content = "",
|
|
};
|
|
}
|
|
}
|
|
|
|
fn renderTemplateWithLayout(
|
|
self: *Self,
|
|
request: *jetzig.http.Request,
|
|
template: zmpl.manifest.Template,
|
|
view: jetzig.views.View,
|
|
route: *jetzig.views.Route,
|
|
) ![]const u8 {
|
|
if (request.getLayout(route)) |layout_name| {
|
|
// TODO: Allow user to configure layouts directory other than src/app/views/layouts/
|
|
const prefixed_name = try std.mem.concat(self.allocator, u8, &[_][]const u8{ "layouts_", layout_name });
|
|
defer self.allocator.free(prefixed_name);
|
|
|
|
if (zmpl.manifest.find(prefixed_name)) |layout| {
|
|
return try template.renderWithLayout(layout, view.data);
|
|
} else {
|
|
self.logger.debug("Unknown layout: {s}", .{layout_name});
|
|
return try template.render(view.data);
|
|
}
|
|
} else return try template.render(view.data);
|
|
}
|
|
|
|
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,
|
|
else => false,
|
|
};
|
|
}
|
|
|
|
fn isBadHttpError(err: anyerror) bool {
|
|
return switch (err) {
|
|
error.JetzigParseHeadError,
|
|
error.UnknownHttpMethod,
|
|
error.HttpHeadersInvalid,
|
|
error.HttpHeaderContinuationsUnsupported,
|
|
error.HttpTransferEncodingUnsupported,
|
|
error.HttpConnectionHeaderUnsupported,
|
|
error.InvalidContentLength,
|
|
error.CompressionUnsupported,
|
|
error.MissingFinalNewline,
|
|
error.HttpConnectionClosing,
|
|
error.ConnectionResetByPeer,
|
|
=> true,
|
|
else => false,
|
|
};
|
|
}
|
|
|
|
fn renderInternalServerError(self: *Self, request: *jetzig.http.Request, err: anyerror) !RenderedView {
|
|
request.response_data.reset();
|
|
|
|
var object = try request.response_data.object();
|
|
try object.put("error", request.response_data.string(@errorName(err)));
|
|
|
|
const stack = @errorReturnTrace();
|
|
if (stack) |capture| try self.logStackTrace(capture, request, object);
|
|
|
|
return .{
|
|
.view = jetzig.views.View{ .data = request.response_data, .status_code = .internal_server_error },
|
|
.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,
|
|
request: *jetzig.http.Request,
|
|
object: *jetzig.data.Value,
|
|
) !void {
|
|
_ = self;
|
|
std.debug.print("\nStack Trace:\n{}", .{stack});
|
|
var array = std.ArrayList(u8).init(request.allocator);
|
|
const writer = array.writer();
|
|
try stack.format("", .{}, writer);
|
|
// TODO: Generate an array of objects with stack trace in useful data structure instead of
|
|
// dumping the whole formatted backtrace as a JSON string:
|
|
try object.put("backtrace", request.response_data.string(array.items));
|
|
}
|
|
|
|
fn requestLogMessage(self: *Self, request: *jetzig.http.Request) ![]const u8 {
|
|
const status: jetzig.http.status_codes.TaggedStatusCode = switch (request.response.status_code) {
|
|
inline else => |status_code| @unionInit(
|
|
jetzig.http.status_codes.TaggedStatusCode,
|
|
@tagName(status_code),
|
|
.{},
|
|
),
|
|
};
|
|
|
|
const formatted_duration = try jetzig.colors.duration(self.allocator, self.duration());
|
|
defer self.allocator.free(formatted_duration);
|
|
|
|
return try std.fmt.allocPrint(self.allocator, "[{s}/{s}/{s}] {s}", .{
|
|
formatted_duration,
|
|
request.fmtMethod(),
|
|
status.format(),
|
|
request.path.path,
|
|
});
|
|
}
|
|
|
|
fn duration(self: *Self) i64 {
|
|
return @intCast(std.time.nanoTimestamp() - self.start_time);
|
|
}
|
|
|
|
fn matchRoute(self: *Self, request: *jetzig.http.Request, static: bool) !?*jetzig.views.Route {
|
|
for (self.routes) |route| {
|
|
// .index routes always take precedence.
|
|
if (route.static == static and route.action == .index and try request.match(route.*)) return route;
|
|
}
|
|
|
|
for (self.routes) |route| {
|
|
if (route.static == static and try request.match(route.*)) return route;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const StaticResource = struct { content: []const u8, mime_type: []const u8 = "application/octet-stream" };
|
|
|
|
fn matchStaticResource(self: *Self, request: *jetzig.http.Request) !?StaticResource {
|
|
// TODO: Map public and static routes at launch to avoid accessing the file system when
|
|
// matching any route - currently every request causes file system traversal.
|
|
const public_resource = try self.matchPublicContent(request);
|
|
if (public_resource) |resource| return resource;
|
|
|
|
const static_content = try self.matchStaticContent(request);
|
|
if (static_content) |content| return .{
|
|
.content = content,
|
|
.mime_type = switch (request.requestFormat()) {
|
|
.HTML, .UNKNOWN => "text/html",
|
|
.JSON => "application/json",
|
|
},
|
|
};
|
|
|
|
return null;
|
|
}
|
|
|
|
fn matchPublicContent(self: *Self, request: *jetzig.http.Request) !?StaticResource {
|
|
if (request.path.file_path.len <= 1) return null;
|
|
if (request.method != .GET) return null;
|
|
|
|
var iterable_dir = std.fs.cwd().openDir(
|
|
jetzig.config.public_content.path,
|
|
.{ .iterate = true, .no_follow = true },
|
|
) catch |err| {
|
|
switch (err) {
|
|
error.FileNotFound => return null,
|
|
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;
|
|
|
|
if (std.mem.eql(u8, file.path, request.path.file_path[1..])) {
|
|
const content = try iterable_dir.readFileAlloc(
|
|
request.allocator,
|
|
file.path,
|
|
jetzig.config.max_bytes_static_content,
|
|
);
|
|
const extension = std.fs.path.extension(file.path);
|
|
const mime_type = if (self.mime_map.get(extension)) |mime| mime else "application/octet-stream";
|
|
return .{
|
|
.content = content,
|
|
.mime_type = mime_type,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
fn matchStaticContent(self: *Self, request: *jetzig.http.Request) !?[]const u8 {
|
|
var static_dir = std.fs.cwd().openDir("static", .{}) catch |err| {
|
|
switch (err) {
|
|
error.FileNotFound => return null,
|
|
else => return err,
|
|
}
|
|
};
|
|
defer static_dir.close();
|
|
|
|
const matched_route = try self.matchRoute(request, true);
|
|
|
|
if (matched_route) |route| {
|
|
const static_path = try staticPath(request, route.*);
|
|
|
|
if (static_path) |capture| {
|
|
return static_dir.readFileAlloc(
|
|
request.allocator,
|
|
capture,
|
|
jetzig.config.max_bytes_static_content,
|
|
) catch |err| {
|
|
switch (err) {
|
|
error.FileNotFound => return null,
|
|
else => return err,
|
|
}
|
|
};
|
|
} else return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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",
|
|
};
|
|
|
|
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.path.resource_id)) continue;
|
|
},
|
|
// Should be unreachable - this means generated `routes.zig` is incoherent:
|
|
inline else => return error.JetzigRouteError,
|
|
}
|
|
}
|
|
},
|
|
}
|
|
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,
|
|
}
|
|
}
|