//! `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, .{}); }