milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 5m6s
CI / frontend (push) Successful in 45s
CI / cross (push) Successful in 7m53s
CI / docker (push) Failing after 1h10m57s

This commit is contained in:
2026-08-07 18:20:30 +02:00
parent c50c6d285a
commit 6f67940995
82 changed files with 3167 additions and 3114 deletions
+15 -44
View File
@@ -87,10 +87,7 @@ pub fn applyCreate(
arena: Allocator,
item: model.BlocklistSource,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkSource(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
@@ -109,10 +106,7 @@ pub fn applyUpdate(
id: i64,
item: model.BlocklistSource,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkSource(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io);
@@ -124,10 +118,7 @@ pub fn applyUpdate(
}
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const written = sources_repo.deleteSource(database, id);
@@ -189,32 +180,19 @@ pub fn applyRefresh(state: *server.WebState, io: std.Io, out: []manager_mod.Sour
// routes
// ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing blocklists"),
};
const resource = mutations.Resource(.{
.Row = sources_repo.SourceRow,
.list = sources_repo.listSourceRows,
.get = sources_repo.getSource,
.remove = applyDelete,
.label = "a blocklist",
.plural = "blocklists",
.envelope = "blocklists",
});
const rows = sources_repo.listSourceRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing blocklists");
return http_util.respondJson(request, .ok, .{ .blocklists = rows.items }, &.{});
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a blocklist"),
};
const row = sources_repo.getSource(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a blocklist");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err|
@@ -251,13 +229,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{});
}
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDelete(state, io, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a blocklist");
}
return http_util.respondEmpty(request, .no_content);
}
/// `POST /api/blocklists/update`.
pub fn refresh(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const statuses = try request.arena.alloc(manager_mod.SourceStatus, max_statuses);
+27 -55
View File
@@ -61,10 +61,7 @@ pub fn applyUpdate(
id: i64,
edit: clients_repo.ClientEdit,
) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const written = clients_repo.updateClient(database, id, edit);
@@ -75,10 +72,7 @@ pub fn applyUpdate(
}
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const written = clients_repo.deleteClient(database, id);
@@ -94,10 +88,7 @@ pub fn applyReplacePrefixes(
arena: Allocator,
items: []const clients_repo.ClientPrefixInput,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
// Canonical duplicates are the same UNIQUE collision the database would
// report for identical text, so they answer 409 (ruling 9) before the
@@ -152,32 +143,33 @@ fn checkPrefixSet(
// routes
// ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing clients"),
};
const resource = mutations.Resource(.{
.Row = clients_repo.ClientRow,
.list = clients_repo.listClientRows,
.get = clients_repo.getClient,
.remove = applyDelete,
.label = "a client",
.plural = "clients",
.envelope = "clients",
});
const rows = clients_repo.listClientRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing clients");
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
return http_util.respondJson(request, .ok, .{ .clients = rows.items }, &.{});
}
/// The prefixes are one list resource with no `/{id}` route: the whole set is
/// read and replaced (ruling 9), so there is nothing to get or delete by id.
const prefixes_resource = mutations.Resource(.{
.Row = clients_repo.ClientPrefixRow,
.list = clients_repo.listClientPrefixRows,
.get = null,
.remove = null,
.label = "a client prefix",
.plural = "client prefixes",
.envelope = "client_prefixes",
});
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a client"),
};
const row = clients_repo.getClient(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a client");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub const listPrefixes = prefixes_resource.list;
pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(ClientBody, request) catch |err|
@@ -199,26 +191,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
return http_util.respondJson(request, .ok, found, &.{});
}
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDelete(state, io, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a client");
}
return http_util.respondEmpty(request, .no_content);
}
pub fn listPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing client prefixes"),
};
const rows = clients_repo.listClientPrefixRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing client prefixes");
return http_util.respondJson(request, .ok, .{ .client_prefixes = rows.items }, &.{});
}
pub fn putPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(PrefixesBody, request) catch |err|
return mutations.respondBadBody(request, err);
+18 -52
View File
@@ -54,10 +54,7 @@ pub fn applyCreate(
arena: Allocator,
item: model.Group,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkGroupName(arena, item.name)) |problem| {
return .{ .fail = .{ .invalid = problem } };
}
@@ -78,10 +75,7 @@ pub fn applyUpdate(
id: i64,
item: model.Group,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkGroupName(arena, item.name)) |problem| {
return .{ .invalid = problem };
}
@@ -112,10 +106,7 @@ fn updateLocked(database: *db.Db, arena: Allocator, id: i64, item: model.Group)
}
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const outcome = deleteLocked(database, arena, id);
@@ -148,10 +139,7 @@ pub fn applySetSources(
id: i64,
source_ids: []const i64,
) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const outcome = groups_repo.setGroupSources(database, id, source_ids);
@@ -165,32 +153,19 @@ pub fn applySetSources(
// routes
// ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing groups"),
};
const resource = mutations.Resource(.{
.Row = groups_repo.GroupRow,
.list = groups_repo.listGroupRows,
.get = groups_repo.getGroup,
.remove = applyDelete,
.label = "a group",
.plural = "groups",
.envelope = "groups",
});
const rows = groups_repo.listGroupRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing groups");
return http_util.respondJson(request, .ok, .{ .groups = rows.items }, &.{});
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a group"),
};
const row = groups_repo.getGroup(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a group");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err|
@@ -223,20 +198,11 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{});
}
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDelete(state, io, request.arena, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a group");
}
return http_util.respondEmpty(request, .no_content);
}
/// `GET /api/groups/{id}/sources` — the assignment the PUT replaces.
pub fn getSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a group"),
};
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, "reading a group");
const id = request.id.?;
const row = groups_repo.getGroup(database, request.arena, id) catch |err|
+31 -91
View File
@@ -94,10 +94,7 @@ pub fn applyCreateRecord(
arena: Allocator,
item: model.LocalRecord,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
@@ -116,10 +113,7 @@ pub fn applyUpdateRecord(
id: i64,
item: model.LocalRecord,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io);
@@ -131,10 +125,7 @@ pub fn applyUpdateRecord(
}
pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
@@ -154,10 +145,7 @@ pub fn applyCreateZone(
arena: Allocator,
item: model.ForwardZone,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
@@ -176,10 +164,7 @@ pub fn applyUpdateZone(
id: i64,
item: model.ForwardZone,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io);
@@ -191,10 +176,7 @@ pub fn applyUpdateZone(
}
pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
@@ -208,35 +190,20 @@ pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id
// local records: routes
// ---------------------------------------------------------------------------
pub fn listRecords(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing local records"),
};
const records_resource = mutations.Resource(.{
.Row = local_repo.LocalRecordRow,
.list = local_repo.listLocalRecordRows,
.get = local_repo.getLocalRecord,
.remove = applyDeleteRecord,
.label = "a local record",
.plural = "local records",
.envelope = "local_records",
.view = RecordView.from,
});
const rows = local_repo.listLocalRecordRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing local records");
const views = try request.arena.alloc(RecordView, rows.items.len);
for (views, rows.items) |*view, row| view.* = .from(row);
return http_util.respondJson(request, .ok, .{ .local_records = views }, &.{});
}
pub fn getRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a local record"),
};
const row = local_repo.getLocalRecord(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a local record");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, RecordView.from(found), &.{});
}
pub const listRecords = records_resource.list;
pub const getRecord = records_resource.get;
pub const removeRecord = records_resource.remove;
pub fn createRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(RecordBody, request) catch |err|
@@ -279,43 +246,23 @@ pub fn updateRecord(state: *server.WebState, io: std.Io, request: *Request) Hand
}, &.{});
}
pub fn removeRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDeleteRecord(state, io, request.arena, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a local record");
}
return http_util.respondEmpty(request, .no_content);
}
// ---------------------------------------------------------------------------
// forward zones: routes
// ---------------------------------------------------------------------------
pub fn listZones(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing forward zones"),
};
const zones_resource = mutations.Resource(.{
.Row = local_repo.ForwardZoneRow,
.list = local_repo.listForwardZoneRows,
.get = local_repo.getForwardZone,
.remove = applyDeleteZone,
.label = "a forward zone",
.plural = "forward zones",
.envelope = "forward_zones",
});
const rows = local_repo.listForwardZoneRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing forward zones");
return http_util.respondJson(request, .ok, .{ .forward_zones = rows.items }, &.{});
}
pub fn getZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a forward zone"),
};
const row = local_repo.getForwardZone(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a forward zone");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub const listZones = zones_resource.list;
pub const getZone = zones_resource.get;
pub const removeZone = zones_resource.remove;
pub fn createZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(ZoneBody, request) catch |err|
@@ -348,13 +295,6 @@ pub fn updateZone(state: *server.WebState, io: std.Io, request: *Request) Handle
}, &.{});
}
pub fn removeZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDeleteZone(state, io, request.arena, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a forward zone");
}
return http_util.respondEmpty(request, .no_content);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
+156 -4
View File
@@ -101,16 +101,156 @@ pub fn dbFailure(err: db.Error, conflict: []const u8) Failure {
};
}
/// The config connection, or the 503 a state without one earns.
pub fn configDb(state: *server.WebState) union(enum) { database: *db.Db, fail: Failure } {
if (state.config_db) |database| return .{ .database = database };
return .{ .fail = .{ .unavailable = "no configuration database" } };
/// The 503 a state with no config connection earns. One constant, because every
/// caller reports the same missing collaborator in the same words.
pub const no_config_db: Failure = .{ .unavailable = "no configuration database" };
/// The config connection, or `error.NoConfigDb` for the caller to turn into
/// `no_config_db` in whatever shape it answers with — a `Failure`, a `Created`,
/// or a written response.
pub fn requireConfigDb(state: *server.WebState) error{NoConfigDb}!*db.Db {
return state.config_db orelse error.NoConfigDb;
}
pub fn nowSeconds(io: std.Io) i64 {
return std.Io.Clock.real.now(io).toSeconds();
}
// ---------------------------------------------------------------------------
// the identical half of an id-addressed resource
// ---------------------------------------------------------------------------
/// The `list`, `get` and `remove` handlers every id-addressed resource in this
/// directory writes the same way: take the config connection or answer 503, call
/// one repository function, and turn what comes back into the response. Nothing
/// a resource decides for itself is here — the create and update bodies, the
/// constraint texts, and the four reload flavors stay hand-written beside the
/// descriptor that names these three.
///
/// `desc` is an anonymous struct literal rather than a typed struct because
/// `anytype` is not legal as a struct *field* type and the members are functions
/// of five signatures. Every member is checked below, so a descriptor that is
/// missing one or spells one wrong is a compile error that names it.
///
/// Members:
///
/// - `Row: type` — what the repository returns for one row.
/// - `list: fn (*db.Db, Allocator) db.Error!std.ArrayList(Row)`.
/// - `get: fn (*db.Db, Allocator, i64) db.Error!?Row`, or `null` for a resource
/// with no `/{id}` route.
/// - `remove: fn (*server.WebState, std.Io, i64) ?Failure`, or the same with an
/// `Allocator` before the id for a delete decision that reads rows, or `null`.
/// - `label: []const u8` — "an upstream": what "reading" and "deleting" take as
/// their object in the log context a 500 carries.
/// - `plural: []const u8` — "upstreams": what "listing" takes as its object.
/// - `envelope: []const u8` — the JSON key the list arrives under.
/// - `view: fn (Row) View` — optional. A resource whose wire shape is not its
/// row spells the difference here; without it the row is serialised as it is.
pub fn Resource(comptime desc: anytype) type {
const Desc = @TypeOf(desc);
for ([_][]const u8{ "Row", "list", "get", "remove", "label", "plural", "envelope" }) |name| {
if (!@hasField(Desc, name)) {
@compileError("resource descriptor has no `" ++ name ++ "`");
}
}
if (@TypeOf(desc.Row) != type) @compileError("resource descriptor `Row` must be a type");
const Row = desc.Row;
expectType("list", @TypeOf(desc.list), fn (*db.Db, Allocator) db.Error!std.ArrayList(Row));
if (!isNull(@TypeOf(desc.get))) {
expectType("get", @TypeOf(desc.get), fn (*db.Db, Allocator, i64) db.Error!?Row);
}
if (!isNull(@TypeOf(desc.remove))) {
const Remove = @TypeOf(desc.remove);
if (removeTakesArena(Remove)) {
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
} else {
expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure);
}
}
_ = @as([]const u8, desc.label);
_ = @as([]const u8, desc.plural);
_ = @as([]const u8, desc.envelope);
const has_view = @hasField(Desc, "view");
if (has_view) {
const info = @typeInfo(@TypeOf(desc.view)).@"fn";
if (info.params.len != 1 or info.params[0].type.? != Row) {
@compileError("resource descriptor `view` must take one " ++ @typeName(Row));
}
}
const View = if (has_view) @typeInfo(@TypeOf(desc.view)).@"fn".return_type.? else Row;
const names: [1][:0]const u8 = .{desc.envelope};
const types: [1]type = .{[]const View};
const attrs: [1]std.builtin.Type.StructField.Attributes = .{.{}};
const Envelope = @Struct(.auto, null, &names, &types, &attrs);
const list_what = "listing " ++ desc.plural;
const get_what = "reading " ++ desc.label;
const remove_what = "deleting " ++ desc.label;
return struct {
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = requireConfigDb(state) catch
return respondFailure(request, no_config_db, list_what);
const rows = desc.list(database, request.arena) catch |err|
return respondFailure(request, .{ .internal = err }, list_what);
const items: []const View = if (has_view) views: {
const views = try request.arena.alloc(View, rows.items.len);
for (views, rows.items) |*view, row| view.* = desc.view(row);
break :views views;
} else rows.items;
var payload: Envelope = undefined;
@field(payload, desc.envelope) = items;
return http_util.respondJson(request, .ok, payload, &.{});
}
pub const get = if (isNull(@TypeOf(desc.get))) {} else getRow;
pub const remove = if (isNull(@TypeOf(desc.remove))) {} else removeRow;
fn getRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = requireConfigDb(state) catch
return respondFailure(request, no_config_db, get_what);
const row = desc.get(database, request.arena, request.id.?) catch |err|
return respondFailure(request, .{ .internal = err }, get_what);
const found = row orelse return respondFailure(request, .not_found, "");
const body: View = if (has_view) desc.view(found) else found;
return http_util.respondJson(request, .ok, body, &.{});
}
fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const failure = if (comptime removeTakesArena(@TypeOf(desc.remove)))
desc.remove(state, io, request.arena, request.id.?)
else
desc.remove(state, io, request.id.?);
if (failure) |value| return respondFailure(request, value, remove_what);
return http_util.respondEmpty(request, .no_content);
}
};
}
fn isNull(comptime T: type) bool {
return T == @TypeOf(null);
}
fn removeTakesArena(comptime T: type) bool {
return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4;
}
fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void {
if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++
@typeName(Expected) ++ ", found " ++ @typeName(Actual));
}
// ---------------------------------------------------------------------------
// applying a change to the running server (ruling 12)
// ---------------------------------------------------------------------------
@@ -439,6 +579,18 @@ test "the schema the bench opens already holds the default group" {
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT id FROM groups WHERE name = 'default'"));
}
test "a state with no configuration database is a 503, not a crash" {
var bench: Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try testing.expectEqual(&bench.database, try requireConfigDb(&bench.state));
var bare: server.WebState = .{ .gpa = testing.allocator };
try testing.expectError(error.NoConfigDb, requireConfigDb(&bare));
try testing.expectEqualStrings("no configuration database", no_config_db.unavailable);
}
test "a database error maps to the status its cause deserves" {
try testing.expectEqual(Failure.not_found, dbFailure(error.NotFound, "x"));
try testing.expectEqualStrings("taken", dbFailure(error.Constraint, "taken").conflict);
+16 -47
View File
@@ -60,10 +60,7 @@ pub fn applyCreate(
arena: Allocator,
item: rules_repo.RuleInput,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
return .{ .fail = .{ .invalid = problem } };
}
@@ -84,10 +81,7 @@ pub fn applyUpdate(
id: i64,
item: rules_repo.RuleInput,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
return .{ .invalid = problem };
}
@@ -101,10 +95,7 @@ pub fn applyUpdate(
}
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
const written = rules_repo.deleteRule(database, id);
@@ -142,35 +133,20 @@ const RuleView = struct {
}
};
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing rules"),
};
const resource = mutations.Resource(.{
.Row = rules_repo.RuleRow,
.list = rules_repo.listRuleRows,
.get = rules_repo.getRule,
.remove = applyDelete,
.label = "a rule",
.plural = "rules",
.envelope = "rules",
.view = RuleView.from,
});
const rows = rules_repo.listRuleRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing rules");
const views = try request.arena.alloc(RuleView, rows.items.len);
for (views, rows.items) |*view, row| view.* = .from(row);
return http_util.respondJson(request, .ok, .{ .rules = views }, &.{});
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a rule"),
};
const row = rules_repo.getRule(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a rule");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, RuleView.from(found), &.{});
}
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err|
@@ -213,13 +189,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{});
}
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDelete(state, io, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a rule");
}
return http_util.respondEmpty(request, .no_content);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
+3 -8
View File
@@ -281,10 +281,7 @@ pub fn applyPut(
arena: Allocator,
patch: Patch,
) error{OutOfMemory}!union(enum) { config: model.Config, fail: Failure } {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
// Ruling 18 of milestone 16: argon2id at m=19 MiB is the longest thing this
// handler does, and its input is the parsed patch alone — nothing under the
@@ -449,10 +446,8 @@ pub const hash_stall_control = if (builtin.is_test) struct {
// ---------------------------------------------------------------------------
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading the settings"),
};
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
// Under the same lock the mutation handlers hold: a PUT rewrites every
// settings row in one transaction on this shared connection, and SQLite's
+15 -44
View File
@@ -44,10 +44,7 @@ pub fn applyCreate(
arena: Allocator,
item: model.UpstreamServer,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
@@ -65,10 +62,7 @@ pub fn applyUpdate(
id: i64,
item: model.UpstreamServer,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io);
@@ -96,10 +90,7 @@ pub fn applyUpdate(
/// answers nothing, and `validate.validate` refuses that configuration at
/// startup — so allowing it here would only produce a box that will not boot.
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
@@ -142,32 +133,19 @@ fn countEnabledExcept(
// routes
// ---------------------------------------------------------------------------
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing upstreams"),
};
const resource = mutations.Resource(.{
.Row = upstreams_repo.UpstreamRow,
.list = upstreams_repo.listUpstreamRows,
.get = upstreams_repo.getUpstream,
.remove = applyDelete,
.label = "an upstream",
.plural = "upstreams",
.envelope = "upstreams",
});
const rows = upstreams_repo.listUpstreamRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing upstreams");
return http_util.respondJson(request, .ok, .{ .upstreams = rows.items }, &.{});
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading an upstream"),
};
const row = upstreams_repo.getUpstream(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading an upstream");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err|
@@ -206,13 +184,6 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
}, &.{});
}
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDelete(state, io, request.arena, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting an upstream");
}
return http_util.respondEmpty(request, .no_content);
}
fn toModel(body: Body) model.UpstreamServer {
return .{
.url = body.url,