140 lines
5.9 KiB
Zig
140 lines
5.9 KiB
Zig
//! `GET /api/openapi.yaml` — the API contract, served verbatim (ruling 23).
|
|
//!
|
|
//! The document is hand-written and embedded; nothing renders or validates it
|
|
//! at runtime (rendering is Phase 10, external validators are dependencies we
|
|
//! refused). What keeps it honest is W10's contract suite plus the tests
|
|
//! below: every route the router serves must appear textually in the
|
|
//! document, so a route added without documentation fails the build's tests
|
|
//! rather than drifting silently.
|
|
|
|
const std = @import("std");
|
|
|
|
const http_util = @import("http_util.zig");
|
|
const router = @import("router.zig");
|
|
const server = @import("server.zig");
|
|
|
|
pub const yaml: []const u8 = @embedFile("openapi.yaml");
|
|
|
|
pub const content_type = "application/yaml";
|
|
|
|
pub fn handle(
|
|
state: *server.WebState,
|
|
io: std.Io,
|
|
request: *http_util.Request,
|
|
) http_util.HandlerError!void {
|
|
_ = state;
|
|
_ = io;
|
|
return http_util.respondBytes(request, .ok, yaml, content_type, &.{});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
test "every served route appears textually in the document" {
|
|
for (router.routes) |route| {
|
|
var key_buf: [128]u8 = undefined;
|
|
// Path keys are two-space indented under `paths:`; requiring the
|
|
// colon keeps `/api/groups` from being satisfied by its `{id}` twin.
|
|
const key = try std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{route.pattern});
|
|
try testing.expect(std.mem.containsAtLeast(u8, yaml, 1, key));
|
|
|
|
var method_buf: [16]u8 = undefined;
|
|
const method = try std.fmt.bufPrint(&method_buf, " {s}:\n", .{@tagName(route.method)});
|
|
_ = std.ascii.lowerString(&method_buf, method);
|
|
try testing.expect(std.mem.containsAtLeast(u8, yaml, 1, method_buf[0..method.len]));
|
|
}
|
|
}
|
|
|
|
// Drift guard for milestone-20 ruling 7: a route classified `config_write` can
|
|
// answer 403 under file authority, so its operation must say so — and a route
|
|
// that cannot must not claim it. Textual, like the coverage test above: the
|
|
// document has no parser here, and the two facts it compares are one line each.
|
|
test "every config write documents the file-authority 403, and nothing else does" {
|
|
for (router.routes) |route| {
|
|
const operation = try operationBlock(route.pattern, route.method);
|
|
const documented = std.mem.containsAtLeast(u8, operation, 1, "\n \"403\":\n");
|
|
if (documented != (route.policy == .config_write)) {
|
|
std.debug.print(
|
|
"{t} {s} is {t} but {s} a 403\n",
|
|
.{ route.method, route.pattern, route.policy, if (documented) "documents" else "does not document" },
|
|
);
|
|
return error.TestUnexpectedResult;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The body of one operation: everything under `pattern`'s `method` key.
|
|
///
|
|
/// The path block is bounded *before* the method is looked for. Searching the
|
|
/// rest of the document instead would let a later path's `delete:` answer for a
|
|
/// path that has none, and the guard above would pass on an operation nobody
|
|
/// documented.
|
|
fn operationBlock(pattern: []const u8, method: std.http.Method) ![]const u8 {
|
|
var key_buf: [128]u8 = undefined;
|
|
const path_key = try std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{pattern});
|
|
const path_at = std.mem.indexOf(u8, yaml, path_key) orelse return error.PathNotDocumented;
|
|
const path_body = blockUnder(yaml[path_at + path_key.len ..], 2);
|
|
|
|
var method_buf: [16]u8 = undefined;
|
|
const method_key = try std.fmt.bufPrint(&method_buf, " {s}:\n", .{@tagName(method)});
|
|
_ = std.ascii.lowerString(&method_buf, method_key);
|
|
const key = method_buf[0..method_key.len];
|
|
|
|
// Anchored at a line start: a `get:` nested deeper inside a description
|
|
// contains the four-space key as a substring.
|
|
var offset: usize = 0;
|
|
while (offset < path_body.len) {
|
|
if (std.mem.startsWith(u8, path_body[offset..], key)) {
|
|
return blockUnder(path_body[offset + key.len ..], 4);
|
|
}
|
|
offset = (std.mem.indexOfScalarPos(u8, path_body, offset, '\n') orelse path_body.len) + 1;
|
|
}
|
|
return error.MethodNotDocumented;
|
|
}
|
|
|
|
/// The run of lines at the start of `body` indented deeper than `indent` — what
|
|
/// belongs to the key that just ended. `body` starts at a line boundary. Blank
|
|
/// lines belong to whatever surrounds them and never close a block.
|
|
fn blockUnder(body: []const u8, indent: usize) []const u8 {
|
|
var offset: usize = 0;
|
|
while (offset < body.len) {
|
|
const line_end = std.mem.indexOfScalarPos(u8, body, offset, '\n') orelse body.len;
|
|
if (line_end != offset) {
|
|
const depth = for (body[offset..line_end], 0..) |c, i| {
|
|
if (c != ' ') break i;
|
|
} else line_end - offset;
|
|
if (depth <= indent) return body[0..offset];
|
|
}
|
|
offset = line_end + 1;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
test "an operation block stops at its own path and its own method" {
|
|
// `/api/groups` has no DELETE. An unbounded search answers with the one
|
|
// under `/api/groups/{id}`, and the 403 guard then grades the wrong
|
|
// operation — silently passing for a route nobody documented.
|
|
try testing.expectError(error.MethodNotDocumented, operationBlock("/api/groups", .DELETE));
|
|
|
|
// A block it does have never reaches into its neighbour under the same
|
|
// path either.
|
|
const list_groups = try operationBlock("/api/groups", .GET);
|
|
try testing.expect(std.mem.containsAtLeast(u8, list_groups, 1, "List groups"));
|
|
try testing.expect(!std.mem.containsAtLeast(u8, list_groups, 1, "Create a group"));
|
|
}
|
|
|
|
test "the document names the contract's fixed points" {
|
|
for ([_][]const u8{
|
|
"openapi: 3.0.3",
|
|
"nxdns_session",
|
|
"text/event-stream",
|
|
"snake_case",
|
|
"Retry-After",
|
|
}) |needle| {
|
|
try testing.expect(std.mem.containsAtLeast(u8, yaml, 1, needle));
|
|
}
|
|
}
|