atproto utils for zig zat.dev
atproto sdk zig
at main 165 lines 5.6 kB view raw
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 7const std = @import("std"); 8const Did = @import("../syntax/did.zig").Did; 9const DidDocument = @import("did_document.zig").DidDocument; 10const HttpTransport = @import("../xrpc/transport.zig").HttpTransport; 11 12pub const DidResolver = struct { 13 allocator: std.mem.Allocator, 14 transport: HttpTransport, 15 16 /// plc directory url (default: https://plc.directory) 17 plc_url: []const u8 = "https://plc.directory", 18 19 pub fn init(allocator: std.mem.Allocator) DidResolver { 20 return initWithOptions(allocator, .{}); 21 } 22 23 pub const Options = struct { 24 keep_alive: bool = true, 25 }; 26 27 pub fn initWithOptions(allocator: std.mem.Allocator, options: Options) DidResolver { 28 var transport = HttpTransport.init(allocator); 29 transport.keep_alive = options.keep_alive; 30 return .{ 31 .allocator = allocator, 32 .transport = transport, 33 }; 34 } 35 36 pub fn deinit(self: *DidResolver) void { 37 self.transport.deinit(); 38 } 39 40 /// resolve a did to its document 41 pub fn resolve(self: *DidResolver, did: Did) !DidDocument { 42 return switch (did.method()) { 43 .plc => try self.resolvePlc(did), 44 .web => try self.resolveWeb(did), 45 .other => error.UnsupportedDidMethod, 46 }; 47 } 48 49 /// resolve did:plc via plc.directory 50 fn resolvePlc(self: *DidResolver, did: Did) !DidDocument { 51 // build url: {plc_url}/{did} 52 const url = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ self.plc_url, did.raw }); 53 defer self.allocator.free(url); 54 55 return try self.fetchDidDocument(url); 56 } 57 58 /// resolve did:web via .well-known 59 fn resolveWeb(self: *DidResolver, did: Did) !DidDocument { 60 // did:web:example.com -> https://example.com/.well-known/did.json 61 // did:web:example.com:path:to -> https://example.com/path/to/did.json 62 const domain_and_path = did.raw["did:web:".len..]; 63 64 // decode percent-encoded colons in path 65 var url_buf: std.ArrayList(u8) = .empty; 66 defer url_buf.deinit(self.allocator); 67 68 try url_buf.appendSlice(self.allocator, "https://"); 69 70 var first_segment = true; 71 var it = std.mem.splitScalar(u8, domain_and_path, ':'); 72 while (it.next()) |segment| { 73 if (first_segment) { 74 // first segment is the domain 75 try url_buf.appendSlice(self.allocator, segment); 76 first_segment = false; 77 } else { 78 // subsequent segments are path components 79 try url_buf.append(self.allocator, '/'); 80 try url_buf.appendSlice(self.allocator, segment); 81 } 82 } 83 84 // add .well-known/did.json or /did.json 85 if (std.mem.indexOf(u8, domain_and_path, ":") == null) { 86 // no path, use .well-known 87 try url_buf.appendSlice(self.allocator, "/.well-known/did.json"); 88 } else { 89 // has path, append did.json 90 try url_buf.appendSlice(self.allocator, "/did.json"); 91 } 92 93 return try self.fetchDidDocument(url_buf.items); 94 } 95 96 /// fetch and parse a did document from url 97 fn fetchDidDocument(self: *DidResolver, url: []const u8) !DidDocument { 98 const result = self.transport.fetch(.{ .url = url }) catch return error.DidResolutionFailed; 99 defer self.allocator.free(result.body); 100 101 if (result.status != .ok) { 102 return error.DidResolutionFailed; 103 } 104 105 return try DidDocument.parse(self.allocator, result.body); 106 } 107}; 108 109// === tests === 110 111test "resolve did:plc - integration" { 112 // use arena for http client internals that may leak 113 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 114 defer arena.deinit(); 115 116 var resolver = DidResolver.init(arena.allocator()); 117 defer resolver.deinit(); 118 119 const did = Did.parse("did:plc:z72i7hdynmk6r22z27h6tvur").?; 120 var doc = resolver.resolve(did) catch |err| { 121 // network errors are ok in CI, but compilation must succeed 122 std.debug.print("network error (expected in CI): {}\n", .{err}); 123 return; 124 }; 125 defer doc.deinit(); 126 127 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", doc.id); 128 try std.testing.expect(doc.handle() != null); 129} 130 131test "resolve did:plc - leak check (no arena)" { 132 // repro for memory leak report: use testing.allocator directly 133 // (no arena) to see if std.http.Client leaks on deinit 134 var resolver = DidResolver.init(std.testing.allocator); 135 defer resolver.deinit(); 136 137 const did = Did.parse("did:plc:z72i7hdynmk6r22z27h6tvur").?; 138 var doc = resolver.resolve(did) catch |err| { 139 std.debug.print("network error (expected in CI): {}\n", .{err}); 140 return; 141 }; 142 defer doc.deinit(); 143 144 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", doc.id); 145} 146 147test "did:web url construction" { 148 // test url building without network 149 var resolver = DidResolver.init(std.testing.allocator); 150 defer resolver.deinit(); 151 152 // simple domain 153 { 154 const did = Did.parse("did:web:example.com").?; 155 _ = did; 156 // would resolve to https://example.com/.well-known/did.json 157 } 158 159 // domain with path 160 { 161 const did = Did.parse("did:web:example.com:user:alice").?; 162 _ = did; 163 // would resolve to https://example.com/user/alice/did.json 164 } 165}