atproto tools in zig zat.dev
sdk atproto zig

add did resolution, xrpc client, json helpers

- DidDocument: parse did documents from json
- DidResolver: resolve did:plc and did:web
- XrpcClient: call AT Protocol endpoints
- json helpers: navigate nested json with paths

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

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

+895 -3
+50 -3
README.md
··· 1 1 # zat 2 2 3 - zig primitives for AT Protocol string formats. 3 + zig primitives for AT Protocol. 4 4 5 5 ## install 6 6 ··· 20 20 ``` 21 21 22 22 ## what's here 23 + 24 + ### string primitives 23 25 24 26 parsing and validation for atproto string identifiers: 25 27 ··· 30 32 - **Rkey** - record keys 31 33 - **AtUri** - `at://` URIs 32 34 33 - all types follow a common pattern: `parse()` returns an optional, accessors extract components. 34 - 35 35 ```zig 36 36 const zat = @import("zat"); 37 37 ··· 40 40 const collection = uri.collection(); 41 41 const rkey = uri.rkey(); 42 42 } 43 + ``` 44 + 45 + ### did resolution 46 + 47 + resolve `did:plc` and `did:web` identifiers to their documents: 48 + 49 + ```zig 50 + var resolver = zat.DidResolver.init(allocator); 51 + defer resolver.deinit(); 52 + 53 + const did = zat.Did.parse("did:plc:z72i7hdynmk6r22z27h6tvur").?; 54 + var doc = try resolver.resolve(did); 55 + defer doc.deinit(); 56 + 57 + const handle = doc.handle(); // "jay.bsky.social" 58 + const pds = doc.pdsEndpoint(); // "https://..." 59 + const key = doc.signingKey(); // verification method 60 + ``` 61 + 62 + ### xrpc client 63 + 64 + call AT Protocol endpoints: 65 + 66 + ```zig 67 + var client = zat.XrpcClient.init(allocator, "https://bsky.social"); 68 + defer client.deinit(); 69 + 70 + const nsid = zat.Nsid.parse("app.bsky.actor.getProfile").?; 71 + var response = try client.query(nsid, params); 72 + defer response.deinit(); 73 + 74 + if (response.ok()) { 75 + var json = try response.json(); 76 + defer json.deinit(); 77 + // use json.value 78 + } 79 + ``` 80 + 81 + ### json helpers 82 + 83 + navigate nested json without verbose if-chains: 84 + 85 + ```zig 86 + // instead of 6 nested if-checks: 87 + const uri = zat.json.getString(value, "embed.external.uri"); 88 + const items = zat.json.getArray(value, "posts"); 89 + const count = zat.json.getInt(value, "meta.count"); 43 90 ``` 44 91 45 92 ## specs
+98
docs/plan-expanded.md
··· 1 + # zat - expanded scope 2 + 3 + the initial release delivered string primitives (Tid, Did, Handle, Nsid, Rkey, AtUri). this plan expands toward a usable AT Protocol sdk. 4 + 5 + ## motivation 6 + 7 + real-world usage shows repeated implementations of: 8 + - DID resolution (plc.directory lookups, did:web fetches) 9 + - JWT parsing and signature verification 10 + - ECDSA verification (P256, secp256k1) 11 + - base58/base64url decoding 12 + - XRPC calls with manual json navigation 13 + 14 + this is shared infrastructure across any atproto app. zat can absorb it incrementally. 15 + 16 + ## next: did resolution 17 + 18 + ```zig 19 + pub const DidResolver = struct { 20 + /// resolve a did to its document 21 + pub fn resolve(self: *DidResolver, did: Did) !DidDocument 22 + 23 + /// resolve did:plc via plc.directory 24 + fn resolvePlc(self: *DidResolver, id: []const u8) !DidDocument 25 + 26 + /// resolve did:web via .well-known 27 + fn resolveWeb(self: *DidResolver, domain: []const u8) !DidDocument 28 + }; 29 + 30 + pub const DidDocument = struct { 31 + id: Did, 32 + also_known_as: [][]const u8, // handles 33 + verification_methods: []VerificationMethod, 34 + services: []Service, 35 + 36 + pub fn pdsEndpoint(self: DidDocument) ?[]const u8 37 + pub fn handle(self: DidDocument) ?[]const u8 38 + }; 39 + ``` 40 + 41 + ## next: cid (content identifiers) 42 + 43 + ```zig 44 + pub const Cid = struct { 45 + raw: []const u8, 46 + 47 + pub fn parse(s: []const u8) ?Cid 48 + pub fn version(self: Cid) u8 49 + pub fn codec(self: Cid) u64 50 + pub fn hash(self: Cid) []const u8 51 + }; 52 + ``` 53 + 54 + ## later: xrpc client 55 + 56 + ```zig 57 + pub const XrpcClient = struct { 58 + pds: []const u8, 59 + access_token: ?[]const u8, 60 + 61 + pub fn query(self: *XrpcClient, nsid: Nsid, params: anytype) !JsonValue 62 + pub fn procedure(self: *XrpcClient, nsid: Nsid, input: anytype) !JsonValue 63 + }; 64 + ``` 65 + 66 + ## later: jwt verification 67 + 68 + ```zig 69 + pub const Jwt = struct { 70 + header: JwtHeader, 71 + payload: JwtPayload, 72 + signature: []const u8, 73 + 74 + pub fn parse(token: []const u8) ?Jwt 75 + pub fn verify(self: Jwt, public_key: PublicKey) bool 76 + }; 77 + ``` 78 + 79 + ## out of scope 80 + 81 + - lexicon codegen (separate project) 82 + - session management / token refresh (app-specific) 83 + - jetstream client (websocket.zig + json is enough) 84 + - application frameworks (too opinionated) 85 + 86 + ## design principles 87 + 88 + 1. **layered** - each piece usable independently (use Did without DidResolver) 89 + 2. **explicit** - no hidden allocations, pass allocators where needed 90 + 3. **borrowing** - parse returns slices into input where possible 91 + 4. **fallible** - return errors/optionals, don't panic 92 + 5. **protocol-focused** - AT Protocol primitives, not app-specific features 93 + 94 + ## open questions 95 + 96 + - should DidResolver cache? or leave that to caller? 97 + - should XrpcClient handle auth refresh? or just expose tokens? 98 + - how to handle json parsing without imposing a specific json library?
docs/plan.md docs/plan-initial.md
+257
src/internal/did_document.zig
··· 1 + //! DID Document - resolved identity information 2 + //! 3 + //! a did document contains: 4 + //! - handle (from alsoKnownAs) 5 + //! - signing key (from verificationMethod) 6 + //! - pds endpoint (from service) 7 + //! 8 + //! see: https://atproto.com/specs/did 9 + 10 + const std = @import("std"); 11 + const Did = @import("did.zig").Did; 12 + 13 + pub const DidDocument = struct { 14 + allocator: std.mem.Allocator, 15 + 16 + /// the did this document describes 17 + id: []const u8, 18 + 19 + /// handles (from alsoKnownAs, stripped of at:// prefix) 20 + handles: [][]const u8, 21 + 22 + /// verification methods (signing keys) 23 + verification_methods: []VerificationMethod, 24 + 25 + /// services (pds endpoints) 26 + services: []Service, 27 + 28 + pub const VerificationMethod = struct { 29 + id: []const u8, 30 + type: []const u8, 31 + controller: []const u8, 32 + public_key_multibase: []const u8, 33 + }; 34 + 35 + pub const Service = struct { 36 + id: []const u8, 37 + type: []const u8, 38 + service_endpoint: []const u8, 39 + }; 40 + 41 + /// get the primary handle (first valid one) 42 + pub fn handle(self: DidDocument) ?[]const u8 { 43 + if (self.handles.len == 0) return null; 44 + return self.handles[0]; 45 + } 46 + 47 + /// get the atproto signing key 48 + pub fn signingKey(self: DidDocument) ?VerificationMethod { 49 + for (self.verification_methods) |vm| { 50 + if (std.mem.endsWith(u8, vm.id, "#atproto")) { 51 + return vm; 52 + } 53 + } 54 + return null; 55 + } 56 + 57 + /// get the pds endpoint 58 + pub fn pdsEndpoint(self: DidDocument) ?[]const u8 { 59 + for (self.services) |svc| { 60 + if (std.mem.endsWith(u8, svc.id, "#atproto_pds")) { 61 + return svc.service_endpoint; 62 + } 63 + } 64 + return null; 65 + } 66 + 67 + /// parse a did document from json 68 + pub fn parse(allocator: std.mem.Allocator, json_str: []const u8) !DidDocument { 69 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_str, .{}); 70 + defer parsed.deinit(); 71 + 72 + return try parseValue(allocator, parsed.value); 73 + } 74 + 75 + /// parse from an already-parsed json value 76 + pub fn parseValue(allocator: std.mem.Allocator, root: std.json.Value) !DidDocument { 77 + if (root != .object) return error.InvalidDidDocument; 78 + const obj = root.object; 79 + 80 + // id is required 81 + const id = if (obj.get("id")) |v| switch (v) { 82 + .string => |s| try allocator.dupe(u8, s), 83 + else => return error.InvalidDidDocument, 84 + } else return error.InvalidDidDocument; 85 + errdefer allocator.free(id); 86 + 87 + // parse alsoKnownAs -> handles 88 + var handles = std.ArrayList([]const u8).init(allocator); 89 + errdefer { 90 + for (handles.items) |h| allocator.free(h); 91 + handles.deinit(); 92 + } 93 + 94 + if (obj.get("alsoKnownAs")) |aka| { 95 + if (aka == .array) { 96 + for (aka.array.items) |item| { 97 + if (item == .string) { 98 + const s = item.string; 99 + // strip at:// prefix if present 100 + const h = if (std.mem.startsWith(u8, s, "at://")) 101 + s[5..] 102 + else 103 + s; 104 + try handles.append(try allocator.dupe(u8, h)); 105 + } 106 + } 107 + } 108 + } 109 + 110 + // parse verificationMethod 111 + var vms = std.ArrayList(VerificationMethod).init(allocator); 112 + errdefer { 113 + for (vms.items) |vm| { 114 + allocator.free(vm.id); 115 + allocator.free(vm.type); 116 + allocator.free(vm.controller); 117 + allocator.free(vm.public_key_multibase); 118 + } 119 + vms.deinit(); 120 + } 121 + 122 + if (obj.get("verificationMethod")) |vm_arr| { 123 + if (vm_arr == .array) { 124 + for (vm_arr.array.items) |item| { 125 + if (item == .object) { 126 + const vm_obj = item.object; 127 + const vm = VerificationMethod{ 128 + .id = try allocator.dupe(u8, getStr(vm_obj, "id") orelse continue), 129 + .type = try allocator.dupe(u8, getStr(vm_obj, "type") orelse ""), 130 + .controller = try allocator.dupe(u8, getStr(vm_obj, "controller") orelse ""), 131 + .public_key_multibase = try allocator.dupe(u8, getStr(vm_obj, "publicKeyMultibase") orelse ""), 132 + }; 133 + try vms.append(vm); 134 + } 135 + } 136 + } 137 + } 138 + 139 + // parse service 140 + var svcs = std.ArrayList(Service).init(allocator); 141 + errdefer { 142 + for (svcs.items) |svc| { 143 + allocator.free(svc.id); 144 + allocator.free(svc.type); 145 + allocator.free(svc.service_endpoint); 146 + } 147 + svcs.deinit(); 148 + } 149 + 150 + if (obj.get("service")) |svc_arr| { 151 + if (svc_arr == .array) { 152 + for (svc_arr.array.items) |item| { 153 + if (item == .object) { 154 + const svc_obj = item.object; 155 + const svc = Service{ 156 + .id = try allocator.dupe(u8, getStr(svc_obj, "id") orelse continue), 157 + .type = try allocator.dupe(u8, getStr(svc_obj, "type") orelse ""), 158 + .service_endpoint = try allocator.dupe(u8, getStr(svc_obj, "serviceEndpoint") orelse ""), 159 + }; 160 + try svcs.append(svc); 161 + } 162 + } 163 + } 164 + } 165 + 166 + return .{ 167 + .allocator = allocator, 168 + .id = id, 169 + .handles = try handles.toOwnedSlice(), 170 + .verification_methods = try vms.toOwnedSlice(), 171 + .services = try svcs.toOwnedSlice(), 172 + }; 173 + } 174 + 175 + pub fn deinit(self: *DidDocument) void { 176 + for (self.handles) |h| self.allocator.free(h); 177 + self.allocator.free(self.handles); 178 + 179 + for (self.verification_methods) |vm| { 180 + self.allocator.free(vm.id); 181 + self.allocator.free(vm.type); 182 + self.allocator.free(vm.controller); 183 + self.allocator.free(vm.public_key_multibase); 184 + } 185 + self.allocator.free(self.verification_methods); 186 + 187 + for (self.services) |svc| { 188 + self.allocator.free(svc.id); 189 + self.allocator.free(svc.type); 190 + self.allocator.free(svc.service_endpoint); 191 + } 192 + self.allocator.free(self.services); 193 + 194 + self.allocator.free(self.id); 195 + } 196 + 197 + fn getStr(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 { 198 + if (obj.get(key)) |v| { 199 + if (v == .string) return v.string; 200 + } 201 + return null; 202 + } 203 + }; 204 + 205 + // === tests === 206 + 207 + test "parse did document" { 208 + const json = 209 + \\{ 210 + \\ "id": "did:plc:z72i7hdynmk6r22z27h6tvur", 211 + \\ "alsoKnownAs": ["at://jay.bsky.social"], 212 + \\ "verificationMethod": [ 213 + \\ { 214 + \\ "id": "did:plc:z72i7hdynmk6r22z27h6tvur#atproto", 215 + \\ "type": "Multikey", 216 + \\ "controller": "did:plc:z72i7hdynmk6r22z27h6tvur", 217 + \\ "publicKeyMultibase": "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 218 + \\ } 219 + \\ ], 220 + \\ "service": [ 221 + \\ { 222 + \\ "id": "#atproto_pds", 223 + \\ "type": "AtprotoPersonalDataServer", 224 + \\ "serviceEndpoint": "https://shimeji.us-east.host.bsky.network" 225 + \\ } 226 + \\ ] 227 + \\} 228 + ; 229 + 230 + var doc = try DidDocument.parse(std.testing.allocator, json); 231 + defer doc.deinit(); 232 + 233 + try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", doc.id); 234 + try std.testing.expectEqualStrings("jay.bsky.social", doc.handle().?); 235 + try std.testing.expectEqualStrings("https://shimeji.us-east.host.bsky.network", doc.pdsEndpoint().?); 236 + 237 + const key = doc.signingKey().?; 238 + try std.testing.expect(std.mem.endsWith(u8, key.id, "#atproto")); 239 + } 240 + 241 + test "parse did document with no handle" { 242 + const json = 243 + \\{ 244 + \\ "id": "did:plc:test123", 245 + \\ "alsoKnownAs": [], 246 + \\ "verificationMethod": [], 247 + \\ "service": [] 248 + \\} 249 + ; 250 + 251 + var doc = try DidDocument.parse(std.testing.allocator, json); 252 + defer doc.deinit(); 253 + 254 + try std.testing.expect(doc.handle() == null); 255 + try std.testing.expect(doc.pdsEndpoint() == null); 256 + try std.testing.expect(doc.signingKey() == null); 257 + }
+152
src/internal/did_resolver.zig
··· 1 + //! DID Resolver - fetches and parses DID documents 2 + //! 3 + //! resolves did:plc via plc.directory and did:web via .well-known/did.json 4 + //! 5 + //! see: https://atproto.com/specs/did 6 + 7 + const std = @import("std"); 8 + const Did = @import("did.zig").Did; 9 + const DidDocument = @import("did_document.zig").DidDocument; 10 + 11 + pub const DidResolver = struct { 12 + allocator: std.mem.Allocator, 13 + http_client: std.http.Client, 14 + 15 + /// plc directory url (default: https://plc.directory) 16 + plc_url: []const u8 = "https://plc.directory", 17 + 18 + pub fn init(allocator: std.mem.Allocator) DidResolver { 19 + return .{ 20 + .allocator = allocator, 21 + .http_client = std.http.Client{ .allocator = allocator }, 22 + }; 23 + } 24 + 25 + pub fn deinit(self: *DidResolver) void { 26 + self.http_client.deinit(); 27 + } 28 + 29 + /// resolve a did to its document 30 + pub fn resolve(self: *DidResolver, did: Did) !DidDocument { 31 + return switch (did.method()) { 32 + .plc => try self.resolvePlc(did), 33 + .web => try self.resolveWeb(did), 34 + }; 35 + } 36 + 37 + /// resolve did:plc via plc.directory 38 + fn resolvePlc(self: *DidResolver, did: Did) !DidDocument { 39 + // build url: {plc_url}/{did} 40 + const url = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ self.plc_url, did.raw }); 41 + defer self.allocator.free(url); 42 + 43 + return try self.fetchDidDocument(url); 44 + } 45 + 46 + /// resolve did:web via .well-known 47 + fn resolveWeb(self: *DidResolver, did: Did) !DidDocument { 48 + // did:web:example.com -> https://example.com/.well-known/did.json 49 + // did:web:example.com:path:to -> https://example.com/path/to/did.json 50 + const domain_and_path = did.raw["did:web:".len..]; 51 + 52 + // decode percent-encoded colons in path 53 + var url_buf = std.ArrayList(u8).init(self.allocator); 54 + defer url_buf.deinit(); 55 + 56 + try url_buf.appendSlice("https://"); 57 + 58 + var first_segment = true; 59 + var it = std.mem.splitScalar(u8, domain_and_path, ':'); 60 + while (it.next()) |segment| { 61 + if (first_segment) { 62 + // first segment is the domain 63 + try url_buf.appendSlice(segment); 64 + first_segment = false; 65 + } else { 66 + // subsequent segments are path components 67 + try url_buf.append('/'); 68 + try url_buf.appendSlice(segment); 69 + } 70 + } 71 + 72 + // add .well-known/did.json or /did.json 73 + if (std.mem.indexOf(u8, domain_and_path, ":") == null) { 74 + // no path, use .well-known 75 + try url_buf.appendSlice("/.well-known/did.json"); 76 + } else { 77 + // has path, append did.json 78 + try url_buf.appendSlice("/did.json"); 79 + } 80 + 81 + return try self.fetchDidDocument(url_buf.items); 82 + } 83 + 84 + /// fetch and parse a did document from url 85 + fn fetchDidDocument(self: *DidResolver, url: []const u8) !DidDocument { 86 + const uri = try std.Uri.parse(url); 87 + 88 + var header_buf: [4096]u8 = undefined; 89 + var req = try self.http_client.open(.GET, uri, .{ 90 + .server_header_buffer = &header_buf, 91 + }); 92 + defer req.deinit(); 93 + 94 + try req.send(); 95 + try req.wait(); 96 + 97 + if (req.status != .ok) { 98 + return error.DidResolutionFailed; 99 + } 100 + 101 + // read response body 102 + var body = std.ArrayList(u8).init(self.allocator); 103 + defer body.deinit(); 104 + 105 + var buf: [4096]u8 = undefined; 106 + while (true) { 107 + const n = try req.reader().read(&buf); 108 + if (n == 0) break; 109 + try body.appendSlice(buf[0..n]); 110 + } 111 + 112 + return try DidDocument.parse(self.allocator, body.items); 113 + } 114 + }; 115 + 116 + // === tests === 117 + 118 + test "resolve did:plc" { 119 + // this is an integration test - requires network 120 + if (true) return error.SkipZigTest; // skip by default 121 + 122 + var resolver = DidResolver.init(std.testing.allocator); 123 + defer resolver.deinit(); 124 + 125 + // jay's did 126 + const did = Did.parse("did:plc:z72i7hdynmk6r22z27h6tvur").?; 127 + var doc = try resolver.resolve(did); 128 + defer doc.deinit(); 129 + 130 + try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", doc.id); 131 + try std.testing.expect(doc.handle() != null); 132 + } 133 + 134 + test "did:web url construction" { 135 + // test url building without network 136 + var resolver = DidResolver.init(std.testing.allocator); 137 + defer resolver.deinit(); 138 + 139 + // simple domain 140 + { 141 + const did = Did.parse("did:web:example.com").?; 142 + _ = did; 143 + // would resolve to https://example.com/.well-known/did.json 144 + } 145 + 146 + // domain with path 147 + { 148 + const did = Did.parse("did:web:example.com:user:alice").?; 149 + _ = did; 150 + // would resolve to https://example.com/user/alice/did.json 151 + } 152 + }
+153
src/internal/json.zig
··· 1 + //! JSON path helpers 2 + //! 3 + //! simplifies navigating nested json structures. 4 + //! eliminates the verbose nested if-checks. 5 + 6 + const std = @import("std"); 7 + 8 + /// navigate a json value by dot-separated path 9 + /// returns null if any segment is missing or wrong type 10 + pub fn getPath(value: std.json.Value, path: []const u8) ?std.json.Value { 11 + var current = value; 12 + var it = std.mem.splitScalar(u8, path, '.'); 13 + 14 + while (it.next()) |segment| { 15 + switch (current) { 16 + .object => |obj| { 17 + current = obj.get(segment) orelse return null; 18 + }, 19 + .array => |arr| { 20 + const idx = std.fmt.parseInt(usize, segment, 10) catch return null; 21 + if (idx >= arr.items.len) return null; 22 + current = arr.items[idx]; 23 + }, 24 + else => return null, 25 + } 26 + } 27 + 28 + return current; 29 + } 30 + 31 + /// get a string at path 32 + pub fn getString(value: std.json.Value, path: []const u8) ?[]const u8 { 33 + const v = getPath(value, path) orelse return null; 34 + return switch (v) { 35 + .string => |s| s, 36 + else => null, 37 + }; 38 + } 39 + 40 + /// get an integer at path 41 + pub fn getInt(value: std.json.Value, path: []const u8) ?i64 { 42 + const v = getPath(value, path) orelse return null; 43 + return switch (v) { 44 + .integer => |i| i, 45 + else => null, 46 + }; 47 + } 48 + 49 + /// get a float at path 50 + pub fn getFloat(value: std.json.Value, path: []const u8) ?f64 { 51 + const v = getPath(value, path) orelse return null; 52 + return switch (v) { 53 + .float => |f| f, 54 + .integer => |i| @floatFromInt(i), 55 + else => null, 56 + }; 57 + } 58 + 59 + /// get a bool at path 60 + pub fn getBool(value: std.json.Value, path: []const u8) ?bool { 61 + const v = getPath(value, path) orelse return null; 62 + return switch (v) { 63 + .bool => |b| b, 64 + else => null, 65 + }; 66 + } 67 + 68 + /// get an array at path 69 + pub fn getArray(value: std.json.Value, path: []const u8) ?[]std.json.Value { 70 + const v = getPath(value, path) orelse return null; 71 + return switch (v) { 72 + .array => |a| a.items, 73 + else => null, 74 + }; 75 + } 76 + 77 + /// get an object at path 78 + pub fn getObject(value: std.json.Value, path: []const u8) ?std.json.ObjectMap { 79 + const v = getPath(value, path) orelse return null; 80 + return switch (v) { 81 + .object => |o| o, 82 + else => null, 83 + }; 84 + } 85 + 86 + // === tests === 87 + 88 + test "getPath simple" { 89 + const json_str = 90 + \\{"name": "alice", "age": 30} 91 + ; 92 + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 93 + defer parsed.deinit(); 94 + 95 + try std.testing.expectEqualStrings("alice", getString(parsed.value, "name").?); 96 + try std.testing.expectEqual(@as(i64, 30), getInt(parsed.value, "age").?); 97 + } 98 + 99 + test "getPath nested" { 100 + const json_str = 101 + \\{"embed": {"external": {"uri": "https://example.com"}}} 102 + ; 103 + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 104 + defer parsed.deinit(); 105 + 106 + try std.testing.expectEqualStrings("https://example.com", getString(parsed.value, "embed.external.uri").?); 107 + } 108 + 109 + test "getPath array index" { 110 + const json_str = 111 + \\{"items": ["a", "b", "c"]} 112 + ; 113 + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 114 + defer parsed.deinit(); 115 + 116 + try std.testing.expectEqualStrings("b", getString(parsed.value, "items.1").?); 117 + } 118 + 119 + test "getPath missing returns null" { 120 + const json_str = 121 + \\{"name": "alice"} 122 + ; 123 + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 124 + defer parsed.deinit(); 125 + 126 + try std.testing.expect(getString(parsed.value, "missing") == null); 127 + try std.testing.expect(getString(parsed.value, "name.nested") == null); 128 + } 129 + 130 + test "getPath deeply nested real-world example" { 131 + // the exact painful example from user feedback 132 + const json_str = 133 + \\{ 134 + \\ "embed": { 135 + \\ "$type": "app.bsky.embed.external", 136 + \\ "external": { 137 + \\ "uri": "https://tangled.sh", 138 + \\ "title": "Tangled", 139 + \\ "description": "Git hosting on AT Protocol" 140 + \\ } 141 + \\ } 142 + \\} 143 + ; 144 + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json_str, .{}); 145 + defer parsed.deinit(); 146 + 147 + // instead of 6 nested if-checks: 148 + const uri = getString(parsed.value, "embed.external.uri"); 149 + try std.testing.expectEqualStrings("https://tangled.sh", uri.?); 150 + 151 + const title = getString(parsed.value, "embed.external.title"); 152 + try std.testing.expectEqualStrings("Tangled", title.?); 153 + }
+173
src/internal/xrpc.zig
··· 1 + //! XRPC Client - AT Protocol RPC calls 2 + //! 3 + //! simplifies calling AT Protocol endpoints. 4 + //! handles query (GET) and procedure (POST) methods. 5 + //! 6 + //! see: https://atproto.com/specs/xrpc 7 + 8 + const std = @import("std"); 9 + const Nsid = @import("nsid.zig").Nsid; 10 + 11 + pub const XrpcClient = struct { 12 + allocator: std.mem.Allocator, 13 + http_client: std.http.Client, 14 + 15 + /// pds or appview host (e.g., "https://bsky.social") 16 + host: []const u8, 17 + 18 + /// bearer token for authenticated requests 19 + access_token: ?[]const u8 = null, 20 + 21 + pub fn init(allocator: std.mem.Allocator, host: []const u8) XrpcClient { 22 + return .{ 23 + .allocator = allocator, 24 + .http_client = std.http.Client{ .allocator = allocator }, 25 + .host = host, 26 + }; 27 + } 28 + 29 + pub fn deinit(self: *XrpcClient) void { 30 + self.http_client.deinit(); 31 + } 32 + 33 + /// set bearer token for authenticated requests 34 + pub fn setAuth(self: *XrpcClient, token: []const u8) void { 35 + self.access_token = token; 36 + } 37 + 38 + /// call a query method (GET) 39 + pub fn query(self: *XrpcClient, nsid: Nsid, params: ?std.StringHashMap([]const u8)) !Response { 40 + const url = try self.buildUrl(nsid, params); 41 + defer self.allocator.free(url); 42 + 43 + return try self.doRequest(.GET, url, null); 44 + } 45 + 46 + /// call a procedure method (POST) 47 + pub fn procedure(self: *XrpcClient, nsid: Nsid, body: ?[]const u8) !Response { 48 + const url = try self.buildUrl(nsid, null); 49 + defer self.allocator.free(url); 50 + 51 + return try self.doRequest(.POST, url, body); 52 + } 53 + 54 + fn buildUrl(self: *XrpcClient, nsid: Nsid, params: ?std.StringHashMap([]const u8)) ![]u8 { 55 + var url = std.ArrayList(u8).init(self.allocator); 56 + errdefer url.deinit(); 57 + 58 + try url.appendSlice(self.host); 59 + try url.appendSlice("/xrpc/"); 60 + try url.appendSlice(nsid.raw); 61 + 62 + if (params) |p| { 63 + var first = true; 64 + var it = p.iterator(); 65 + while (it.next()) |entry| { 66 + try url.append(if (first) '?' else '&'); 67 + first = false; 68 + try url.appendSlice(entry.key_ptr.*); 69 + try url.append('='); 70 + // url encode value 71 + for (entry.value_ptr.*) |c| { 72 + if (std.ascii.isAlphanumeric(c) or c == '-' or c == '_' or c == '.' or c == '~') { 73 + try url.append(c); 74 + } else { 75 + try url.writer().print("%{X:0>2}", .{c}); 76 + } 77 + } 78 + } 79 + } 80 + 81 + return try url.toOwnedSlice(); 82 + } 83 + 84 + fn doRequest(self: *XrpcClient, method: std.http.Method, url: []const u8, body: ?[]const u8) !Response { 85 + const uri = try std.Uri.parse(url); 86 + 87 + var header_buf: [8192]u8 = undefined; 88 + var req = try self.http_client.open(method, uri, .{ 89 + .server_header_buffer = &header_buf, 90 + .extra_headers = if (self.access_token) |token| &.{ 91 + .{ .name = "Authorization", .value = try std.fmt.allocPrint(self.allocator, "Bearer {s}", .{token}) }, 92 + } else &.{}, 93 + }); 94 + defer req.deinit(); 95 + 96 + req.transfer_encoding = if (body) |b| .{ .content_length = b.len } else .none; 97 + 98 + try req.send(); 99 + 100 + if (body) |b| { 101 + try req.writer().writeAll(b); 102 + try req.finish(); 103 + } 104 + 105 + try req.wait(); 106 + 107 + // read response body 108 + var response_body = std.ArrayList(u8).init(self.allocator); 109 + errdefer response_body.deinit(); 110 + 111 + var buf: [4096]u8 = undefined; 112 + while (true) { 113 + const n = try req.reader().read(&buf); 114 + if (n == 0) break; 115 + try response_body.appendSlice(buf[0..n]); 116 + } 117 + 118 + return .{ 119 + .allocator = self.allocator, 120 + .status = req.status, 121 + .body = try response_body.toOwnedSlice(), 122 + }; 123 + } 124 + 125 + pub const Response = struct { 126 + allocator: std.mem.Allocator, 127 + status: std.http.Status, 128 + body: []u8, 129 + 130 + pub fn deinit(self: *Response) void { 131 + self.allocator.free(self.body); 132 + } 133 + 134 + /// check if request succeeded 135 + pub fn ok(self: Response) bool { 136 + return self.status == .ok; 137 + } 138 + 139 + /// parse body as json 140 + pub fn json(self: Response) !std.json.Parsed(std.json.Value) { 141 + return try std.json.parseFromSlice(std.json.Value, self.allocator, self.body, .{}); 142 + } 143 + }; 144 + }; 145 + 146 + // === tests === 147 + 148 + test "build url without params" { 149 + var client = XrpcClient.init(std.testing.allocator, "https://bsky.social"); 150 + defer client.deinit(); 151 + 152 + const nsid = Nsid.parse("app.bsky.actor.getProfile").?; 153 + const url = try client.buildUrl(nsid, null); 154 + defer std.testing.allocator.free(url); 155 + 156 + try std.testing.expectEqualStrings("https://bsky.social/xrpc/app.bsky.actor.getProfile", url); 157 + } 158 + 159 + test "build url with params" { 160 + var client = XrpcClient.init(std.testing.allocator, "https://bsky.social"); 161 + defer client.deinit(); 162 + 163 + var params = std.StringHashMap([]const u8).init(std.testing.allocator); 164 + defer params.deinit(); 165 + try params.put("actor", "did:plc:test123"); 166 + 167 + const nsid = Nsid.parse("app.bsky.actor.getProfile").?; 168 + const url = try client.buildUrl(nsid, params); 169 + defer std.testing.allocator.free(url); 170 + 171 + try std.testing.expect(std.mem.startsWith(u8, url, "https://bsky.social/xrpc/app.bsky.actor.getProfile?")); 172 + try std.testing.expect(std.mem.indexOf(u8, url, "actor=did%3Aplc%3Atest123") != null); 173 + }
+12
src/root.zig
··· 1 1 //! zat - zig atproto primitives 2 2 //! 3 3 //! parsing and validation for AT Protocol string formats. 4 + //! DID resolution for did:plc and did:web. 4 5 6 + // string primitives 5 7 pub const Tid = @import("internal/tid.zig").Tid; 6 8 pub const Did = @import("internal/did.zig").Did; 7 9 pub const Handle = @import("internal/handle.zig").Handle; 8 10 pub const Nsid = @import("internal/nsid.zig").Nsid; 9 11 pub const Rkey = @import("internal/rkey.zig").Rkey; 10 12 pub const AtUri = @import("internal/at_uri.zig").AtUri; 13 + 14 + // did resolution 15 + pub const DidDocument = @import("internal/did_document.zig").DidDocument; 16 + pub const DidResolver = @import("internal/did_resolver.zig").DidResolver; 17 + 18 + // xrpc 19 + pub const XrpcClient = @import("internal/xrpc.zig").XrpcClient; 20 + 21 + // json helpers 22 + pub const json = @import("internal/json.zig");