Merge pull request #73 from jetzig-framework/custom-routes

Implement custom routes
This commit is contained in:
bobf 2024-05-22 20:58:10 +01:00 committed by GitHub
commit 2ddeccdadb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 164 additions and 36 deletions

View File

@ -0,0 +1,7 @@
const jetzig = @import("jetzig");
pub fn bar(id: []const u8, request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
var root = try data.object();
try root.put("id", data.string(id));
return request.render(.ok);
}

View File

@ -0,0 +1,3 @@
{{jetzig_view}}
{{jetzig_action}}
{{.id}}

View File

@ -169,5 +169,8 @@ pub fn main() !void {
const app = try jetzig.init(allocator);
defer app.deinit();
// Example custom route:
// app.route(.GET, "/custom/:id/foo/bar", @import("app/views/custom/foo.zig"), .bar);
try app.start(routes, .{});
}

View File

@ -191,5 +191,6 @@ pub fn init(allocator: std.mem.Allocator) !App {
return .{
.environment = environment,
.allocator = allocator,
.custom_routes = std.ArrayList(views.Route).init(allocator),
};
}

View File

@ -9,9 +9,10 @@ const App = @This();
environment: jetzig.Environment,
allocator: std.mem.Allocator,
custom_routes: std.ArrayList(jetzig.views.Route),
pub fn deinit(self: App) void {
_ = self;
pub fn deinit(self: *const App) void {
@constCast(self).custom_routes.deinit();
}
// Not used yet, but allows us to add new options to `start()` without breaking
@ -31,30 +32,32 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void {
var routes = std.ArrayList(*jetzig.views.Route).init(self.allocator);
for (routes_module.routes) |const_route| {
var route = try self.allocator.create(jetzig.views.Route);
route.* = .{
var var_route = try self.allocator.create(jetzig.views.Route);
var_route.* = .{
.name = const_route.name,
.action = const_route.action,
.view_name = const_route.view_name,
.uri_path = const_route.uri_path,
.view = const_route.view,
.static_view = const_route.static_view,
.static = const_route.static,
.render = const_route.render,
.renderStatic = const_route.renderStatic,
.layout = const_route.layout,
.template = const_route.template,
.json_params = const_route.json_params,
};
try route.initParams(self.allocator);
try routes.append(route);
try var_route.initParams(self.allocator);
try routes.append(var_route);
}
defer routes.deinit();
defer for (routes.items) |route| {
route.deinitParams();
self.allocator.destroy(route);
defer for (routes.items) |var_route| {
var_route.deinitParams();
self.allocator.destroy(var_route);
};
defer for (self.custom_routes.items) |custom_route| {
self.allocator.free(custom_route.view_name);
self.allocator.free(custom_route.template);
};
var store = try jetzig.kv.Store.init(
@ -105,6 +108,7 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void {
self.allocator,
server_options,
routes.items,
self.custom_routes.items,
&routes_module.jobs,
&routes_module.mailers,
&mime_map,
@ -151,3 +155,41 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void {
}
};
}
pub fn route(
self: *const App,
comptime method: jetzig.http.Request.Method,
comptime path: []const u8,
comptime module: type,
comptime action: std.meta.DeclEnum(module),
) void {
const member = @tagName(action);
const viewFn = @field(module, member);
const module_name = comptime std.mem.trimLeft(u8, @typeName(module), "app.views.");
var template: [module_name.len + 1 + member.len]u8 = undefined;
@memcpy(&template, module_name ++ "/" ++ member);
std.mem.replaceScalar(u8, &template, '.', '/');
var view_name: [module_name.len]u8 = undefined;
@memcpy(&view_name, module_name);
std.mem.replaceScalar(u8, &view_name, '.', '/');
@constCast(self).custom_routes.append(.{
.name = member,
.action = .custom,
.method = method,
.view_name = self.allocator.dupe(u8, &view_name) catch @panic("OOM"),
.uri_path = path,
.view = comptime switch (isIncludeId(path)) {
true => .{ .custom = .{ .with_id = viewFn } },
false => .{ .custom = .{ .without_id = viewFn } },
},
.template = self.allocator.dupe(u8, &template) catch @panic("OOM"),
.json_params = &.{},
}) catch @panic("OOM");
}
inline fn isIncludeId(comptime path: []const u8) bool {
return std.mem.containsAtLeast(u8, path, 1, "/:");
}

View File

@ -7,6 +7,7 @@
/// * Extension (".json", ".html", etc.)
/// * Query (everything after first "?" character)
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
path: []const u8,
base_path: []const u8,
@ -38,6 +39,21 @@ pub fn deinit(self: *Self) void {
_ = self;
}
/// For a given route with a possible `:id` placeholder, return the matching URL segment for that
/// placeholder. e.g. route with path `/foo/:id/bar` and request path `/foo/1234/bar` returns
/// `"1234"`.
pub fn resourceId(self: Self, route: jetzig.views.Route) []const u8 {
var route_uri_path_it = std.mem.splitScalar(u8, route.uri_path, '/');
var base_path_it = std.mem.splitScalar(u8, self.base_path, '/');
while (route_uri_path_it.next()) |route_uri_path_segment| {
const base_path_segment = base_path_it.next() orelse return self.resource_id;
if (std.mem.startsWith(u8, route_uri_path_segment, ":")) return base_path_segment;
}
return self.resource_id;
}
// Extract `"/foo/bar/baz"` from:
// * `"/foo/bar/baz"`
// * `"/foo/bar/baz.html"`

View File

@ -241,7 +241,7 @@ pub fn setLayout(self: *Request, layout: ?[]const u8) void {
/// Derive a layout name from the current request if defined, otherwise from the route (if
/// defined).
pub fn getLayout(self: *Request, route: *jetzig.views.Route) ?[]const u8 {
pub fn getLayout(self: *Request, route: jetzig.views.Route) ?[]const u8 {
if (self.layout_disabled) return null;
if (self.layout) |capture| return capture;
if (route.layout) |capture| return capture;

View File

@ -20,6 +20,7 @@ allocator: std.mem.Allocator,
logger: jetzig.loggers.Logger,
options: ServerOptions,
routes: []*jetzig.views.Route,
custom_routes: []jetzig.views.Route,
job_definitions: []const jetzig.JobDefinition,
mailer_definitions: []const jetzig.MailerDefinition,
mime_map: *jetzig.http.mime.MimeMap,
@ -35,6 +36,7 @@ pub fn init(
allocator: std.mem.Allocator,
options: ServerOptions,
routes: []*jetzig.views.Route,
custom_routes: []jetzig.views.Route,
job_definitions: []const jetzig.JobDefinition,
mailer_definitions: []const jetzig.MailerDefinition,
mime_map: *jetzig.http.mime.MimeMap,
@ -47,6 +49,7 @@ pub fn init(
.logger = options.logger,
.options = options,
.routes = routes,
.custom_routes = custom_routes,
.job_definitions = job_definitions,
.mailer_definitions = mailer_definitions,
.mime_map = mime_map,
@ -163,7 +166,7 @@ fn renderResponse(self: *Server, request: *jetzig.http.Request) !void {
return;
}
const route = try self.matchRoute(request, false);
const route = self.matchCustomRoute(request) orelse try self.matchRoute(request, false);
switch (request.requestFormat()) {
.HTML => try self.renderHTML(request, route),
@ -182,7 +185,7 @@ fn renderStatic(resource: StaticResource, request: *jetzig.http.Request) !void {
fn renderHTML(
self: *Server,
request: *jetzig.http.Request,
route: ?*jetzig.views.Route,
route: ?jetzig.views.Route,
) !void {
if (route) |matched_route| {
if (zmpl.findPrefixed("views", matched_route.template)) |template| {
@ -217,7 +220,7 @@ fn renderHTML(
fn renderJSON(
self: *Server,
request: *jetzig.http.Request,
route: ?*jetzig.views.Route,
route: ?jetzig.views.Route,
) !void {
if (route) |matched_route| {
var rendered = try self.renderView(matched_route, request, null);
@ -254,14 +257,14 @@ pub const RenderedView = struct { view: jetzig.views.View, content: []const u8 }
fn renderView(
self: *Server,
route: *jetzig.views.Route,
route: jetzig.views.Route,
request: *jetzig.http.Request,
template: ?zmpl.Template,
) !RenderedView {
// View functions return a `View` to 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| {
_ = route.render(route, request) catch |err| {
try self.logger.ERROR("Encountered error: {s}", .{@errorName(err)});
if (isUnhandledError(err)) return err;
if (isBadRequest(err)) return try self.renderBadRequest(request);
@ -299,7 +302,7 @@ fn renderTemplateWithLayout(
request: *jetzig.http.Request,
template: zmpl.Template,
view: jetzig.views.View,
route: *jetzig.views.Route,
route: jetzig.views.Route,
) ![]const u8 {
try addTemplateConstants(view, route);
@ -321,9 +324,15 @@ fn renderTemplateWithLayout(
} else return try template.render(view.data);
}
fn addTemplateConstants(view: jetzig.views.View, route: *const jetzig.views.Route) !void {
fn addTemplateConstants(view: jetzig.views.View, route: jetzig.views.Route) !void {
try view.data.addConst("jetzig_view", view.data.string(route.view_name));
try view.data.addConst("jetzig_action", view.data.string(@tagName(route.action)));
const action = switch (route.action) {
.custom => route.name,
else => |tag| @tagName(tag),
};
try view.data.addConst("jetzig_action", view.data.string(action));
}
fn isBadRequest(err: anyerror) bool {
@ -421,7 +430,7 @@ fn renderErrorView(
switch (request.requestFormat()) {
.HTML, .UNKNOWN => {
if (zmpl.findPrefixed("views", route.template)) |template| {
try addTemplateConstants(view, route);
try addTemplateConstants(view, route.*);
return .{ .view = view, .content = try template.render(request.response_data) };
}
},
@ -490,14 +499,22 @@ fn logStackTrace(
try self.logger.ERROR("{s}\n", .{buf.items});
}
fn matchRoute(self: *Server, request: *jetzig.http.Request, static: bool) !?*jetzig.views.Route {
fn matchCustomRoute(self: Server, request: *const jetzig.http.Request) ?jetzig.views.Route {
for (self.custom_routes) |custom_route| {
if (custom_route.match(request)) return custom_route;
}
return null;
}
fn matchRoute(self: *Server, 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;
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;
if (route.static == static and try request.match(route.*)) return route.*;
}
return null;
@ -574,7 +591,7 @@ fn matchStaticContent(self: *Server, request: *jetzig.http.Request) !?[]const u8
const matched_route = try self.matchRoute(request, true);
if (matched_route) |route| {
const static_path = try staticPath(request, route.*);
const static_path = try staticPath(request, route);
if (static_path) |capture| {
return static_dir.readFileAlloc(

View File

@ -1,2 +1,3 @@
pub const Route = @import("views/Route.zig");
pub const View = @import("views/View.zig");
pub const CustomRoute = @import("views/CustomRoute.zig");

View File

@ -0,0 +1,8 @@
const jetzig = @import("../../jetzig.zig");
method: jetzig.http.Request.Method,
path: []const u8,
view: union(enum) {
with_id: *const fn (id: []const u8, *jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View,
without_id: *const fn (*jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View,
},

View File

@ -4,12 +4,12 @@ const jetzig = @import("../../jetzig.zig");
const Route = @This();
pub const Action = enum { index, get, post, put, patch, delete };
pub const Action = enum { index, get, post, put, patch, delete, custom };
pub const RenderFn = *const fn (Route, *jetzig.http.Request) anyerror!jetzig.views.View;
pub const RenderStaticFn = *const fn (Route, *jetzig.http.StaticRequest) anyerror!jetzig.views.View;
const ViewWithoutId = *const fn (*jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
const ViewWithId = *const fn (id: []const u8, *jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
pub const ViewWithoutId = *const fn (*jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
pub const ViewWithId = *const fn (id: []const u8, *jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
const StaticViewWithoutId = *const fn (*jetzig.http.StaticRequest, *jetzig.data.Data) anyerror!jetzig.views.View;
const StaticViewWithId = *const fn (id: []const u8, *jetzig.http.StaticRequest, *jetzig.data.Data) anyerror!jetzig.views.View;
@ -20,6 +20,7 @@ pub const DynamicViewType = union(Action) {
put: ViewWithId,
patch: ViewWithId,
delete: ViewWithId,
custom: CustomViewType,
};
pub const StaticViewType = union(Action) {
@ -29,23 +30,30 @@ pub const StaticViewType = union(Action) {
put: StaticViewWithId,
patch: StaticViewWithId,
delete: StaticViewWithId,
custom: void,
};
pub const CustomViewType = union(enum) {
with_id: ViewWithId,
without_id: ViewWithoutId,
};
pub const ViewType = union(enum) {
static: StaticViewType,
dynamic: DynamicViewType,
custom: CustomViewType,
};
name: []const u8,
action: Action,
method: jetzig.http.Request.Method = undefined, // Used by custom routes only
view_name: []const u8,
uri_path: []const u8,
view: ?ViewType = null,
static_view: ?StaticViewType = null,
static: bool,
view: ViewType,
render: RenderFn = renderFn,
renderStatic: RenderStaticFn = renderStaticFn,
layout: ?[]const u8,
static: bool = false,
layout: ?[]const u8 = null,
template: []const u8,
json_params: []const []const u8,
params: std.ArrayList(*jetzig.data.Data) = undefined,
@ -70,34 +78,56 @@ pub fn deinitParams(self: *const Route) void {
self.params.deinit();
}
/// Match a **custom** route to a request - not used by auto-generated route matching.
pub fn match(self: Route, request: *const jetzig.http.Request) bool {
if (self.method != request.method) return false;
var request_path_it = std.mem.splitScalar(u8, request.path.base_path, '/');
var uri_path_it = std.mem.splitScalar(u8, self.uri_path, '/');
while (uri_path_it.next()) |expected_segment| {
const actual_segment = request_path_it.next() orelse return false;
if (std.mem.startsWith(u8, expected_segment, ":")) continue;
if (!std.mem.eql(u8, expected_segment, actual_segment)) return false;
}
return true;
}
fn renderFn(self: Route, request: *jetzig.http.Request) anyerror!jetzig.views.View {
switch (self.view.?) {
switch (self.view) {
.dynamic => {},
.custom => |view_type| switch (view_type) {
.with_id => |view| return try view(request.path.resourceId(self), request, request.response_data),
.without_id => |view| return try view(request, request.response_data),
},
// We only end up here if a static route is defined but its output is not found in the
// file system (e.g. if it was manually deleted after build). This should be avoidable by
// including the content as an artifact in the compiled executable (TODO):
.static => return error.JetzigMissingStaticContent,
}
switch (self.view.?.dynamic) {
switch (self.view.dynamic) {
.index => |view| return try view(request, request.response_data),
.get => |view| return try view(request.path.resource_id, request, request.response_data),
.post => |view| return try view(request, request.response_data),
.patch => |view| return try view(request.path.resource_id, request, request.response_data),
.put => |view| return try view(request.path.resource_id, request, request.response_data),
.delete => |view| return try view(request.path.resource_id, request, request.response_data),
.custom => unreachable,
}
}
fn renderStaticFn(self: Route, request: *jetzig.http.StaticRequest) anyerror!jetzig.views.View {
request.response_data.* = jetzig.data.Data.init(request.allocator);
switch (self.view.?.static) {
switch (self.view.static) {
.index => |view| return try view(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(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),
.custom => unreachable,
}
}