prefect server in zig

add JSON path helpers, simplify events_api.zig

utilities/json.zig:
- getPath(value, "path.to.field") - navigate by dot-separated path
- getString, getInt, getBool, getFloat, getArray, getObject helpers
- extractAt(T, alloc, value, .{"path"}) - comptime typed extraction
- comprehensive tests

events_api.zig:
- parseFilterOptions: 54 lines → 28 lines using path helpers
- filterNext: simplified token field extraction
- filter: cleaner limit parsing

inspired by zat's internal/json.zig pattern

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+230 -102
+58 -102
src/api/events_api.zig
··· 50 50 json.sendStatus(r, "{\"detail\":\"invalid json\"}", .bad_request); 51 51 return; 52 52 }; 53 - const obj = parsed.value.object; 54 53 55 54 // parse limit (default PAGE_SIZE, max PAGE_SIZE for pagination) 56 - var limit: usize = PAGE_SIZE; 57 - if (obj.get("limit")) |v| { 58 - if (v == .integer) { 59 - const l = v.integer; 60 - if (l > 0 and l <= PAGE_SIZE) limit = @intCast(l); 55 + const limit: usize = blk: { 56 + if (json.getInt(parsed.value, "limit")) |l| { 57 + if (l > 0 and l <= PAGE_SIZE) break :blk @intCast(l); 61 58 } 62 - } 59 + break :blk PAGE_SIZE; 60 + }; 63 61 64 62 // parse filter object 65 - const filter_opts = parseFilterOptions(alloc, obj); 63 + const filter_opts = parseFilterOptions(alloc, parsed.value); 66 64 67 65 // get total count first 68 66 const total = db_events.countWithFilter(alloc, filter_opts) catch 0; ··· 129 127 return; 130 128 } 131 129 132 - const token_obj = token_parsed.value.object; 130 + // extract token fields using path helpers 131 + const total: usize = if (json.getInt(token_parsed.value, "count")) |c| 132 + if (c >= 0) @intCast(c) else 0 133 + else 134 + 0; 133 135 134 - // extract token fields 135 - const total: usize = blk: { 136 - if (token_obj.get("count")) |v| { 137 - if (v == .integer and v.integer >= 0) break :blk @intCast(v.integer); 138 - } 139 - break :blk 0; 140 - }; 136 + const page_size: usize = if (json.getInt(token_parsed.value, "page_size")) |ps| 137 + if (ps > 0) @intCast(ps) else PAGE_SIZE 138 + else 139 + PAGE_SIZE; 141 140 142 - const page_size: usize = blk: { 143 - if (token_obj.get("page_size")) |v| { 144 - if (v == .integer and v.integer > 0) break :blk @intCast(v.integer); 145 - } 146 - break :blk PAGE_SIZE; 147 - }; 148 - 149 - const offset: usize = blk: { 150 - if (token_obj.get("offset")) |v| { 151 - if (v == .integer and v.integer >= 0) break :blk @intCast(v.integer); 152 - } 153 - break :blk 0; 154 - }; 141 + const offset: usize = if (json.getInt(token_parsed.value, "offset")) |o| 142 + if (o >= 0) @intCast(o) else 0 143 + else 144 + 0; 155 145 156 - // parse filter from token - extract fields directly from token_obj.filter 146 + // parse filter from token 157 147 var filter_opts = db_events.FilterOptions{}; 158 - if (token_obj.get("filter")) |filter_val| { 159 - if (filter_val == .object) { 160 - const fobj = filter_val.object; 161 - if (fobj.get("occurred_since")) |v| { 162 - if (v == .string) filter_opts.occurred_since = v.string; 163 - } 164 - if (fobj.get("occurred_until")) |v| { 165 - if (v == .string) filter_opts.occurred_until = v.string; 166 - } 167 - if (fobj.get("event_prefixes")) |v| { 168 - filter_opts.event_prefixes = db_events.parseJsonStringArray(alloc, v); 169 - } 170 - if (fobj.get("event_names")) |v| { 171 - filter_opts.event_names = db_events.parseJsonStringArray(alloc, v); 172 - } 173 - if (fobj.get("resource_ids")) |v| { 174 - filter_opts.resource_ids = db_events.parseJsonStringArray(alloc, v); 175 - } 176 - if (fobj.get("resource_id_prefixes")) |v| { 177 - filter_opts.resource_id_prefixes = db_events.parseJsonStringArray(alloc, v); 178 - } 179 - if (fobj.get("order_asc")) |v| { 180 - if (v == .bool) filter_opts.order_asc = v.bool; 181 - } 182 - } 148 + filter_opts.occurred_since = json.getString(token_parsed.value, "filter.occurred_since"); 149 + filter_opts.occurred_until = json.getString(token_parsed.value, "filter.occurred_until"); 150 + filter_opts.order_asc = json.getBool(token_parsed.value, "filter.order_asc") orelse false; 151 + 152 + if (json.getPath(token_parsed.value, "filter.event_prefixes")) |v| { 153 + filter_opts.event_prefixes = db_events.parseJsonStringArray(alloc, v); 154 + } 155 + if (json.getPath(token_parsed.value, "filter.event_names")) |v| { 156 + filter_opts.event_names = db_events.parseJsonStringArray(alloc, v); 157 + } 158 + if (json.getPath(token_parsed.value, "filter.resource_ids")) |v| { 159 + filter_opts.resource_ids = db_events.parseJsonStringArray(alloc, v); 160 + } 161 + if (json.getPath(token_parsed.value, "filter.resource_id_prefixes")) |v| { 162 + filter_opts.resource_id_prefixes = db_events.parseJsonStringArray(alloc, v); 183 163 } 184 164 185 165 // query this page ··· 196 176 sendEventPage(r, alloc, events, total, next_page); 197 177 } 198 178 199 - fn parseFilterOptions(alloc: std.mem.Allocator, obj: std.json.ObjectMap) db_events.FilterOptions { 200 - var filter_opts = db_events.FilterOptions{}; 201 - 202 - if (obj.get("filter")) |filter_val| { 203 - if (filter_val == .object) { 204 - const fobj = filter_val.object; 179 + fn parseFilterOptions(alloc: std.mem.Allocator, value: std.json.Value) db_events.FilterOptions { 180 + var opts = db_events.FilterOptions{}; 205 181 206 - // occurred filter: { "since": "...", "until": "..." } 207 - if (fobj.get("occurred")) |occ| { 208 - if (occ == .object) { 209 - if (occ.object.get("since")) |v| { 210 - if (v == .string) filter_opts.occurred_since = v.string; 211 - } 212 - if (occ.object.get("until")) |v| { 213 - if (v == .string) filter_opts.occurred_until = v.string; 214 - } 215 - } 216 - } 182 + // occurred filter 183 + opts.occurred_since = json.getString(value, "filter.occurred.since"); 184 + opts.occurred_until = json.getString(value, "filter.occurred.until"); 217 185 218 - // event filter: { "prefix": ["..."], "name": ["..."] } 219 - if (fobj.get("event")) |ev| { 220 - if (ev == .object) { 221 - if (ev.object.get("prefix")) |v| { 222 - filter_opts.event_prefixes = db_events.parseJsonStringArray(alloc, v); 223 - } 224 - if (ev.object.get("name")) |v| { 225 - filter_opts.event_names = db_events.parseJsonStringArray(alloc, v); 226 - } 227 - } 228 - } 186 + // event filter 187 + if (json.getPath(value, "filter.event.prefix")) |v| { 188 + opts.event_prefixes = db_events.parseJsonStringArray(alloc, v); 189 + } 190 + if (json.getPath(value, "filter.event.name")) |v| { 191 + opts.event_names = db_events.parseJsonStringArray(alloc, v); 192 + } 229 193 230 - // resource filter: { "id": ["..."], "id_prefix": ["..."] } 231 - if (fobj.get("resource")) |res| { 232 - if (res == .object) { 233 - if (res.object.get("id")) |v| { 234 - filter_opts.resource_ids = db_events.parseJsonStringArray(alloc, v); 235 - } 236 - if (res.object.get("id_prefix")) |v| { 237 - filter_opts.resource_id_prefixes = db_events.parseJsonStringArray(alloc, v); 238 - } 239 - } 240 - } 194 + // resource filter 195 + if (json.getPath(value, "filter.resource.id")) |v| { 196 + opts.resource_ids = db_events.parseJsonStringArray(alloc, v); 197 + } 198 + if (json.getPath(value, "filter.resource.id_prefix")) |v| { 199 + opts.resource_id_prefixes = db_events.parseJsonStringArray(alloc, v); 200 + } 241 201 242 - // order: "ASC" or "DESC" 243 - if (fobj.get("order")) |v| { 244 - if (v == .string) { 245 - if (mem.eql(u8, v.string, "ASC")) filter_opts.order_asc = true; 246 - } 247 - } 248 - } 202 + // order 203 + if (json.getString(value, "filter.order")) |order| { 204 + opts.order_asc = mem.eql(u8, order, "ASC"); 249 205 } 250 206 251 - return filter_opts; 207 + return opts; 252 208 } 253 209 254 210 fn generateNextPageUrl(
+172
src/utilities/json.zig
··· 15 15 r.setStatus(status); 16 16 send(r, body); 17 17 } 18 + 19 + // ============================================================================ 20 + // JSON path navigation helpers 21 + // ============================================================================ 22 + 23 + /// navigate a json value by dot-separated path 24 + /// returns null if any segment is missing or wrong type 25 + pub fn getPath(value: std.json.Value, path: []const u8) ?std.json.Value { 26 + var current = value; 27 + var it = std.mem.splitScalar(u8, path, '.'); 28 + 29 + while (it.next()) |segment| { 30 + switch (current) { 31 + .object => |obj| { 32 + current = obj.get(segment) orelse return null; 33 + }, 34 + .array => |arr| { 35 + const idx = std.fmt.parseInt(usize, segment, 10) catch return null; 36 + if (idx >= arr.items.len) return null; 37 + current = arr.items[idx]; 38 + }, 39 + else => return null, 40 + } 41 + } 42 + return current; 43 + } 44 + 45 + /// get a string at path 46 + pub fn getString(value: std.json.Value, path: []const u8) ?[]const u8 { 47 + const v = getPath(value, path) orelse return null; 48 + return switch (v) { 49 + .string => |s| s, 50 + else => null, 51 + }; 52 + } 53 + 54 + /// get an integer at path 55 + pub fn getInt(value: std.json.Value, path: []const u8) ?i64 { 56 + const v = getPath(value, path) orelse return null; 57 + return switch (v) { 58 + .integer => |i| i, 59 + else => null, 60 + }; 61 + } 62 + 63 + /// get a float at path 64 + pub fn getFloat(value: std.json.Value, path: []const u8) ?f64 { 65 + const v = getPath(value, path) orelse return null; 66 + return switch (v) { 67 + .float => |f| f, 68 + .integer => |i| @floatFromInt(i), 69 + else => null, 70 + }; 71 + } 72 + 73 + /// get a bool at path 74 + pub fn getBool(value: std.json.Value, path: []const u8) ?bool { 75 + const v = getPath(value, path) orelse return null; 76 + return switch (v) { 77 + .bool => |b| b, 78 + else => null, 79 + }; 80 + } 81 + 82 + /// get an array at path 83 + pub fn getArray(value: std.json.Value, path: []const u8) ?[]std.json.Value { 84 + const v = getPath(value, path) orelse return null; 85 + return switch (v) { 86 + .array => |a| a.items, 87 + else => null, 88 + }; 89 + } 90 + 91 + /// get an object at path 92 + pub fn getObject(value: std.json.Value, path: []const u8) ?std.json.ObjectMap { 93 + const v = getPath(value, path) orelse return null; 94 + return switch (v) { 95 + .object => |o| o, 96 + else => null, 97 + }; 98 + } 99 + 100 + // ============================================================================ 101 + // comptime path extraction 102 + // ============================================================================ 103 + 104 + /// extract a typed struct from a nested path 105 + /// uses comptime tuple for path segments - no runtime string parsing 106 + /// leverages std.json.parseFromValueLeaky for type-safe extraction 107 + pub fn extractAt( 108 + comptime T: type, 109 + allocator: std.mem.Allocator, 110 + value: std.json.Value, 111 + comptime path: anytype, 112 + ) std.json.ParseFromValueError!T { 113 + var current = value; 114 + inline for (path) |segment| { 115 + current = switch (current) { 116 + .object => |obj| obj.get(segment) orelse return error.MissingField, 117 + else => return error.UnexpectedToken, 118 + }; 119 + } 120 + return std.json.parseFromValueLeaky(T, allocator, current, .{ .ignore_unknown_fields = true }); 121 + } 122 + 123 + /// extract a typed value, returning null if path doesn't exist 124 + pub fn extractAtOptional( 125 + comptime T: type, 126 + allocator: std.mem.Allocator, 127 + value: std.json.Value, 128 + comptime path: anytype, 129 + ) ?T { 130 + return extractAt(T, allocator, value, path) catch null; 131 + } 132 + 133 + // ============================================================================ 134 + // tests 135 + // ============================================================================ 136 + 137 + test "getPath simple" { 138 + const json_str = "{\"name\": \"alice\", \"age\": 30}"; 139 + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 140 + defer parsed.deinit(); 141 + try std.testing.expectEqualStrings("alice", getString(parsed.value, "name").?); 142 + try std.testing.expectEqual(@as(i64, 30), getInt(parsed.value, "age").?); 143 + } 144 + 145 + test "getPath nested" { 146 + const json_str = "{\"embed\": {\"external\": {\"uri\": \"https://example.com\"}}}"; 147 + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 148 + defer parsed.deinit(); 149 + try std.testing.expectEqualStrings("https://example.com", getString(parsed.value, "embed.external.uri").?); 150 + } 151 + 152 + test "getPath array index" { 153 + const json_str = "{\"items\": [\"a\", \"b\", \"c\"]}"; 154 + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 155 + defer parsed.deinit(); 156 + try std.testing.expectEqualStrings("b", getString(parsed.value, "items.1").?); 157 + } 158 + 159 + test "getPath missing returns null" { 160 + const json_str = "{\"name\": \"alice\"}"; 161 + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 162 + defer parsed.deinit(); 163 + try std.testing.expect(getString(parsed.value, "missing") == null); 164 + try std.testing.expect(getString(parsed.value, "name.nested") == null); 165 + } 166 + 167 + test "extractAt struct" { 168 + const json_str = "{\"embed\": {\"external\": {\"uri\": \"https://tangled.sh\", \"title\": \"Tangled\"}}}"; 169 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 170 + defer arena.deinit(); 171 + const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{}); 172 + const External = struct { uri: []const u8, title: []const u8 }; 173 + const ext = try extractAt(External, arena.allocator(), parsed.value, .{ "embed", "external" }); 174 + try std.testing.expectEqualStrings("https://tangled.sh", ext.uri); 175 + try std.testing.expectEqualStrings("Tangled", ext.title); 176 + } 177 + 178 + test "extractAtOptional returns null on missing path" { 179 + const json_str = "{\"exists\": {\"value\": 1}}"; 180 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 181 + defer arena.deinit(); 182 + const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{}); 183 + const Thing = struct { value: i64 }; 184 + const exists = extractAtOptional(Thing, arena.allocator(), parsed.value, .{"exists"}); 185 + try std.testing.expect(exists != null); 186 + try std.testing.expectEqual(@as(i64, 1), exists.?.value); 187 + const missing = extractAtOptional(Thing, arena.allocator(), parsed.value, .{"missing"}); 188 + try std.testing.expect(missing == null); 189 + }