//! 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); }