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}