atproto utils for zig
zat.dev
atproto
sdk
zig
1//! AT-URI Parser
2//!
3//! at-uris identify repositories and records in the atproto network.
4//! format: at://<authority>[/<collection>[/<rkey>]]
5//!
6//! validation rules:
7//! - max 8KB length
8//! - no trailing slashes
9//! - authority is either a DID or handle
10//! - collection (if present) must be a valid NSID
11//! - rkey (if present) must be a valid record key
12//!
13//! see: https://atproto.com/specs/at-uri-scheme
14
15const std = @import("std");
16const Did = @import("did.zig").Did;
17const Handle = @import("handle.zig").Handle;
18const Nsid = @import("nsid.zig").Nsid;
19const Rkey = @import("rkey.zig").Rkey;
20
21pub const AtUri = struct {
22 /// the full uri string (borrowed, not owned)
23 raw: []const u8,
24
25 /// offset where authority ends (after "at://")
26 authority_end: usize,
27
28 /// offset where collection ends (0 if no collection)
29 collection_end: usize,
30
31 pub const max_length = 8 * 1024;
32 const prefix = "at://";
33
34 /// parse an at-uri. returns null if invalid.
35 pub fn parse(s: []const u8) ?AtUri {
36 // length check
37 if (s.len < prefix.len or s.len > max_length) return null;
38
39 // must start with "at://"
40 if (!std.mem.startsWith(u8, s, prefix)) return null;
41
42 // reject forbidden characters anywhere after prefix
43 for (s) |c| {
44 if (c == ' ' or c == '#' or c == '?') return null;
45 }
46
47 // no trailing slash
48 if (s[s.len - 1] == '/') return null;
49
50 const after_prefix = s[prefix.len..];
51 if (after_prefix.len == 0) return null; // empty authority
52
53 // find first slash (end of authority)
54 const authority_end_rel = std.mem.indexOfScalar(u8, after_prefix, '/');
55
56 const auth_str = after_prefix[0 .. authority_end_rel orelse after_prefix.len];
57 if (auth_str.len == 0) return null;
58
59 // authority must be a valid DID or handle
60 if (Did.parse(auth_str) == null and Handle.parse(auth_str) == null) return null;
61
62 if (authority_end_rel) |ae| {
63 const after_authority = after_prefix[ae + 1 ..];
64 if (after_authority.len == 0) return null; // trailing slash after authority
65
66 // find second slash (end of collection)
67 const collection_end_rel = std.mem.indexOfScalar(u8, after_authority, '/');
68
69 const coll_str = after_authority[0 .. collection_end_rel orelse after_authority.len];
70 if (coll_str.len == 0) return null; // empty collection
71
72 // collection must be a valid NSID
73 if (Nsid.parse(coll_str) == null) return null;
74
75 if (collection_end_rel) |ce| {
76 const rkey_str = after_authority[ce + 1 ..];
77 if (rkey_str.len == 0) return null; // trailing slash after collection
78
79 // rkey must be a valid record key
80 if (Rkey.parse(rkey_str) == null) return null;
81
82 return .{
83 .raw = s,
84 .authority_end = prefix.len + ae,
85 .collection_end = prefix.len + ae + 1 + ce,
86 };
87 } else {
88 // uri with authority + collection only
89 return .{
90 .raw = s,
91 .authority_end = prefix.len + ae,
92 .collection_end = s.len,
93 };
94 }
95 } else {
96 // authority only
97 return .{
98 .raw = s,
99 .authority_end = s.len,
100 .collection_end = 0,
101 };
102 }
103 }
104
105 /// the authority portion (DID or handle)
106 pub fn authority(self: AtUri) []const u8 {
107 return self.raw[prefix.len..self.authority_end];
108 }
109
110 /// the collection portion, or null if not present
111 pub fn collection(self: AtUri) ?[]const u8 {
112 if (self.collection_end == 0) return null;
113 return self.raw[self.authority_end + 1 .. self.collection_end];
114 }
115
116 /// the rkey portion, or null if not present
117 pub fn rkey(self: AtUri) ?[]const u8 {
118 if (self.collection_end == 0) return null;
119 if (self.collection_end >= self.raw.len) return null;
120 const r = self.raw[self.collection_end + 1 ..];
121 if (r.len == 0) return null;
122 return r;
123 }
124
125 /// check if this uri has a collection component
126 pub fn hasCollection(self: AtUri) bool {
127 return self.collection_end != 0;
128 }
129
130 /// check if this uri has an rkey component
131 pub fn hasRkey(self: AtUri) bool {
132 return self.rkey() != null;
133 }
134
135 /// format a new at-uri into the provided buffer.
136 /// returns the slice of the buffer used, or null if buffer too small.
137 pub fn format(
138 buf: []u8,
139 authority_str: []const u8,
140 collection_str: ?[]const u8,
141 rkey_str: ?[]const u8,
142 ) ?[]const u8 {
143 var total_len = prefix.len + authority_str.len;
144 if (collection_str) |c| {
145 total_len += 1 + c.len;
146 if (rkey_str) |r| {
147 total_len += 1 + r.len;
148 }
149 }
150
151 if (buf.len < total_len) return null;
152
153 var pos: usize = 0;
154
155 @memcpy(buf[pos..][0..prefix.len], prefix);
156 pos += prefix.len;
157
158 @memcpy(buf[pos..][0..authority_str.len], authority_str);
159 pos += authority_str.len;
160
161 if (collection_str) |c| {
162 buf[pos] = '/';
163 pos += 1;
164 @memcpy(buf[pos..][0..c.len], c);
165 pos += c.len;
166
167 if (rkey_str) |r| {
168 buf[pos] = '/';
169 pos += 1;
170 @memcpy(buf[pos..][0..r.len], r);
171 pos += r.len;
172 }
173 }
174
175 return buf[0..pos];
176 }
177};
178
179// === tests from atproto.com/specs/at-uri-scheme ===
180
181test "valid: full uri with did:plc" {
182 const uri = AtUri.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jxtb5w2hkt2m") orelse return error.InvalidUri;
183 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", uri.authority());
184 try std.testing.expectEqualStrings("app.bsky.feed.post", uri.collection().?);
185 try std.testing.expectEqualStrings("3jxtb5w2hkt2m", uri.rkey().?);
186}
187
188test "valid: full uri with did:web" {
189 const uri = AtUri.parse("at://did:web:example.com/app.bsky.actor.profile/self") orelse return error.InvalidUri;
190 try std.testing.expectEqualStrings("did:web:example.com", uri.authority());
191 try std.testing.expectEqualStrings("app.bsky.actor.profile", uri.collection().?);
192 try std.testing.expectEqualStrings("self", uri.rkey().?);
193}
194
195test "valid: full uri with handle" {
196 const uri = AtUri.parse("at://alice.bsky.social/app.bsky.feed.post/abc123") orelse return error.InvalidUri;
197 try std.testing.expectEqualStrings("alice.bsky.social", uri.authority());
198 try std.testing.expectEqualStrings("app.bsky.feed.post", uri.collection().?);
199 try std.testing.expectEqualStrings("abc123", uri.rkey().?);
200}
201
202test "valid: authority only" {
203 const uri = AtUri.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur") orelse return error.InvalidUri;
204 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", uri.authority());
205 try std.testing.expect(uri.collection() == null);
206 try std.testing.expect(uri.rkey() == null);
207 try std.testing.expect(!uri.hasCollection());
208 try std.testing.expect(!uri.hasRkey());
209}
210
211test "valid: authority and collection only" {
212 const uri = AtUri.parse("at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post") orelse return error.InvalidUri;
213 try std.testing.expectEqualStrings("did:plc:z72i7hdynmk6r22z27h6tvur", uri.authority());
214 try std.testing.expectEqualStrings("app.bsky.feed.post", uri.collection().?);
215 try std.testing.expect(uri.rkey() == null);
216 try std.testing.expect(uri.hasCollection());
217 try std.testing.expect(!uri.hasRkey());
218}
219
220test "invalid: missing prefix" {
221 try std.testing.expect(AtUri.parse("did:plc:xyz/app.bsky.feed.post/abc") == null);
222 try std.testing.expect(AtUri.parse("http://did:plc:xyz/collection/rkey") == null);
223}
224
225test "invalid: empty authority" {
226 try std.testing.expect(AtUri.parse("at://") == null);
227 try std.testing.expect(AtUri.parse("at:///collection/rkey") == null);
228}
229
230test "invalid: trailing slash" {
231 try std.testing.expect(AtUri.parse("at://did:plc:xyz/") == null);
232 try std.testing.expect(AtUri.parse("at://did:plc:xyz/collection/") == null);
233 try std.testing.expect(AtUri.parse("at://did:plc:xyz/collection/rkey/") == null);
234}
235
236test "invalid: empty collection" {
237 try std.testing.expect(AtUri.parse("at://did:plc:xyz//rkey") == null);
238}
239
240test "invalid: empty rkey" {
241 try std.testing.expect(AtUri.parse("at://did:plc:xyz/collection/") == null);
242}
243
244test "format: full uri" {
245 var buf: [256]u8 = undefined;
246 const result = AtUri.format(&buf, "did:plc:xyz", "app.bsky.feed.post", "abc123") orelse return error.BufferTooSmall;
247 try std.testing.expectEqualStrings("at://did:plc:xyz/app.bsky.feed.post/abc123", result);
248}
249
250test "format: authority only" {
251 var buf: [256]u8 = undefined;
252 const result = AtUri.format(&buf, "did:plc:xyz", null, null) orelse return error.BufferTooSmall;
253 try std.testing.expectEqualStrings("at://did:plc:xyz", result);
254}
255
256test "format: authority and collection" {
257 var buf: [256]u8 = undefined;
258 const result = AtUri.format(&buf, "did:plc:xyz", "app.bsky.feed.post", null) orelse return error.BufferTooSmall;
259 try std.testing.expectEqualStrings("at://did:plc:xyz/app.bsky.feed.post", result);
260}