atproto utils for zig zat.dev
atproto sdk zig
at main 12 kB view raw
1//! JSON path helpers 2//! 3//! simplifies navigating nested json structures. 4//! eliminates the verbose nested if-checks. 5//! 6//! two approaches: 7//! - runtime paths: getString(value, "embed.external.uri") - for dynamic paths 8//! - comptime paths: extractAt(T, alloc, value, .{"embed", "external"}) - for static paths with type safety 9//! 10//! debug logging: 11//! enable with `pub const std_options = .{ .log_scope_levels = &.{.{ .scope = .zat, .level = .debug }} };` 12 13const std = @import("std"); 14const log = std.log.scoped(.zat); 15 16/// navigate a json value by dot-separated path 17/// returns null if any segment is missing or wrong type 18pub fn getPath(value: std.json.Value, path: []const u8) ?std.json.Value { 19 var current = value; 20 var it = std.mem.splitScalar(u8, path, '.'); 21 22 while (it.next()) |segment| { 23 switch (current) { 24 .object => |obj| { 25 current = obj.get(segment) orelse return null; 26 }, 27 .array => |arr| { 28 const idx = std.fmt.parseInt(usize, segment, 10) catch return null; 29 if (idx >= arr.items.len) return null; 30 current = arr.items[idx]; 31 }, 32 else => return null, 33 } 34 } 35 36 return current; 37} 38 39/// get a string at path 40pub fn getString(value: std.json.Value, path: []const u8) ?[]const u8 { 41 const v = getPath(value, path) orelse return null; 42 return switch (v) { 43 .string => |s| s, 44 else => null, 45 }; 46} 47 48/// get an integer at path 49pub fn getInt(value: std.json.Value, path: []const u8) ?i64 { 50 const v = getPath(value, path) orelse return null; 51 return switch (v) { 52 .integer => |i| i, 53 else => null, 54 }; 55} 56 57/// get a float at path 58pub fn getFloat(value: std.json.Value, path: []const u8) ?f64 { 59 const v = getPath(value, path) orelse return null; 60 return switch (v) { 61 .float => |f| f, 62 .integer => |i| @floatFromInt(i), 63 else => null, 64 }; 65} 66 67/// get a bool at path 68pub fn getBool(value: std.json.Value, path: []const u8) ?bool { 69 const v = getPath(value, path) orelse return null; 70 return switch (v) { 71 .bool => |b| b, 72 else => null, 73 }; 74} 75 76/// get an array at path 77pub fn getArray(value: std.json.Value, path: []const u8) ?[]std.json.Value { 78 const v = getPath(value, path) orelse return null; 79 return switch (v) { 80 .array => |a| a.items, 81 else => null, 82 }; 83} 84 85/// get an object at path 86pub fn getObject(value: std.json.Value, path: []const u8) ?std.json.ObjectMap { 87 const v = getPath(value, path) orelse return null; 88 return switch (v) { 89 .object => |o| o, 90 else => null, 91 }; 92} 93 94// === comptime path extraction === 95 96/// extract a typed struct from a nested path 97/// uses comptime tuple for path segments - no runtime string parsing 98/// leverages std.json.parseFromValueLeaky for type-safe extraction 99/// 100/// on failure, logs diagnostic info when debug logging is enabled for .zat scope 101pub fn extractAt( 102 comptime T: type, 103 allocator: std.mem.Allocator, 104 value: std.json.Value, 105 comptime path: anytype, 106) std.json.ParseFromValueError!T { 107 var current = value; 108 inline for (path) |segment| { 109 current = switch (current) { 110 .object => |obj| obj.get(segment) orelse { 111 log.debug("extractAt: missing field \"{s}\" in path {any}, expected {s}", .{ 112 segment, 113 path, 114 @typeName(T), 115 }); 116 return error.MissingField; 117 }, 118 else => { 119 log.debug("extractAt: expected object at \"{s}\" in path {any}, got {s}", .{ 120 segment, 121 path, 122 @tagName(current), 123 }); 124 return error.UnexpectedToken; 125 }, 126 }; 127 } 128 return std.json.parseFromValueLeaky(T, allocator, current, .{ .ignore_unknown_fields = true }) catch |err| { 129 log.debug("extractAt: parse failed for {s} at path {any}: {s} (json type: {s})", .{ 130 @typeName(T), 131 path, 132 @errorName(err), 133 @tagName(current), 134 }); 135 return err; 136 }; 137} 138 139/// extract a typed value, returning null if path doesn't exist 140pub fn extractAtOptional( 141 comptime T: type, 142 allocator: std.mem.Allocator, 143 value: std.json.Value, 144 comptime path: anytype, 145) ?T { 146 return extractAt(T, allocator, value, path) catch null; 147} 148 149// === tests === 150 151test "getPath simple" { 152 const json_str = 153 \\{"name": "alice", "age": 30} 154 ; 155 const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 156 defer parsed.deinit(); 157 158 try std.testing.expectEqualStrings("alice", getString(parsed.value, "name").?); 159 try std.testing.expectEqual(@as(i64, 30), getInt(parsed.value, "age").?); 160} 161 162test "getPath nested" { 163 const json_str = 164 \\{"embed": {"external": {"uri": "https://example.com"}}} 165 ; 166 const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 167 defer parsed.deinit(); 168 169 try std.testing.expectEqualStrings("https://example.com", getString(parsed.value, "embed.external.uri").?); 170} 171 172test "getPath array index" { 173 const json_str = 174 \\{"items": ["a", "b", "c"]} 175 ; 176 const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 177 defer parsed.deinit(); 178 179 try std.testing.expectEqualStrings("b", getString(parsed.value, "items.1").?); 180} 181 182test "getPath missing returns null" { 183 const json_str = 184 \\{"name": "alice"} 185 ; 186 const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 187 defer parsed.deinit(); 188 189 try std.testing.expect(getString(parsed.value, "missing") == null); 190 try std.testing.expect(getString(parsed.value, "name.nested") == null); 191} 192 193test "getPath deeply nested real-world example" { 194 // the exact painful example from user feedback 195 const json_str = 196 \\{ 197 \\ "embed": { 198 \\ "$type": "app.bsky.embed.external", 199 \\ "external": { 200 \\ "uri": "https://tangled.sh", 201 \\ "title": "Tangled", 202 \\ "description": "Git hosting on AT Protocol" 203 \\ } 204 \\ } 205 \\} 206 ; 207 const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 208 defer parsed.deinit(); 209 210 // instead of 6 nested if-checks: 211 const uri = getString(parsed.value, "embed.external.uri"); 212 try std.testing.expectEqualStrings("https://tangled.sh", uri.?); 213 214 const title = getString(parsed.value, "embed.external.title"); 215 try std.testing.expectEqualStrings("Tangled", title.?); 216} 217 218// === comptime extraction tests === 219 220test "extractAt struct" { 221 const json_str = 222 \\{ 223 \\ "embed": { 224 \\ "external": { 225 \\ "uri": "https://tangled.sh", 226 \\ "title": "Tangled" 227 \\ } 228 \\ } 229 \\} 230 ; 231 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 232 defer arena.deinit(); 233 234 const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{}); 235 236 const External = struct { 237 uri: []const u8, 238 title: []const u8, 239 }; 240 241 const ext = try extractAt(External, arena.allocator(), parsed.value, .{ "embed", "external" }); 242 try std.testing.expectEqualStrings("https://tangled.sh", ext.uri); 243 try std.testing.expectEqualStrings("Tangled", ext.title); 244} 245 246test "extractAt with optional fields" { 247 const json_str = 248 \\{ 249 \\ "user": { 250 \\ "name": "alice", 251 \\ "age": 30 252 \\ } 253 \\} 254 ; 255 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 256 defer arena.deinit(); 257 258 const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{}); 259 260 const User = struct { 261 name: []const u8, 262 age: i64, 263 bio: ?[]const u8 = null, 264 }; 265 266 const user = try extractAt(User, arena.allocator(), parsed.value, .{"user"}); 267 try std.testing.expectEqualStrings("alice", user.name); 268 try std.testing.expectEqual(@as(i64, 30), user.age); 269 try std.testing.expect(user.bio == null); 270} 271 272test "extractAt empty path extracts root" { 273 const json_str = 274 \\{"name": "root", "value": 42} 275 ; 276 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 277 defer arena.deinit(); 278 279 const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{}); 280 281 const Root = struct { 282 name: []const u8, 283 value: i64, 284 }; 285 286 const root = try extractAt(Root, arena.allocator(), parsed.value, .{}); 287 try std.testing.expectEqualStrings("root", root.name); 288 try std.testing.expectEqual(@as(i64, 42), root.value); 289} 290 291test "extractAtOptional returns null on missing path" { 292 const json_str = 293 \\{"exists": {"value": 1}} 294 ; 295 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 296 defer arena.deinit(); 297 298 const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{}); 299 300 const Thing = struct { value: i64 }; 301 302 const exists = extractAtOptional(Thing, arena.allocator(), parsed.value, .{"exists"}); 303 try std.testing.expect(exists != null); 304 try std.testing.expectEqual(@as(i64, 1), exists.?.value); 305 306 const missing = extractAtOptional(Thing, arena.allocator(), parsed.value, .{"missing"}); 307 try std.testing.expect(missing == null); 308} 309 310test "extractAt logs diagnostic on enum parse failure" { 311 // simulates the issue: unknown enum value from external API 312 const json_str = 313 \\{"op": {"action": "archive", "path": "app.bsky.feed.post/abc"}} 314 ; 315 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 316 defer arena.deinit(); 317 318 const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{}); 319 320 const Action = enum { create, update, delete }; 321 const Op = struct { 322 action: Action, 323 path: []const u8, 324 }; 325 326 // "archive" is not a valid Action variant - this should fail 327 // with debug logging enabled, you'd see: 328 // debug(zat): extractAt: parse failed for json.Op at path { "op" }: InvalidEnumTag (json type: object) 329 const result = extractAtOptional(Op, arena.allocator(), parsed.value, .{"op"}); 330 try std.testing.expect(result == null); 331} 332 333test "extractAt logs diagnostic on missing field" { 334 const json_str = 335 \\{"data": {"name": "test"}} 336 ; 337 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 338 defer arena.deinit(); 339 340 const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{}); 341 342 const Thing = struct { value: i64 }; 343 344 // path "data.missing" doesn't exist 345 // with debug logging enabled, you'd see: 346 // debug(zat): extractAt: missing field "missing" in path { "data", "missing" }, expected json.Thing 347 const result = extractAtOptional(Thing, arena.allocator(), parsed.value, .{ "data", "missing" }); 348 try std.testing.expect(result == null); 349} 350 351test "extractAt ignores unknown fields" { 352 // real-world case: TAP messages have extra fields (live, rev, cid) that we don't need 353 const json_str = 354 \\{ 355 \\ "record": { 356 \\ "live": true, 357 \\ "did": "did:plc:abc123", 358 \\ "rev": "3mbspmpaidl2a", 359 \\ "collection": "pub.leaflet.document", 360 \\ "rkey": "xyz789", 361 \\ "action": "create", 362 \\ "cid": "bafyreitest" 363 \\ } 364 \\} 365 ; 366 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 367 defer arena.deinit(); 368 369 const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), json_str, .{}); 370 371 // only extract the fields we care about 372 const Record = struct { 373 collection: []const u8, 374 action: []const u8, 375 did: []const u8, 376 rkey: []const u8, 377 }; 378 379 const rec = try extractAt(Record, arena.allocator(), parsed.value, .{"record"}); 380 try std.testing.expectEqualStrings("pub.leaflet.document", rec.collection); 381 try std.testing.expectEqualStrings("create", rec.action); 382 try std.testing.expectEqualStrings("did:plc:abc123", rec.did); 383 try std.testing.expectEqualStrings("xyz789", rec.rkey); 384}