atproto utils for zig zat.dev
atproto sdk zig
at main 5.8 kB view raw
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 8const std = @import("std"); 9const Nsid = @import("nsid.zig").Nsid; 10 11pub 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 /// atproto JWTs are ~1KB; buffer needs room for "Bearer " prefix 22 const max_auth_header_len = 2048; 23 24 pub fn init(allocator: std.mem.Allocator, host: []const u8) XrpcClient { 25 return .{ 26 .allocator = allocator, 27 .http_client = .{ .allocator = allocator }, 28 .host = host, 29 }; 30 } 31 32 pub fn deinit(self: *XrpcClient) void { 33 self.http_client.deinit(); 34 } 35 36 /// set bearer token for authenticated requests 37 pub fn setAuth(self: *XrpcClient, token: []const u8) void { 38 self.access_token = token; 39 } 40 41 /// call a query method (GET) 42 pub fn query(self: *XrpcClient, nsid: Nsid, params: ?std.StringHashMap([]const u8)) !Response { 43 const url = try self.buildUrl(nsid, params); 44 defer self.allocator.free(url); 45 46 return try self.doRequest(url, null); 47 } 48 49 /// call a procedure method (POST) 50 pub fn procedure(self: *XrpcClient, nsid: Nsid, body: ?[]const u8) !Response { 51 const url = try self.buildUrl(nsid, null); 52 defer self.allocator.free(url); 53 54 return try self.doRequest(url, body); 55 } 56 57 fn buildUrl(self: *XrpcClient, nsid: Nsid, params: ?std.StringHashMap([]const u8)) ![]u8 { 58 var url: std.ArrayList(u8) = .empty; 59 errdefer url.deinit(self.allocator); 60 61 try url.appendSlice(self.allocator, self.host); 62 try url.appendSlice(self.allocator, "/xrpc/"); 63 try url.appendSlice(self.allocator, nsid.raw); 64 65 if (params) |p| { 66 var first = true; 67 var it = p.iterator(); 68 while (it.next()) |entry| { 69 try url.append(self.allocator, if (first) '?' else '&'); 70 first = false; 71 try url.appendSlice(self.allocator, entry.key_ptr.*); 72 try url.append(self.allocator, '='); 73 // url encode value 74 for (entry.value_ptr.*) |c| { 75 if (std.ascii.isAlphanumeric(c) or c == '-' or c == '_' or c == '.' or c == '~') { 76 try url.append(self.allocator, c); 77 } else { 78 try url.print(self.allocator, "%{X:0>2}", .{c}); 79 } 80 } 81 } 82 } 83 84 return try url.toOwnedSlice(self.allocator); 85 } 86 87 fn doRequest(self: *XrpcClient, url: []const u8, body: ?[]const u8) !Response { 88 var aw: std.Io.Writer.Allocating = .init(self.allocator); 89 defer aw.deinit(); 90 91 // disable gzip: zig stdlib flate.Decompress panics on certain streams 92 // https://github.com/ziglang/zig/issues/25021 93 var extra_headers: std.http.Client.Request.Headers = .{ 94 .accept_encoding = .{ .override = "identity" }, 95 .content_type = if (body != null) .{ .override = "application/json" } else .default, 96 }; 97 var auth_header_buf: [max_auth_header_len]u8 = undefined; 98 if (self.access_token) |token| { 99 const auth_value = try std.fmt.bufPrint(&auth_header_buf, "Bearer {s}", .{token}); 100 extra_headers.authorization = .{ .override = auth_value }; 101 } 102 103 const result = self.http_client.fetch(.{ 104 .location = .{ .url = url }, 105 .response_writer = &aw.writer, 106 .method = if (body != null) .POST else .GET, 107 .payload = body, 108 .headers = extra_headers, 109 }) catch return error.RequestFailed; 110 111 const response_body = aw.toArrayList().items; 112 113 return .{ 114 .allocator = self.allocator, 115 .status = result.status, 116 .body = try self.allocator.dupe(u8, response_body), 117 }; 118 } 119 120 pub const Response = struct { 121 allocator: std.mem.Allocator, 122 status: std.http.Status, 123 body: []u8, 124 125 pub fn deinit(self: *Response) void { 126 self.allocator.free(self.body); 127 } 128 129 /// check if request succeeded 130 pub fn ok(self: Response) bool { 131 return self.status == .ok; 132 } 133 134 /// parse body as json 135 pub fn json(self: Response) !std.json.Parsed(std.json.Value) { 136 return try std.json.parseFromSlice(std.json.Value, self.allocator, self.body, .{}); 137 } 138 }; 139}; 140 141// === tests === 142 143test "build url without params" { 144 var client = XrpcClient.init(std.testing.allocator, "https://bsky.social"); 145 defer client.deinit(); 146 147 const nsid = Nsid.parse("app.bsky.actor.getProfile").?; 148 const url = try client.buildUrl(nsid, null); 149 defer std.testing.allocator.free(url); 150 151 try std.testing.expectEqualStrings("https://bsky.social/xrpc/app.bsky.actor.getProfile", url); 152} 153 154test "build url with params" { 155 var client = XrpcClient.init(std.testing.allocator, "https://bsky.social"); 156 defer client.deinit(); 157 158 var params = std.StringHashMap([]const u8).init(std.testing.allocator); 159 defer params.deinit(); 160 try params.put("actor", "did:plc:test123"); 161 162 const nsid = Nsid.parse("app.bsky.actor.getProfile").?; 163 const url = try client.buildUrl(nsid, params); 164 defer std.testing.allocator.free(url); 165 166 try std.testing.expect(std.mem.startsWith(u8, url, "https://bsky.social/xrpc/app.bsky.actor.getProfile?")); 167 try std.testing.expect(std.mem.indexOf(u8, url, "actor=did%3Aplc%3Atest123") != null); 168}