๐Ÿ€ ATproto library for gleam
atproto library gleam
34

Configure Feed

Select the types of activity you want to include in your feed.

possum/at_uri, possum/handle: add "at_uri" and "handle" modules

kacaii.dev (Jul 10, 2026, 6:38 PM -0300) 0aa4353c d70e892c

+240 -1
+2 -1
CHANGELOG.md
··· 9 9 10 10 ### Added 11 11 12 - - Experimental module for `at-uri` strings 12 + - `at_uri` module for validating at-uris 13 + - `handle` module for validating atproto handles 13 14 14 15 ### Changed 15 16
+148
src/possum/at_uri.gleam
··· 1 + //// The AT URI scheme (at://) makes it easy to reference individual records in 2 + //// a specific repository, identified by either DID or handle. 3 + //// 4 + //// AT URIs are not content-addressed, so the contents of the record they 5 + //// refer to may also change over time. 6 + 7 + import gleam/result 8 + import gleam/string 9 + import possum/did 10 + import possum/handle 11 + 12 + pub type ParseError { 13 + /// Only "at://" is supported 14 + InvalidScheme(value: String) 15 + /// Authority is neither a valid DID or Handle 16 + InvalidAuthority(value: String) 17 + } 18 + 19 + pub type Authority { 20 + /// User handle, like: "tangled.org" 21 + /// 22 + /// AT URIs referencing handles are not durable. 23 + /// If a user changes their handle, any AT URIs using that handle will become 24 + /// invalid and could potentially point to a record in another repo if 25 + /// the handle is reused. 26 + Handle(value: handle.Handle) 27 + /// User DID, like: "did:plc:wshs7t2adsemcrrd4snkeqli" 28 + /// 29 + /// When referencing records, especially from other repositories, best practice 30 + /// is to use a DID in the authority part, not a handle. 31 + Did(value: did.Did) 32 + } 33 + 34 + pub opaque type AtUri { 35 + AtUri( 36 + /// Identifier, can be a DID or a Handle 37 + authority: Authority, 38 + /// Path 39 + collection: String, 40 + /// Query string 41 + rkey: String, 42 + ) 43 + } 44 + 45 + // Return the authority part of the Uri 46 + pub fn authority(self: AtUri) -> Authority { 47 + self.authority 48 + } 49 + 50 + // Return the collecion part of the Uri 51 + pub fn collection(self: AtUri) -> String { 52 + self.collection 53 + } 54 + 55 + // Return the rkey part of the Uri 56 + pub fn rkey(self: AtUri) -> String { 57 + self.rkey 58 + } 59 + 60 + /// Parse a String into a valid At-Uri type 61 + /// If the value is not a valid string then an error is returned. 62 + /// 63 + /// Handles are not supported, use a DID instead. 64 + /// 65 + /// ## Examples 66 + /// 67 + /// ```gleam 68 + /// import possum/at_uri 69 + /// 70 + /// let string = "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3k5nobkf2w72g" 71 + /// let assert Ok(at_uri) = at_uri.parse(string) 72 + pub fn parse(content: String) -> Result(AtUri, ParseError) { 73 + use rest <- result.try(case content { 74 + "at://" <> rest -> Ok(rest) 75 + 76 + // Other schemes like "HTTP" and "HTTPS" 77 + _ -> 78 + case string.split_once(content, on: "://") { 79 + Ok(scheme) -> Error(InvalidScheme(scheme.0)) 80 + Error(_) -> Error(InvalidScheme("")) 81 + } 82 + }) 83 + 84 + case string.split_once(rest, "/") { 85 + // Contains only authority (DID or handle) 86 + // ex: at://kacaii.dev 87 + Error(_) | Ok(#(_, "")) -> { 88 + use authority <- result.map(parse_authority(rest)) 89 + AtUri(authority:, collection: "", rkey: "") 90 + } 91 + 92 + // Contains at least authority and collection 93 + // ex: at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post 94 + Ok(#(authority, rest)) -> { 95 + use authority <- result.map(parse_authority(authority)) 96 + 97 + case string.split_once(rest, "/") { 98 + // Contains only collection 99 + // ex: at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post 100 + Error(_) -> AtUri(authority:, collection: rest, rkey: "") 101 + 102 + // Contains collection and rkey 103 + // ex: at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3k5nobkf2w72g 104 + Ok(#(collection, rkey)) -> AtUri(authority:, collection:, rkey:) 105 + } 106 + } 107 + } 108 + } 109 + 110 + fn parse_authority(string: String) -> Result(Authority, ParseError) { 111 + use _ <- result.try_recover(case did.parse(string) { 112 + Ok(value) -> Ok(Did(value)) 113 + Error(_) -> Error(InvalidAuthority(string)) 114 + }) 115 + 116 + case handle.parse(string) { 117 + Ok(value) -> Ok(Handle(value)) 118 + Error(_) -> Error(InvalidAuthority(string)) 119 + } 120 + } 121 + 122 + /// Convert a At-URI into a String 123 + /// 124 + /// ## Examples 125 + /// 126 + /// ```gleam 127 + /// import possum/at_uri 128 + /// 129 + /// let string = 130 + /// "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3k5nobkf2w72g" 131 + /// 132 + /// let assert Ok(at_uri) = at_uri.parse(string) 133 + /// assert at.to_string(at_uri) == string 134 + /// ``` 135 + pub fn to_string(self: AtUri) -> String { 136 + let host = case self.authority { 137 + Handle(value:) -> handle.to_string(value) 138 + Did(value:) -> did.to_string(value) 139 + } 140 + 141 + let path = case self.collection, self.rkey { 142 + "", _ -> "" 143 + collection, "" -> "/" <> collection 144 + collection, rkey -> "/" <> string.join([collection, rkey], "/") 145 + } 146 + 147 + "at://" <> host <> path 148 + }
+1
src/possum/did.gleam
··· 16 16 17 17 pub const pattern = "^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$" 18 18 19 + // TODO: change to ParseError 19 20 pub type DidError { 20 21 InvalidRegexPattern(pattern: String) 21 22 InvalidFormat(value: String)
+66
src/possum/handle.gleam
··· 1 + //// DIDs are the long-term persistent identifiers for accounts in atproto, 2 + //// but they can be opaque and unfriendly for human use. Handles are mutable 3 + //// and human-friendly account usernames, in the form of a DNS hostname. 4 + //// For example, "user.example.com". 5 + 6 + import gleam/list 7 + import gleam/regexp 8 + import gleam/result 9 + import gleam/string 10 + 11 + const pattern = "^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$" 12 + 13 + pub type ParseError { 14 + InvalidRegexPattern(pattern: String) 15 + InvalidFormat(value: String) 16 + /// "Reserved" top-level domains should not fail syntax validation, 17 + /// but they must immediately fail any attempt at registration, 18 + /// resolution, etc. 19 + DisallowedTopLevelDomain(domain: String) 20 + } 21 + 22 + /// Handles have a limited role in atproto, 23 + /// and need to be resolved to a DID in almost all situations. 24 + pub opaque type Handle { 25 + Handle(value: String) 26 + } 27 + 28 + pub fn to_string(handle: Handle) -> String { 29 + handle.value 30 + } 31 + 32 + /// Parse a string into a valid `Handle` type 33 + /// If the value is not a valid string then an error is returned. 34 + pub fn parse(content: String) -> Result(Handle, ParseError) { 35 + use with <- result.try( 36 + regexp.from_string(pattern) 37 + |> result.replace_error(InvalidRegexPattern(pattern:)), 38 + ) 39 + 40 + use value <- result.try(case regexp.check(with:, content:) { 41 + True -> Ok(content) 42 + False -> Error(InvalidFormat(content)) 43 + }) 44 + 45 + use value <- result.map(check_invalid_domains(value)) 46 + Handle(value:) 47 + } 48 + 49 + fn check_invalid_domains(value: String) -> Result(String, ParseError) { 50 + let disallowed_domains = [ 51 + ".alt", 52 + ".arpa", 53 + ".internal", 54 + ".invalid", 55 + ".local", 56 + ".localhost", 57 + ".onion", 58 + ] 59 + 60 + list.try_fold(disallowed_domains, value, fn(acc, domain) { 61 + case string.ends_with(acc, domain) { 62 + True -> Error(DisallowedTopLevelDomain(domain:)) 63 + False -> Ok(acc) 64 + } 65 + }) 66 + }
+23
test/possum_test.gleam
··· 11 11 import gleeunit 12 12 import global_value 13 13 import possum 14 + import possum/at_uri 14 15 import possum/did 15 16 16 17 pub fn main() -> Nil { ··· 159 160 let assert Ok(_) = json.parse(response.body, decoder) 160 161 Nil 161 162 } 163 + 164 + pub fn at_uri_parsing_test() -> Nil { 165 + // Valid syntax and lexicon 166 + let assert Ok(_) = 167 + at_uri.parse( 168 + "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3k5nobkf2w72g", 169 + ) 170 + 171 + let assert Ok(_) = 172 + "at://retr0.id/app.bsky.feed.post/3k5nobkf2w72g" 173 + |> at_uri.parse 174 + 175 + let assert Ok(_) = 176 + "at://foo.com/com.example.foo/123" 177 + |> at_uri.parse 178 + 179 + let assert Error(at_uri.InvalidAuthority(_)) = 180 + "at://example.com:3000" 181 + |> at_uri.parse 182 + 183 + Nil 184 + }