milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user