355 lines
13 KiB
Zig
355 lines
13 KiB
Zig
//! `upstreams`.
|
|
//!
|
|
//! The list sorts by `priority` first because that is the operationally
|
|
//! meaningful order — it matches what `Pool.init` expects — and `url` breaks
|
|
//! ties uniquely, which is what makes an export byte-stable.
|
|
//!
|
|
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
|
|
//! is the second half of this file: it speaks row ids, because that is what an
|
|
//! `/api/upstreams/{id}` request names.
|
|
|
|
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
const db = @import("../db.zig");
|
|
const migrations = @import("../migrations.zig");
|
|
const model = @import("../../config/model.zig");
|
|
const context = @import("context.zig");
|
|
const crud = @import("crud.zig");
|
|
|
|
const InsertContext = context.InsertContext;
|
|
|
|
/// Every string in the result is a heap copy owned by `gpa`.
|
|
pub fn listUpstreams(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.UpstreamServer) {
|
|
var stmt = try database.prepare(
|
|
"SELECT url, priority, enabled, tls_name FROM upstreams ORDER BY priority, url",
|
|
);
|
|
defer stmt.deinit();
|
|
|
|
var out: std.ArrayList(model.UpstreamServer) = .empty;
|
|
// `errdefer`s run in reverse: the free pass is declared last so it runs
|
|
// before the backing array is released.
|
|
errdefer out.deinit(gpa);
|
|
errdefer freeUpstreams(gpa, out.items);
|
|
|
|
while (try stmt.step()) {
|
|
const url = try stmt.columnTextAlloc(gpa, 0);
|
|
errdefer gpa.free(url);
|
|
const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch;
|
|
const tls_name = try stmt.columnTextAlloc(gpa, 3);
|
|
errdefer gpa.free(tls_name);
|
|
try out.append(gpa, .{
|
|
.url = url,
|
|
.priority = priority,
|
|
.enabled = stmt.columnBool(2),
|
|
.tls_name = tls_name,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
pub fn freeUpstreams(gpa: Allocator, items: []const model.UpstreamServer) void {
|
|
for (items) |item| {
|
|
gpa.free(item.url);
|
|
gpa.free(item.tls_name);
|
|
}
|
|
}
|
|
|
|
pub fn insertUpstream(database: *db.Db, item: model.UpstreamServer, ctx: InsertContext) db.Error!void {
|
|
_ = ctx;
|
|
var stmt = try database.prepare(
|
|
"INSERT INTO upstreams (url, priority, enabled, tls_name) VALUES (?1, ?2, ?3, ?4)",
|
|
);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, item.url);
|
|
try stmt.bindInt(2, item.priority);
|
|
try stmt.bindBool(3, item.enabled);
|
|
try stmt.bindText(4, item.tls_name);
|
|
try stmt.exec();
|
|
}
|
|
|
|
pub fn deleteAllUpstreams(database: *db.Db) db.Error!void {
|
|
return database.exec("DELETE FROM upstreams;");
|
|
}
|
|
|
|
pub fn countUpstreams(database: *db.Db) db.Error!i64 {
|
|
return database.queryInt("SELECT count(*) FROM upstreams");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REST surface (milestone 8)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The write shape is `model.UpstreamServer`: its four fields are exactly the
|
|
// columns of the table, so the REST layer needs no second shape for them.
|
|
|
|
pub const UpstreamRow = struct {
|
|
id: i64,
|
|
url: []const u8,
|
|
priority: i32,
|
|
enabled: bool,
|
|
tls_name: []const u8,
|
|
};
|
|
|
|
const list_upstream_rows_sql =
|
|
\\SELECT id, url, priority, enabled, tls_name FROM upstreams ORDER BY priority, url
|
|
;
|
|
|
|
const get_upstream_sql =
|
|
\\SELECT id, url, priority, enabled, tls_name FROM upstreams WHERE id = ?1
|
|
;
|
|
|
|
/// Same order as `listUpstreams`; every string is a heap copy owned by `gpa`.
|
|
pub fn listUpstreamRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(UpstreamRow) {
|
|
var stmt = try database.prepare(list_upstream_rows_sql);
|
|
defer stmt.deinit();
|
|
|
|
var out: std.ArrayList(UpstreamRow) = .empty;
|
|
errdefer out.deinit(gpa);
|
|
errdefer freeUpstreamRows(gpa, out.items);
|
|
|
|
while (try stmt.step()) {
|
|
const row = try readUpstreamRow(&stmt, gpa);
|
|
errdefer freeUpstreamRow(gpa, row);
|
|
try out.append(gpa, row);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
pub fn freeUpstreamRow(gpa: Allocator, row: UpstreamRow) void {
|
|
gpa.free(row.url);
|
|
gpa.free(row.tls_name);
|
|
}
|
|
|
|
pub fn freeUpstreamRows(gpa: Allocator, items: []const UpstreamRow) void {
|
|
for (items) |item| freeUpstreamRow(gpa, item);
|
|
}
|
|
|
|
pub fn getUpstream(database: *db.Db, gpa: Allocator, id: i64) db.Error!?UpstreamRow {
|
|
var stmt = try database.prepare(get_upstream_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
if (!try stmt.step()) return null;
|
|
return try readUpstreamRow(&stmt, gpa);
|
|
}
|
|
|
|
fn readUpstreamRow(stmt: *db.Stmt, gpa: Allocator) db.Error!UpstreamRow {
|
|
const url = try stmt.columnTextAlloc(gpa, 1);
|
|
errdefer gpa.free(url);
|
|
const tls_name = try stmt.columnTextAlloc(gpa, 4);
|
|
errdefer gpa.free(tls_name);
|
|
// The column is a 64-bit integer; the row field is `i32`. A value outside
|
|
// that range means something other than nxdns wrote the row.
|
|
const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
|
|
return .{
|
|
.id = stmt.columnInt(0),
|
|
.url = url,
|
|
.priority = priority,
|
|
.enabled = stmt.columnBool(3),
|
|
.tls_name = tls_name,
|
|
};
|
|
}
|
|
|
|
/// `error.Constraint`: `upstreams.url` is UNIQUE.
|
|
pub fn insertUpstreamRow(database: *db.Db, item: model.UpstreamServer) db.Error!i64 {
|
|
var stmt = try database.prepare(
|
|
"INSERT INTO upstreams (url, priority, enabled, tls_name) VALUES (?1, ?2, ?3, ?4)",
|
|
);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, item.url);
|
|
try stmt.bindInt(2, item.priority);
|
|
try stmt.bindBool(3, item.enabled);
|
|
try stmt.bindText(4, item.tls_name);
|
|
try stmt.exec();
|
|
return database.lastInsertRowid();
|
|
}
|
|
|
|
/// `error.NotFound`: no upstream holds `id`. `error.Constraint`:
|
|
/// `upstreams.url` is UNIQUE.
|
|
pub fn updateUpstream(database: *db.Db, id: i64, item: model.UpstreamServer) db.Error!void {
|
|
var stmt = try database.prepare(
|
|
"UPDATE upstreams SET url = ?2, priority = ?3, enabled = ?4, tls_name = ?5 WHERE id = ?1",
|
|
);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
try stmt.bindText(2, item.url);
|
|
try stmt.bindInt(3, item.priority);
|
|
try stmt.bindBool(4, item.enabled);
|
|
try stmt.bindText(5, item.tls_name);
|
|
return crud.execStrict(database, &stmt);
|
|
}
|
|
|
|
/// `error.NotFound`: no upstream holds `id`. Nothing references `upstreams`, so
|
|
/// a delete cannot violate a constraint.
|
|
pub fn deleteUpstream(database: *db.Db, id: i64) db.Error!void {
|
|
var stmt = try database.prepare("DELETE FROM upstreams WHERE id = ?1");
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
return crud.execStrict(database, &stmt);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
fn openMigrated() !db.Db {
|
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
errdefer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
_ = try migrations.migrate(&database);
|
|
return database;
|
|
}
|
|
|
|
fn seedUpstreams(database: *db.Db) !void {
|
|
const ctx: InsertContext = .{};
|
|
try insertUpstream(database, .{ .url = "https://dns.example/dns-query", .priority = 50 }, ctx);
|
|
try insertUpstream(database, .{
|
|
.url = "tls://1.1.1.1:853",
|
|
.priority = 10,
|
|
.enabled = false,
|
|
.tls_name = "one.one.one.one",
|
|
}, ctx);
|
|
try insertUpstream(database, .{ .url = "https://a.example/dns-query", .priority = 50 }, ctx);
|
|
}
|
|
|
|
test "upstreams round-trip in priority then url order" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedUpstreams(&database);
|
|
|
|
var items = try listUpstreams(&database, testing.allocator);
|
|
defer items.deinit(testing.allocator);
|
|
defer freeUpstreams(testing.allocator, items.items);
|
|
|
|
try testing.expectEqual(@as(usize, 3), items.items.len);
|
|
try testing.expectEqualStrings("tls://1.1.1.1:853", items.items[0].url);
|
|
try testing.expectEqual(@as(i32, 10), items.items[0].priority);
|
|
try testing.expect(!items.items[0].enabled);
|
|
try testing.expectEqualStrings("one.one.one.one", items.items[0].tls_name);
|
|
try testing.expectEqualStrings("https://a.example/dns-query", items.items[1].url);
|
|
// An upstream inserted without one reads back as the empty column default.
|
|
try testing.expectEqualStrings("", items.items[1].tls_name);
|
|
try testing.expectEqual(@as(i32, 50), items.items[1].priority);
|
|
try testing.expect(items.items[1].enabled);
|
|
try testing.expectEqualStrings("https://dns.example/dns-query", items.items[2].url);
|
|
try testing.expectEqual(@as(i32, 50), items.items[2].priority);
|
|
try testing.expect(items.items[2].enabled);
|
|
}
|
|
|
|
test "deleteAllUpstreams empties the table and countUpstreams reflects it" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedUpstreams(&database);
|
|
|
|
try testing.expectEqual(@as(i64, 3), try countUpstreams(&database));
|
|
try deleteAllUpstreams(&database);
|
|
try testing.expectEqual(@as(i64, 0), try countUpstreams(&database));
|
|
|
|
var items = try listUpstreams(&database, testing.allocator);
|
|
defer items.deinit(testing.allocator);
|
|
defer freeUpstreams(testing.allocator, items.items);
|
|
try testing.expectEqual(@as(usize, 0), items.items.len);
|
|
}
|
|
|
|
fn listUpstreamsUnderFailure(gpa: Allocator) !void {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedUpstreams(&database);
|
|
|
|
var items = try listUpstreams(&database, gpa);
|
|
defer items.deinit(gpa);
|
|
defer freeUpstreams(gpa, items.items);
|
|
}
|
|
|
|
test "listUpstreams is leak-safe under allocation failure" {
|
|
try testing.checkAllAllocationFailures(testing.allocator, listUpstreamsUnderFailure, .{});
|
|
}
|
|
|
|
// --- REST surface ----------------------------------------------------------
|
|
|
|
test "an upstream round-trips through insert, get, list, update and delete" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
|
|
const id = try insertUpstreamRow(&database, .{
|
|
.url = "tls://1.1.1.1:853",
|
|
.priority = 10,
|
|
.tls_name = "one.one.one.one",
|
|
});
|
|
|
|
const fetched = (try getUpstream(&database, testing.allocator, id)).?;
|
|
defer freeUpstreamRow(testing.allocator, fetched);
|
|
try testing.expectEqual(id, fetched.id);
|
|
try testing.expectEqualStrings("tls://1.1.1.1:853", fetched.url);
|
|
try testing.expectEqual(@as(i32, 10), fetched.priority);
|
|
try testing.expect(fetched.enabled);
|
|
try testing.expectEqualStrings("one.one.one.one", fetched.tls_name);
|
|
|
|
const second = try insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53", .priority = 20 });
|
|
|
|
var rows = try listUpstreamRows(&database, testing.allocator);
|
|
defer rows.deinit(testing.allocator);
|
|
defer freeUpstreamRows(testing.allocator, rows.items);
|
|
try testing.expectEqual(@as(usize, 2), rows.items.len);
|
|
try testing.expectEqual(id, rows.items[0].id);
|
|
try testing.expectEqual(second, rows.items[1].id);
|
|
try testing.expectEqualStrings("", rows.items[1].tls_name);
|
|
|
|
try updateUpstream(&database, id, .{
|
|
.url = "tls://1.0.0.1:853",
|
|
.priority = 5,
|
|
.enabled = false,
|
|
.tls_name = "",
|
|
});
|
|
const updated = (try getUpstream(&database, testing.allocator, id)).?;
|
|
defer freeUpstreamRow(testing.allocator, updated);
|
|
try testing.expectEqualStrings("tls://1.0.0.1:853", updated.url);
|
|
try testing.expectEqual(@as(i32, 5), updated.priority);
|
|
try testing.expect(!updated.enabled);
|
|
try testing.expectEqualStrings("", updated.tls_name);
|
|
|
|
try deleteUpstream(&database, id);
|
|
try testing.expectEqual(@as(?UpstreamRow, null), try getUpstream(&database, testing.allocator, id));
|
|
try testing.expectEqual(@as(i64, 1), try countUpstreams(&database));
|
|
}
|
|
|
|
test "upstream update and delete report NotFound for an id no row holds" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
|
|
try testing.expectError(error.NotFound, updateUpstream(&database, 404, .{ .url = "udp://9.9.9.9:53" }));
|
|
try testing.expectError(error.NotFound, deleteUpstream(&database, 404));
|
|
try testing.expectEqual(@as(?UpstreamRow, null), try getUpstream(&database, testing.allocator, 404));
|
|
}
|
|
|
|
test "a duplicate upstream url surfaces as error.Constraint on insert and on update" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
|
|
_ = try insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53" });
|
|
const other = try insertUpstreamRow(&database, .{ .url = "udp://1.1.1.1:53" });
|
|
|
|
try testing.expectError(error.Constraint, insertUpstreamRow(&database, .{ .url = "udp://9.9.9.9:53" }));
|
|
try testing.expectError(error.Constraint, updateUpstream(&database, other, .{ .url = "udp://9.9.9.9:53" }));
|
|
try testing.expectEqual(@as(i64, 2), try countUpstreams(&database));
|
|
}
|
|
|
|
fn upstreamRowsUnderFailure(gpa: Allocator) !void {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedUpstreams(&database);
|
|
|
|
var rows = try listUpstreamRows(&database, gpa);
|
|
defer rows.deinit(gpa);
|
|
defer freeUpstreamRows(gpa, rows.items);
|
|
|
|
const one = (try getUpstream(&database, gpa, rows.items[0].id)).?;
|
|
defer freeUpstreamRow(gpa, one);
|
|
}
|
|
|
|
test "the upstream read surface is leak-safe under allocation failure" {
|
|
try testing.checkAllAllocationFailures(testing.allocator, upstreamRowsUnderFailure, .{});
|
|
}
|