atproto utils for zig zat.dev
atproto sdk zig

add debug logging to extractAt for parse failures

enables diagnostic output when json parsing fails, particularly useful
for debugging enum mismatches from external APIs. users can enable via:

pub const std_options = .{
.log_scope_levels = &.{.{ .scope = .zat, .level = .debug }},
};

logs include: field name, path, expected type, actual json type, and error.
zero overhead when disabled - compiles away entirely.

closes #1

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

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

Changed files
+72 -3
src
internal
+72 -3
src/internal/json.zig
··· 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 const std = @import("std"); 11 12 /// navigate a json value by dot-separated path 13 /// returns null if any segment is missing or wrong type ··· 92 /// extract a typed struct from a nested path 93 /// uses comptime tuple for path segments - no runtime string parsing 94 /// leverages std.json.parseFromValueLeaky for type-safe extraction 95 pub fn extractAt( 96 comptime T: type, 97 allocator: std.mem.Allocator, ··· 101 var current = value; 102 inline for (path) |segment| { 103 current = switch (current) { 104 - .object => |obj| obj.get(segment) orelse return error.MissingField, 105 - else => return error.UnexpectedToken, 106 }; 107 } 108 - return std.json.parseFromValueLeaky(T, allocator, current, .{}); 109 } 110 111 /// extract a typed value, returning null if path doesn't exist ··· 278 const missing = extractAtOptional(Thing, arena.allocator(), parsed.value, .{"missing"}); 279 try std.testing.expect(missing == null); 280 }
··· 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 13 const std = @import("std"); 14 + const 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 ··· 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 101 pub fn extractAt( 102 comptime T: type, 103 allocator: std.mem.Allocator, ··· 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, .{}) 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 ··· 306 const missing = extractAtOptional(Thing, arena.allocator(), parsed.value, .{"missing"}); 307 try std.testing.expect(missing == null); 308 } 309 + 310 + test "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 + 333 + test "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 + }