milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
//! 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 };
|
||||
|
||||
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,
|
||||
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`.
|
||||
pub 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");
|
||||
}
|
||||
|
||||
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, .handler = noopHandler, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .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 "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);
|
||||
}
|
||||
Reference in New Issue
Block a user