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}