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 6 //! two approaches: 7 7 //! - runtime paths: getString(value, "embed.external.uri") - for dynamic paths 8 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 }} };` 9 12 10 13 const std = @import("std"); 14 + const log = std.log.scoped(.zat); 11 15 12 16 /// navigate a json value by dot-separated path 13 17 /// returns null if any segment is missing or wrong type ··· 92 96 /// extract a typed struct from a nested path 93 97 /// uses comptime tuple for path segments - no runtime string parsing 94 98 /// leverages std.json.parseFromValueLeaky for type-safe extraction 99 + /// 100 + /// on failure, logs diagnostic info when debug logging is enabled for .zat scope 95 101 pub fn extractAt( 96 102 comptime T: type, 97 103 allocator: std.mem.Allocator, ··· 101 107 var current = value; 102 108 inline for (path) |segment| { 103 109 current = switch (current) { 104 - .object => |obj| obj.get(segment) orelse return error.MissingField, 105 - else => return error.UnexpectedToken, 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 + }, 106 126 }; 107 127 } 108 - return std.json.parseFromValueLeaky(T, allocator, current, .{}); 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 + }; 109 137 } 110 138 111 139 /// extract a typed value, returning null if path doesn't exist ··· 278 306 const missing = extractAtOptional(Thing, arena.allocator(), parsed.value, .{"missing"}); 279 307 try std.testing.expect(missing == null); 280 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 + }