Gates / frontend (push) Successful in 1m34s
Gates / test (push) Successful in 2m3s
Gates / test-aarch64 (push) Failing after 3h13m33s
Gates / package (push) Successful in 5m20s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 6h30m45s
318 lines
13 KiB
Zig
318 lines
13 KiB
Zig
//! Route matching and dispatch.
|
|
//!
|
|
//! The table is a flat array of literal patterns with at most one `{id}`
|
|
//! capture, matched segment by segment. A LAN admin API has a few dozen routes
|
|
//! and one request per user action, so a linear scan is the whole algorithm —
|
|
//! a trie would buy nothing and cost a build step.
|
|
//!
|
|
//! Dispatch is where the cross-cutting policies live, in the order a request
|
|
//! meets them: match, rate limit, authenticate, handle. Matching comes first
|
|
//! because both the limiter exemption (ruling 19: `/metrics` and `/api/health`
|
|
//! must never see a 429) and the auth exemption (ruling 18) are properties of
|
|
//! the matched route, not of the raw path.
|
|
|
|
const std = @import("std");
|
|
const http = std.http;
|
|
|
|
const http_util = @import("http_util.zig");
|
|
const routes_table = @import("routes.zig");
|
|
const server = @import("server.zig");
|
|
|
|
/// Every route the server serves. Ruling 23 reads this to prove the OpenAPI
|
|
/// document and the contract test cover the whole surface.
|
|
pub const routes: []const RouteInfo = routes_table.table;
|
|
|
|
pub const HandlerFn = *const fn (
|
|
state: *server.WebState,
|
|
io: std.Io,
|
|
request: *http_util.Request,
|
|
) http_util.HandlerError!void;
|
|
|
|
/// Whether a route needs a session cookie when authentication is enabled.
|
|
/// Ruling 18 lists the open ones: health, version, metrics, the OpenAPI
|
|
/// document, login, and the static assets.
|
|
pub const Auth = enum { open, session };
|
|
|
|
/// Whether a route spends an API rate-limit token. Ruling 19 exempts the two
|
|
/// monitoring endpoints so a Prometheus scrape can never be throttled.
|
|
pub const RateLimit = enum { counted, exempt };
|
|
|
|
/// What a route does to the configuration, and therefore whether file
|
|
/// authority may allow it (milestone-20 ruling 7). `config_write` changes the
|
|
/// declarative state the managed file owns; `runtime_action` changes runtime
|
|
/// state the file never declares; `read` changes nothing.
|
|
pub const Policy = enum { read, config_write, runtime_action };
|
|
|
|
pub const RouteInfo = struct {
|
|
method: http.Method,
|
|
/// Segments separated by `/`, with at most one `{id}` capture, which must
|
|
/// be a positive integer row id.
|
|
pattern: []const u8,
|
|
auth: Auth,
|
|
/// No default: a new route states its class or does not compile.
|
|
policy: Policy,
|
|
handler: HandlerFn,
|
|
rate_limit: RateLimit = .counted,
|
|
};
|
|
|
|
pub const Match = union(enum) {
|
|
found: Found,
|
|
/// The path matches a route registered under a different method.
|
|
method_not_allowed,
|
|
not_found,
|
|
|
|
pub const Found = struct {
|
|
route: *const RouteInfo,
|
|
id: ?i64,
|
|
};
|
|
};
|
|
|
|
/// Matches `segments` (already decoded) against `table`.
|
|
pub fn match(
|
|
table: []const RouteInfo,
|
|
method: http.Method,
|
|
segments: []const []const u8,
|
|
) Match {
|
|
var path_exists = false;
|
|
for (table) |*route| {
|
|
const id = matchPattern(route.pattern, segments) orelse continue;
|
|
if (route.method != method) {
|
|
path_exists = true;
|
|
continue;
|
|
}
|
|
return .{ .found = .{ .route = route, .id = id } };
|
|
}
|
|
return if (path_exists) .method_not_allowed else .not_found;
|
|
}
|
|
|
|
/// Returns the `{id}` capture, or a null capture for a pattern without one.
|
|
/// The outer optional is "did the pattern match at all".
|
|
fn matchPattern(pattern: []const u8, segments: []const []const u8) ??i64 {
|
|
var id: ?i64 = null;
|
|
var index: usize = 0;
|
|
var rest = pattern;
|
|
while (rest.len != 0) {
|
|
const end = std.mem.findScalar(u8, rest, '/') orelse rest.len;
|
|
const part = rest[0..end];
|
|
rest = if (end == rest.len) rest[end..] else rest[end + 1 ..];
|
|
if (part.len == 0) continue;
|
|
if (index == segments.len) return null;
|
|
const segment = segments[index];
|
|
index += 1;
|
|
if (std.mem.eql(u8, part, "{id}")) {
|
|
id = std.fmt.parseInt(i64, segment, 10) catch return null;
|
|
// A row id is a positive integer; `-1` must 404, not reach SQL.
|
|
if (id.? <= 0) return null;
|
|
continue;
|
|
}
|
|
if (!std.mem.eql(u8, part, segment)) return null;
|
|
}
|
|
if (index != segments.len) return null;
|
|
return id;
|
|
}
|
|
|
|
/// Fills `buf` with the `Allow` header value for a path that matched under
|
|
/// other methods. The returned slice borrows `buf`.
|
|
fn formatAllow(table: []const RouteInfo, segments: []const []const u8, buf: []u8) []const u8 {
|
|
var writer: std.Io.Writer = .fixed(buf);
|
|
var first = true;
|
|
for (table) |*route| {
|
|
if (matchPattern(route.pattern, segments) == null) continue;
|
|
if (!first) writer.writeAll(", ") catch break;
|
|
writer.writeAll(@tagName(route.method)) catch break;
|
|
first = false;
|
|
}
|
|
return writer.buffered();
|
|
}
|
|
|
|
/// Runs one request to completion: match, limit, authenticate, handle.
|
|
///
|
|
/// Every exit responds. A `WriteFailed` on the way out is the client
|
|
/// disconnecting (ruling 28) and ends the connection.
|
|
pub fn dispatch(
|
|
state: *server.WebState,
|
|
io: std.Io,
|
|
request: *http_util.Request,
|
|
) http_util.HandlerError!void {
|
|
const segments = request.path.segments();
|
|
const found = switch (match(state.routes, request.method, segments)) {
|
|
.found => |f| f,
|
|
.method_not_allowed => {
|
|
var buf: [64]u8 = undefined;
|
|
const allow = formatAllow(state.routes, segments, &buf);
|
|
return respondMethodNotAllowed(request, allow);
|
|
},
|
|
// Ruling 24: an unknown non-`/api` path is the SPA's, and the static
|
|
// handler answers it with index.html so client-side routing works. An
|
|
// unknown `/api` path is a real 404 and must stay JSON.
|
|
.not_found => {
|
|
if (state.fallback) |fallback| {
|
|
if (!std.mem.eql(u8, request.firstSegment(), "api")) {
|
|
return fallback(state, io, request);
|
|
}
|
|
}
|
|
return http_util.respondError(request, .not_found, "not found");
|
|
},
|
|
};
|
|
|
|
request.id = found.id;
|
|
|
|
if (found.route.rate_limit == .counted) {
|
|
const verdict = state.check_limit(state, io, request);
|
|
if (!verdict.allowed) return respondRateLimited(request, verdict.retry_after_s);
|
|
}
|
|
|
|
if (found.route.auth == .session and !state.check_auth(state, io, request)) {
|
|
return http_util.respondError(request, .unauthorized, "authentication required");
|
|
}
|
|
|
|
// Milestone-20 ruling 7, and it runs *after* the auth check on purpose:
|
|
// rejecting before authenticating would tell an anonymous caller which
|
|
// routes exist. An unauthenticated request to a protected route answers
|
|
// 401 in both authority modes.
|
|
if (found.route.policy == .config_write) {
|
|
switch (state.authority) {
|
|
.database => {},
|
|
.managed_file => |path| return http_util.respondManagedByFile(request, path),
|
|
}
|
|
}
|
|
|
|
return found.route.handler(state, io, request);
|
|
}
|
|
|
|
fn respondMethodNotAllowed(request: *http_util.Request, allow: []const u8) http_util.HandlerError!void {
|
|
return http_util.respondBytes(
|
|
request,
|
|
.method_not_allowed,
|
|
"{\"error\":\"method not allowed\"}",
|
|
http_util.content_type_json,
|
|
&.{.{ .name = "allow", .value = allow }},
|
|
);
|
|
}
|
|
|
|
fn respondRateLimited(request: *http_util.Request, retry_after_seconds: u32) http_util.HandlerError!void {
|
|
var buf: [16]u8 = undefined;
|
|
const retry_after = std.fmt.bufPrint(&buf, "{d}", .{retry_after_seconds}) catch "60";
|
|
return http_util.respondBytes(
|
|
request,
|
|
.too_many_requests,
|
|
"{\"error\":\"rate limited\"}",
|
|
http_util.content_type_json,
|
|
&.{.{ .name = "retry-after", .value = retry_after }},
|
|
);
|
|
}
|
|
|
|
const testing = std.testing;
|
|
|
|
fn noopHandler(
|
|
state: *server.WebState,
|
|
io: std.Io,
|
|
request: *http_util.Request,
|
|
) http_util.HandlerError!void {
|
|
_ = state;
|
|
_ = io;
|
|
_ = request;
|
|
}
|
|
|
|
const test_table = [_]RouteInfo{
|
|
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = noopHandler, .rate_limit = .exempt },
|
|
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = noopHandler },
|
|
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
|
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = noopHandler },
|
|
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
|
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
|
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = noopHandler },
|
|
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = noopHandler },
|
|
};
|
|
|
|
fn matchPath(method: http.Method, path: []const u8) Match {
|
|
var buf: [128]u8 = undefined;
|
|
@memcpy(buf[0..path.len], path);
|
|
const parsed = http_util.parsePath(buf[0..path.len]) catch return .not_found;
|
|
return match(&test_table, method, parsed.segments());
|
|
}
|
|
|
|
test "the matching table resolves every registered shape" {
|
|
const cases = [_]struct { method: http.Method, path: []const u8, id: ?i64 }{
|
|
.{ .method = .GET, .path = "/api/health", .id = null },
|
|
.{ .method = .GET, .path = "/api/groups", .id = null },
|
|
.{ .method = .POST, .path = "/api/groups", .id = null },
|
|
.{ .method = .GET, .path = "/api/groups/7", .id = 7 },
|
|
.{ .method = .PUT, .path = "/api/groups/7", .id = 7 },
|
|
.{ .method = .DELETE, .path = "/api/groups/12", .id = 12 },
|
|
.{ .method = .PUT, .path = "/api/groups/12/sources", .id = 12 },
|
|
};
|
|
for (cases) |case| {
|
|
const found = matchPath(case.method, case.path).found;
|
|
try testing.expectEqual(case.id, found.id);
|
|
try testing.expectEqual(case.method, found.route.method);
|
|
}
|
|
}
|
|
|
|
test "a trailing slash matches the same route" {
|
|
try testing.expectEqual(@as(?i64, 7), matchPath(.GET, "/api/groups/7/").found.id);
|
|
try testing.expectEqual(@as(?i64, null), matchPath(.GET, "/api/groups/").found.id);
|
|
}
|
|
|
|
test "an unregistered path is not found" {
|
|
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/nope")));
|
|
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api")));
|
|
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/7/sources/1")));
|
|
}
|
|
|
|
test "a non-numeric or non-positive id does not match the capture" {
|
|
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/abc")));
|
|
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/0")));
|
|
try testing.expectEqual(.not_found, std.meta.activeTag(matchPath(.GET, "/api/groups/-1")));
|
|
}
|
|
|
|
test "a known path under an unknown method is 405, not 404" {
|
|
try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.DELETE, "/api/groups")));
|
|
try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.POST, "/api/groups/7")));
|
|
try testing.expectEqual(.method_not_allowed, std.meta.activeTag(matchPath(.PUT, "/api/health")));
|
|
}
|
|
|
|
test "the allow header lists every method the path accepts" {
|
|
var path_buf = "/api/groups".*;
|
|
const collection = try http_util.parsePath(&path_buf);
|
|
var buf: [64]u8 = undefined;
|
|
try testing.expectEqualStrings("GET, POST", formatAllow(&test_table, collection.segments(), &buf));
|
|
|
|
var item_buf = "/api/groups/7".*;
|
|
const item = try http_util.parsePath(&item_buf);
|
|
try testing.expectEqualStrings("GET, PUT, DELETE", formatAllow(&test_table, item.segments(), &buf));
|
|
}
|
|
|
|
test "matching carries the class the table declares, per route and not per prefix" {
|
|
const cases = [_]struct { method: http.Method, path: []const u8, policy: Policy }{
|
|
.{ .method = .GET, .path = "/api/groups", .policy = .read },
|
|
.{ .method = .GET, .path = "/api/groups/7", .policy = .read },
|
|
.{ .method = .POST, .path = "/api/groups", .policy = .config_write },
|
|
.{ .method = .PUT, .path = "/api/groups/7", .policy = .config_write },
|
|
.{ .method = .DELETE, .path = "/api/groups/7", .policy = .config_write },
|
|
.{ .method = .PUT, .path = "/api/groups/7/sources", .policy = .config_write },
|
|
// Same prefix, different class: the column is per route.
|
|
.{ .method = .POST, .path = "/api/pause", .policy = .runtime_action },
|
|
};
|
|
for (cases) |case| {
|
|
try testing.expectEqual(case.policy, matchPath(case.method, case.path).found.route.policy);
|
|
}
|
|
}
|
|
|
|
test "the shipped route table classifies /api/blocklists by route, not by prefix" {
|
|
var refresh: ?Policy = null;
|
|
var create: ?Policy = null;
|
|
for (routes) |route| {
|
|
if (route.method != .POST) continue;
|
|
if (std.mem.eql(u8, route.pattern, "/api/blocklists/update")) refresh = route.policy;
|
|
if (std.mem.eql(u8, route.pattern, "/api/blocklists")) create = route.policy;
|
|
}
|
|
try testing.expectEqual(Policy.runtime_action, refresh.?);
|
|
try testing.expectEqual(Policy.config_write, create.?);
|
|
}
|
|
|
|
test "the shipped route table is the one the router matches against" {
|
|
try testing.expectEqual(routes_table.table.ptr, routes.ptr);
|
|
try testing.expectEqual(routes_table.table.len, routes.len);
|
|
}
|