Refactor Server into generic type

We need to have routes available within the server if we are going to do
any kind of dynamic dispatch for Channel Actions.
This commit is contained in:
Bob Farrell 2025-04-23 12:58:41 +01:00
parent d3b3ae63cf
commit 8c2d6806b5
16 changed files with 1059 additions and 993 deletions

View File

@ -16,7 +16,7 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
}
pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
try request.server.logger.INFO("id: {s}", .{id});
try request.logger.INFO("id: {s}", .{id});
return request.render(.ok);
}

View File

@ -1,37 +1,7 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn post(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
return request.render(.created);
}
pub fn put(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn patch(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
return request.render(.ok);
}
pub fn delete(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
_ = data;
_ = id;
pub fn index(request: *jetzig.Request) !jetzig.View {
return request.render(.ok);
}
@ -90,6 +60,13 @@ pub const Channel = struct {
try message.channel.sync();
}
pub const Actions = struct {
pub fn reset(channel: jetzig.channels.Channel) !void {
_ = channel;
std.debug.print("here\n", .{});
}
};
fn resetGame(channel: jetzig.channels.Channel) !void {
try channel.put("winner", null);
var cells = try channel.put("cells", .array);
@ -172,51 +149,3 @@ const Game = struct {
}
}
};
test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/websockets", .{});
try response.expectStatus(.ok);
}
test "get" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.GET, "/websockets/example-id", .{});
try response.expectStatus(.ok);
}
test "post" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.POST, "/websockets", .{});
try response.expectStatus(.created);
}
test "put" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.PUT, "/websockets/example-id", .{});
try response.expectStatus(.ok);
}
test "patch" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.PATCH, "/websockets/example-id", .{});
try response.expectStatus(.ok);
}
test "delete" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));
defer app.deinit();
const response = try app.request(.DELETE, "/websockets/example-id", .{});
try response.expectStatus(.ok);
}

View File

@ -57,15 +57,15 @@
<div id="party-container"></div>
<div class="board" id="board">
<div class="cell" id="tic-tac-toe-cell-0" data-cell="0"></div>
<div class="cell" id="tic-tac-toe-cell-1" data-cell="1"></div>
<div class="cell" id="tic-tac-toe-cell-2" data-cell="2"></div>
<div class="cell" id="tic-tac-toe-cell-3" data-cell="3"></div>
<div class="cell" id="tic-tac-toe-cell-4" data-cell="4"></div>
<div class="cell" id="tic-tac-toe-cell-5" data-cell="5"></div>
<div class="cell" id="tic-tac-toe-cell-6" data-cell="6"></div>
<div class="cell" id="tic-tac-toe-cell-7" data-cell="7"></div>
<div class="cell" id="tic-tac-toe-cell-8" data-cell="8"></div>
<div class="cell" jetzig-connect="$.cells.0" id="tic-tac-toe-cell-0" data-cell="0"></div>
<div class="cell" jetzig-connect="$.cells.1" id="tic-tac-toe-cell-1" data-cell="1"></div>
<div class="cell" jetzig-connect="$.cells.2" id="tic-tac-toe-cell-2" data-cell="2"></div>
<div class="cell" jetzig-connect="$.cells.3" id="tic-tac-toe-cell-3" data-cell="3"></div>
<div class="cell" jetzig-connect="$.cells.4" id="tic-tac-toe-cell-4" data-cell="4"></div>
<div class="cell" jetzig-connect="$.cells.5" id="tic-tac-toe-cell-5" data-cell="5"></div>
<div class="cell" jetzig-connect="$.cells.6" id="tic-tac-toe-cell-6" data-cell="6"></div>
<div class="cell" jetzig-connect="$.cells.7" id="tic-tac-toe-cell-7" data-cell="7"></div>
<div class="cell" jetzig-connect="$.cells.8" id="tic-tac-toe-cell-8" data-cell="8"></div>
</div>
<button id="reset-button">Reset Game</button>
@ -86,7 +86,7 @@
Object.entries(state.cells).forEach(([cell, state]) => {
const element = document.querySelector(`#tic-tac-toe-cell-${cell}`);
element.innerHTML = { player: "&#9992;", cpu: "&#129422;" }[state] || "";
element.innerHTML = { player: "&#9992;&#65039;", cpu: "&#129422;" }[state] || "";
});
});

View File

@ -12,7 +12,7 @@ buffer: std.ArrayList(u8),
dynamic_routes: std.ArrayList(Function),
static_routes: std.ArrayList(Function),
channel_routes: std.ArrayList([]const u8),
module_paths: std.ArrayList([]const u8),
module_paths: std.StringHashMap(void),
data: *jetzig.data.Data,
const receive_message = "receiveMessage";
@ -124,7 +124,7 @@ pub fn init(
.static_routes = std.ArrayList(Function).init(allocator),
.dynamic_routes = std.ArrayList(Function).init(allocator),
.channel_routes = std.ArrayList([]const u8).init(allocator),
.module_paths = std.ArrayList([]const u8).init(allocator),
.module_paths = std.StringHashMap(void).init(allocator),
.data = data,
};
}
@ -186,16 +186,29 @@ pub fn generateRoutes(self: *Routes) ![]const u8 {
\\
);
try writer.writeAll(
\\
\\pub const View = struct { name: []const u8, module: type };
\\pub const views = [_]View{
\\
);
try self.writeViewsArray(writer);
try writer.writeAll(
\\};
\\
);
try writer.writeAll(
\\test {
\\
);
for (self.module_paths.items) |module_path| {
var it = self.module_paths.keyIterator();
while (it.next()) |module_path| {
try writer.print(
\\ _ = @import("{s}");
\\
, .{module_path});
, .{module_path.*});
}
try writer.writeAll(
@ -362,7 +375,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
unreachable;
std.mem.replaceScalar(u8, module_path, '\\', '/');
try self.module_paths.append(try self.allocator.dupe(u8, module_path));
try self.module_paths.put(try self.allocator.dupe(u8, module_path), {});
var buf: [32]u8 = undefined;
const id = jetzig.util.generateVariableName(&buf);
@ -879,3 +892,15 @@ fn writeJobs(self: Routes, writer: anytype) !void {
std.debug.print("[jetzig] Imported {} job(s)\n", .{count});
}
fn writeViewsArray(self: Routes, writer: anytype) !void {
var it = self.module_paths.keyIterator();
while (it.next()) |path| {
try writer.print(
\\.{{ .name = "{s}", .module = @import("{s}") }},
\\
,
.{ chompExtension(try self.relativePathFrom(.views, path.*, .posix)), path.* },
);
}
}

View File

@ -11,7 +11,7 @@ env: jetzig.Environment,
allocator: std.mem.Allocator,
custom_routes: std.ArrayList(jetzig.views.Route),
initHook: ?*const fn (*App) anyerror!void,
server: *jetzig.http.Server = undefined,
server: *anyopaque = undefined,
pub fn deinit(self: *const App) void {
@constCast(self).custom_routes.deinit();
@ -28,6 +28,10 @@ const AppOptions = struct {
pub fn start(self: *const App, routes_module: type, options: AppOptions) !void {
defer self.env.deinit();
const action_router = comptime jetzig.channels.ActionRouter.initComptime(routes_module);
_ = action_router;
// inline for (action_router.actions) |action| std.debug.print("{s}\n", .{@typeName(action.params[0].type.?)});
if (self.initHook) |hook| try hook(@constCast(self));
var mime_map = jetzig.http.mime.MimeMap.init(self.allocator);
@ -93,7 +97,7 @@ pub fn start(self: *const App, routes_module: type, options: AppOptions) !void {
std.process.exit(0);
}
var server = jetzig.http.Server.init(
var server = jetzig.http.Server.RoutedServer(routes_module).init(
self.allocator,
self.env,
routes,

View File

@ -1,3 +1,4 @@
pub const Channel = @import("channels/Channel.zig");
pub const Message = @import("channels/Message.zig");
pub const Route = @import("channels/Route.zig");
pub const ActionRouter = @import("channels/ActionRouter.zig");

View File

@ -0,0 +1,45 @@
const std = @import("std");
pub fn initComptime(T: type) ActionRouter {
comptime {
var len: usize = 0;
for (T.views) |view| {
if (!@hasDecl(view.module, "Channel")) continue;
if (!@hasDecl(view.module.Channel, "Actions")) continue;
const actions = view.module.Channel.Actions;
for (std.meta.declarations(actions)) |_| {
len += 1;
}
}
var actions: [len]Action = undefined;
var index: usize = 0;
for (T.views) |view| {
if (!@hasDecl(view.module, "Channel")) continue;
if (!@hasDecl(view.module.Channel, "Actions")) continue;
const channel_actions = view.module.Channel.Actions;
for (std.meta.declarations(channel_actions)) |decl| {
actions[index] = .{
.view = view.name,
.name = decl.name,
.params = &.{}, //@typeInfo(@TypeOf(@field(view.module.Channel.Actions, decl.name))).@"fn".params,
};
index += 1;
}
}
const result = actions;
return .{ .actions = &result };
}
}
pub const Action = struct {
view: []const u8,
name: []const u8,
params: []const u8,
};
pub const ActionRouter = struct {
actions: []const Action,
};

View File

@ -30,7 +30,12 @@ pub fn value(message: Message) !*jetzig.data.Value {
test "message with payload" {
const message = Message.init(
std.testing.allocator,
Channel{ .websocket = undefined, .state = undefined },
Channel{
.websocket = undefined,
.state = undefined,
.allocator = undefined,
.data = undefined,
},
"foo",
);
try std.testing.expectEqualStrings(message.payload, "foo");

View File

@ -39,9 +39,10 @@ pub fn repo(allocator: std.mem.Allocator, app: anytype) !Repo {
}
fn eventCallback(event: jetzig.jetquery.events.Event, app: anytype) !void {
try app.server.logger.logSql(event);
var server: *jetzig.http.Server.RoutedServer(@import("root").routes) = @ptrCast(@alignCast(app.server));
try server.logger.logSql(event);
if (event.err) |err| {
try app.server.logger.ERROR("[database] {?s}", .{err.message});
try server.logger.ERROR("[database] {?s}", .{err.message});
}
}

View File

@ -27,7 +27,6 @@ path: jetzig.http.Path,
method: Method,
headers: jetzig.http.Headers,
host: []const u8,
server: *jetzig.http.Server,
httpz_request: *httpz.Request,
httpz_response: *httpz.Response,
response: *jetzig.http.Response,
@ -53,8 +52,14 @@ rendered_view: ?jetzig.views.View = null,
start_time: i128,
store: RequestStore(jetzig.kv.Store.GeneralStore),
cache: RequestStore(jetzig.kv.Store.CacheStore),
job_queue: RequestStore(jetzig.kv.Store.JobQueueStore),
job_definitions: []const jetzig.JobDefinition,
mailer_definitions: []const jetzig.MailerDefinition,
repo: *jetzig.database.Repo,
global: *jetzig.Global,
env: jetzig.Environment,
routes: []const *const jetzig.views.Route,
logger: jetzig.loggers.Logger,
/// Wrapper for KV store that uses the request's arena allocator for fetching values.
pub fn RequestStore(T: type) type {
@ -123,12 +128,20 @@ pub fn RequestStore(T: type) type {
pub fn init(
allocator: std.mem.Allocator,
server: *jetzig.http.Server,
start_time: i128,
httpz_request: *httpz.Request,
httpz_response: *httpz.Response,
response: *jetzig.http.Response,
repo: *jetzig.database.Repo,
env: jetzig.Environment,
routes: []const *const jetzig.views.Route,
logger: jetzig.loggers.Logger,
store: *jetzig.kv.Store.GeneralStore,
cache: *jetzig.kv.Store.CacheStore,
job_queue: *jetzig.kv.Store.JobQueueStore,
job_definitions: []const jetzig.JobDefinition,
mailer_definitions: []const jetzig.MailerDefinition,
global: *anyopaque,
) !Request {
const path = jetzig.http.Path.init(httpz_request.url.raw);
@ -156,19 +169,24 @@ pub fn init(
.method = method,
.headers = headers,
.host = host,
.server = server,
.response = response,
.response_data = response_data,
.httpz_request = httpz_request,
.httpz_response = httpz_response,
.start_time = start_time,
.store = .{ .store = server.store, .allocator = allocator },
.cache = .{ .store = server.cache, .allocator = allocator },
.store = .{ .store = store, .allocator = allocator },
.cache = .{ .store = cache, .allocator = allocator },
.job_queue = .{ .store = job_queue, .allocator = allocator },
.job_definitions = job_definitions,
.mailer_definitions = mailer_definitions,
.env = env,
.routes = routes,
.logger = logger,
.repo = repo,
.global = if (@hasField(jetzig.Global, "__jetzig_default"))
undefined
else
@ptrCast(@alignCast(server.global)),
@ptrCast(@alignCast(global)),
};
}
@ -501,19 +519,19 @@ pub fn cookies(self: *Request) !*jetzig.http.Cookies {
/// `jetzig.http.Session`.
pub fn session(self: *Request) !*jetzig.http.Session {
if (self._session) |capture| return capture;
const cookie_name = self.server.env.vars.get("JETZIG_SESSION_COOKIE") orelse
const cookie_name = self.env.vars.get("JETZIG_SESSION_COOKIE") orelse
jetzig.http.Session.default_cookie_name;
const local_session = try self.allocator.create(jetzig.http.Session);
local_session.* = jetzig.http.Session.init(
self.allocator,
try self.cookies(),
self.server.env.secret,
self.env.secret,
.{ .cookie_name = cookie_name },
);
local_session.parse() catch |err| {
switch (err) {
error.JetzigInvalidSessionCookie => {
try self.server.logger.DEBUG("Invalid session cookie detected. Resetting session.", .{});
try self.logger.DEBUG("Invalid session cookie detected. Resetting session.", .{});
try local_session.reset();
},
else => return err,
@ -565,11 +583,11 @@ pub fn job(self: *Request, job_name: []const u8) !*jetzig.Job {
const background_job = try self.allocator.create(jetzig.Job);
background_job.* = jetzig.Job.init(
self.allocator,
self.server.store,
self.server.job_queue,
self.server.cache,
self.server.logger,
self.server.job_definitions,
self.store.store,
self.job_queue.store,
self.cache.store,
self.logger,
self.job_definitions,
job_name,
);
return background_job;
@ -611,14 +629,14 @@ const RequestMail = struct {
self.request.allocator,
mail_job.params,
jetzig.jobs.JobEnv{
.vars = self.request.server.env.vars,
.environment = self.request.server.env.environment,
.logger = self.request.server.logger,
.routes = self.request.server.routes,
.mailers = self.request.server.mailer_definitions,
.jobs = self.request.server.job_definitions,
.store = self.request.server.store,
.cache = self.request.server.cache,
.vars = self.request.env.vars,
.environment = self.request.env.environment,
.logger = self.request.logger,
.routes = self.request.routes,
.mailers = self.request.mailer_definitions,
.jobs = self.request.job_definitions,
.store = self.request.store.store,
.cache = self.request.cache.store,
.mutex = undefined,
.repo = self.request.repo,
},

View File

@ -6,6 +6,11 @@ const zmpl = @import("zmpl");
const zmd = @import("zmd");
const httpz = @import("httpz");
pub const RenderedView = struct { view: jetzig.views.View, content: []const u8 };
pub fn RoutedServer(Routes: type) type {
_ = Routes;
return struct {
allocator: std.mem.Allocator,
logger: jetzig.loggers.Logger,
env: jetzig.Environment,
@ -150,12 +155,20 @@ pub fn processNextRequest(
var response = try jetzig.http.Response.init(httpz_response.arena, httpz_response);
var request = try jetzig.http.Request.init(
httpz_response.arena,
self,
start_time,
httpz_request,
httpz_response,
&response,
&repo,
self.env,
self.routes,
self.logger,
self.store,
self.cache,
self.job_queue,
self.job_definitions,
self.mailer_definitions,
self.global,
);
if (try self.upgradeWebsocket(httpz_request, httpz_response, &request)) {
@ -184,6 +197,7 @@ pub fn processNextRequest(
/// Attempt to match a channel name to a view with a Channel implementation.
pub fn matchChannelRoute(self: *const Server, channel_name: []const u8) ?jetzig.channels.Route {
// TODO: Detect root path correctly.
return self.channel_routes.get(channel_name);
}
@ -208,7 +222,7 @@ fn upgradeWebsocket(
.allocator = self.allocator,
.route = route,
.session_id = session_id,
.server = self,
.channels = self.channels,
},
);
}
@ -383,8 +397,6 @@ fn renderMarkdown(self: *Server, request: *jetzig.http.Request) !?RenderedView {
}
}
pub const RenderedView = struct { view: jetzig.views.View, content: []const u8 };
fn renderView(
self: *Server,
route: jetzig.views.Route,
@ -905,3 +917,5 @@ fn matchStaticOutput(
else
true; // We reached a params filter (possibly the default catch-all) with no params set.
}
};
}

View File

@ -8,14 +8,14 @@ pub const Context = struct {
allocator: std.mem.Allocator,
route: jetzig.channels.Route,
session_id: []const u8,
server: *const jetzig.http.Server,
channels: *jetzig.kv.Store.ChannelStore,
};
const Websocket = @This();
allocator: std.mem.Allocator,
connection: *httpz.websocket.Conn,
server: *const jetzig.http.Server,
channels: *jetzig.kv.Store.ChannelStore,
route: jetzig.channels.Route,
data: *jetzig.Data,
session_id: []const u8,
@ -29,7 +29,7 @@ pub fn init(connection: *httpz.websocket.Conn, context: Context) !Websocket {
.connection = connection,
.route = context.route,
.session_id = context.session_id,
.server = context.server,
.channels = context.channels,
.data = data,
};
}
@ -70,15 +70,15 @@ pub fn syncState(websocket: *Websocket, channel: jetzig.channels.Channel) !void
const writer = write_buffer.writer();
// TODO: Make this really fast.
try websocket.server.channels.put(websocket.session_id, channel.state);
try websocket.channels.put(websocket.session_id, channel.state);
try writer.print("__jetzig_channel_state__:{s}", .{try websocket.data.toJson()});
try write_buffer.flush();
}
pub fn getState(websocket: *Websocket) !*jetzig.data.Value {
return try websocket.server.channels.get(websocket.data, websocket.session_id) orelse blk: {
return try websocket.channels.get(websocket.data, websocket.session_id) orelse blk: {
const root = try websocket.data.root(.object);
try websocket.server.channels.put(websocket.session_id, root);
break :blk try websocket.server.channels.get(websocket.data, websocket.session_id) orelse error.JetzigInvalidChannel;
try websocket.channels.put(websocket.session_id, root);
break :blk try websocket.channels.get(websocket.data, websocket.session_id) orelse error.JetzigInvalidChannel;
};
}

View File

@ -48,7 +48,7 @@ pub fn Type(comptime name: MiddlewareEnum()) type {
}
}
pub fn afterLaunch(server: *jetzig.http.Server) !void {
pub fn afterLaunch(server: *jetzig.http.Server.RoutedServer(@import("root").routes)) !void {
inline for (middlewares) |middleware| {
if (comptime @hasDecl(middleware, "afterLaunch")) {
try middleware.afterLaunch(server);

View File

@ -29,7 +29,7 @@ pub fn beforeRender(request: *jetzig.http.Request, route: jetzig.views.Route) !v
fn logFailure(request: *jetzig.http.Request) !void {
_ = request.fail(.forbidden);
try request.server.logger.DEBUG("Anti-CSRF token validation failed. Request aborted.", .{});
try request.logger.DEBUG("Anti-CSRF token validation failed. Request aborted.", .{});
}
fn verifyCsrfToken(request: *jetzig.http.Request) !void {

View File

@ -15,7 +15,7 @@ channels: *MemoryStore,
job_queue: *MemoryStore,
multipart_boundary: ?[]const u8 = null,
logger: jetzig.loggers.Logger,
server: Server,
server: *jetzig.http.Server.RoutedServer(@import("root").routes),
repo: *jetzig.database.Repo,
cookies: *jetzig.http.Cookies,
session: *jetzig.http.Session,
@ -58,6 +58,28 @@ pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
const session = try alloc.create(jetzig.http.Session);
session.* = jetzig.http.Session.init(alloc, cookies, jetzig.testing.secret, .{});
const server = try alloc.create(jetzig.http.Server.RoutedServer(@import("root").routes));
// This server is only used by database logging - we create a more lifelike server when we
// process the actual test request.
server.* = .{
.logger = logger,
.allocator = undefined,
.env = undefined,
.routes = undefined,
.channel_routes = undefined,
.custom_routes = undefined,
.job_definitions = undefined,
.mailer_definitions = undefined,
.mime_map = undefined,
.initialized = undefined,
.store = undefined,
.job_queue = undefined,
.cache = undefined,
.channels = undefined,
.repo = undefined,
.global = undefined,
};
app.* = App{
.arena = arena,
.allocator = allocator,
@ -67,7 +89,7 @@ pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
.channels = try createStore(arena.allocator(), logger, .channels),
.job_queue = try createStore(arena.allocator(), logger, .jobs),
.logger = logger,
.server = .{ .logger = logger },
.server = server,
.repo = repo,
.cookies = cookies,
.session = session,
@ -133,7 +155,7 @@ pub fn request(
.env_map = std.process.EnvMap.init(allocator),
.env_file = null,
};
var server = jetzig.http.Server{
var server = jetzig.http.Server.RoutedServer(@import("root").routes){
.allocator = allocator,
.logger = self.logger,
.env = .{
@ -150,6 +172,7 @@ pub fn request(
.secret = jetzig.testing.secret,
},
.routes = routes,
.channel_routes = std.StaticStringMap(jetzig.channels.Route).initComptime(.{}),
.custom_routes = &.{},
.mailer_definitions = &.{},
.job_definitions = &.{},

View File

@ -8,6 +8,7 @@ pub const std_options = std.Options{
};
pub const jetzig_options = @import("main").jetzig_options;
pub const routes = @import("main").routes;
pub fn log(
comptime message_level: std.log.Level,