milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
//! The route table.
|
||||
//!
|
||||
//! Deliberately its own file: `router.zig` owns matching and dispatch, and the
|
||||
//! entries are filled in by the session that writes the handlers (milestone 8,
|
||||
//! session W8). Ruling 23's drift guards read `router.routes`, which is this
|
||||
//! array re-exported, so the contract test and the router can never disagree
|
||||
//! about what the server serves.
|
||||
//!
|
||||
//! Adding a route means adding one entry here — and documenting it in
|
||||
//! openapi.yaml, which openapi.zig's tests and W10's drift guards enforce.
|
||||
//! Nothing else in the web layer knows the path set.
|
||||
//!
|
||||
//! Policy columns restate two rulings as data: `auth = .open` is exactly
|
||||
//! ruling 18's exemption list (monitoring endpoints, the contract, the login
|
||||
//! itself), and `rate_limit = .exempt` is ruling 19's (Prometheus must never
|
||||
//! see 429) plus the live stream, which holds one request across its whole
|
||||
//! life and is bounded by the SSE per-address cap instead of the token
|
||||
//! bucket. The static assets are ruling 18's remaining exemption; they are
|
||||
//! not routes — the router sends unmatched non-`/api` paths to
|
||||
//! `WebState.fallback` before any policy check.
|
||||
|
||||
const router = @import("router.zig");
|
||||
|
||||
const auth = @import("handlers/auth.zig");
|
||||
const blocklists = @import("handlers/blocklists.zig");
|
||||
const clients = @import("handlers/clients.zig");
|
||||
const groups = @import("handlers/groups.zig");
|
||||
const health = @import("handlers/health.zig");
|
||||
const live = @import("handlers/live.zig");
|
||||
const local = @import("handlers/local.zig");
|
||||
const lookup = @import("handlers/lookup.zig");
|
||||
const metrics = @import("metrics.zig");
|
||||
const openapi = @import("openapi.zig");
|
||||
const pause = @import("handlers/pause.zig");
|
||||
const queries = @import("handlers/queries.zig");
|
||||
const rules = @import("handlers/rules.zig");
|
||||
const settings = @import("handlers/settings.zig");
|
||||
const stats = @import("handlers/stats.zig");
|
||||
const upstream_health = @import("handlers/upstream_health.zig");
|
||||
const upstreams = @import("handlers/upstreams.zig");
|
||||
const version = @import("handlers/version.zig");
|
||||
|
||||
pub const table: []const router.RouteInfo = &.{
|
||||
// Monitoring and contract (ruling 18's open set, ruling 19's exemptions).
|
||||
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .handler = metrics.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = health.handle, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .handler = version.handle },
|
||||
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .handler = openapi.handle },
|
||||
|
||||
// Authentication.
|
||||
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .handler = auth.login },
|
||||
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .handler = auth.logout },
|
||||
|
||||
// Query log, stats, live stream, lookup.
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .handler = queries.list },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .handler = live.stream, .rate_limit = .exempt },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .handler = stats.totals },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .handler = stats.timeseries },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .handler = lookup.handle },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .handler = upstream_health.handle },
|
||||
|
||||
// Groups.
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = groups.list },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = groups.create },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.get },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.remove },
|
||||
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.getSources },
|
||||
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.putSources },
|
||||
|
||||
// Blocklist sources. `/api/blocklists/update` is a literal segment; it
|
||||
// cannot collide with `{id}`, which only matches a positive integer.
|
||||
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.list },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.create },
|
||||
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .handler = blocklists.refresh },
|
||||
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.get },
|
||||
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.remove },
|
||||
|
||||
// Rules.
|
||||
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .handler = rules.list },
|
||||
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .handler = rules.create },
|
||||
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.get },
|
||||
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.remove },
|
||||
|
||||
// Local records.
|
||||
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .handler = local.listRecords },
|
||||
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .handler = local.createRecord },
|
||||
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.getRecord },
|
||||
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.updateRecord },
|
||||
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.removeRecord },
|
||||
|
||||
// Forward zones.
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .handler = local.listZones },
|
||||
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .handler = local.createZone },
|
||||
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.getZone },
|
||||
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.updateZone },
|
||||
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.removeZone },
|
||||
|
||||
// Clients (no POST — rows come from DNS activity or import, ruling 9).
|
||||
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .handler = clients.list },
|
||||
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.get },
|
||||
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.remove },
|
||||
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.listPrefixes },
|
||||
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.putPrefixes },
|
||||
|
||||
// Upstreams (restart-required resource).
|
||||
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.list },
|
||||
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.create },
|
||||
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.get },
|
||||
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.update },
|
||||
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.remove },
|
||||
|
||||
// Pause and settings.
|
||||
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .handler = pause.get },
|
||||
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .handler = pause.post },
|
||||
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .handler = settings.get },
|
||||
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .handler = settings.put },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const std = @import("std");
|
||||
const testing = std.testing;
|
||||
|
||||
test "the table carries every endpoint of the milestone" {
|
||||
try testing.expectEqual(@as(usize, 55), table.len);
|
||||
}
|
||||
|
||||
test "no two entries claim the same method and pattern" {
|
||||
for (table, 0..) |a, i| {
|
||||
for (table[i + 1 ..]) |b| {
|
||||
if (a.method != b.method) continue;
|
||||
try testing.expect(!std.mem.eql(u8, a.pattern, b.pattern));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "every pattern lives under /api except the Prometheus endpoint" {
|
||||
for (table) |route| {
|
||||
if (std.mem.eql(u8, route.pattern, "/metrics")) continue;
|
||||
try testing.expect(std.mem.startsWith(u8, route.pattern, "/api/"));
|
||||
}
|
||||
}
|
||||
|
||||
test "the open set is exactly ruling 18's exemption list" {
|
||||
const open = [_][]const u8{
|
||||
"/metrics",
|
||||
"/api/health",
|
||||
"/api/version",
|
||||
"/api/openapi.yaml",
|
||||
"/api/auth/login",
|
||||
};
|
||||
var found: usize = 0;
|
||||
for (table) |route| {
|
||||
if (route.auth != .open) continue;
|
||||
found += 1;
|
||||
var listed = false;
|
||||
for (open) |pattern| listed = listed or std.mem.eql(u8, route.pattern, pattern);
|
||||
try testing.expect(listed);
|
||||
}
|
||||
try testing.expectEqual(open.len, found);
|
||||
}
|
||||
|
||||
test "the limiter exemptions are the monitoring endpoints and the live stream" {
|
||||
const exempt = [_][]const u8{
|
||||
"/metrics",
|
||||
"/api/health",
|
||||
"/api/queries/live",
|
||||
};
|
||||
var found: usize = 0;
|
||||
for (table) |route| {
|
||||
if (route.rate_limit != .exempt) continue;
|
||||
found += 1;
|
||||
var listed = false;
|
||||
for (exempt) |pattern| listed = listed or std.mem.eql(u8, route.pattern, pattern);
|
||||
try testing.expect(listed);
|
||||
}
|
||||
try testing.expectEqual(exempt.len, found);
|
||||
}
|
||||
|
||||
test "item routes capture one id and collection routes capture none" {
|
||||
for (table) |route| {
|
||||
const captures = std.mem.count(u8, route.pattern, "{id}");
|
||||
try testing.expect(captures <= 1);
|
||||
if (captures == 1) {
|
||||
try testing.expect(route.method == .GET or route.method == .PUT or route.method == .DELETE);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user