···991010### Added
11111212-- Experimental module for `at-uri` strings
1212+- `at_uri` module for validating at-uris
1313+- `handle` module for validating atproto handles
13141415### Changed
1516
+148
src/possum/at_uri.gleam
···11+//// The AT URI scheme (at://) makes it easy to reference individual records in
22+//// a specific repository, identified by either DID or handle.
33+////
44+//// AT URIs are not content-addressed, so the contents of the record they
55+//// refer to may also change over time.
66+77+import gleam/result
88+import gleam/string
99+import possum/did
1010+import possum/handle
1111+1212+pub type ParseError {
1313+ /// Only "at://" is supported
1414+ InvalidScheme(value: String)
1515+ /// Authority is neither a valid DID or Handle
1616+ InvalidAuthority(value: String)
1717+}
1818+1919+pub type Authority {
2020+ /// User handle, like: "tangled.org"
2121+ ///
2222+ /// AT URIs referencing handles are not durable.
2323+ /// If a user changes their handle, any AT URIs using that handle will become
2424+ /// invalid and could potentially point to a record in another repo if
2525+ /// the handle is reused.
2626+ Handle(value: handle.Handle)
2727+ /// User DID, like: "did:plc:wshs7t2adsemcrrd4snkeqli"
2828+ ///
2929+ /// When referencing records, especially from other repositories, best practice
3030+ /// is to use a DID in the authority part, not a handle.
3131+ Did(value: did.Did)
3232+}
3333+3434+pub opaque type AtUri {
3535+ AtUri(
3636+ /// Identifier, can be a DID or a Handle
3737+ authority: Authority,
3838+ /// Path
3939+ collection: String,
4040+ /// Query string
4141+ rkey: String,
4242+ )
4343+}
4444+4545+// Return the authority part of the Uri
4646+pub fn authority(self: AtUri) -> Authority {
4747+ self.authority
4848+}
4949+5050+// Return the collecion part of the Uri
5151+pub fn collection(self: AtUri) -> String {
5252+ self.collection
5353+}
5454+5555+// Return the rkey part of the Uri
5656+pub fn rkey(self: AtUri) -> String {
5757+ self.rkey
5858+}
5959+6060+/// Parse a String into a valid At-Uri type
6161+/// If the value is not a valid string then an error is returned.
6262+///
6363+/// Handles are not supported, use a DID instead.
6464+///
6565+/// ## Examples
6666+///
6767+/// ```gleam
6868+/// import possum/at_uri
6969+///
7070+/// let string = "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3k5nobkf2w72g"
7171+/// let assert Ok(at_uri) = at_uri.parse(string)
7272+pub fn parse(content: String) -> Result(AtUri, ParseError) {
7373+ use rest <- result.try(case content {
7474+ "at://" <> rest -> Ok(rest)
7575+7676+ // Other schemes like "HTTP" and "HTTPS"
7777+ _ ->
7878+ case string.split_once(content, on: "://") {
7979+ Ok(scheme) -> Error(InvalidScheme(scheme.0))
8080+ Error(_) -> Error(InvalidScheme(""))
8181+ }
8282+ })
8383+8484+ case string.split_once(rest, "/") {
8585+ // Contains only authority (DID or handle)
8686+ // ex: at://kacaii.dev
8787+ Error(_) | Ok(#(_, "")) -> {
8888+ use authority <- result.map(parse_authority(rest))
8989+ AtUri(authority:, collection: "", rkey: "")
9090+ }
9191+9292+ // Contains at least authority and collection
9393+ // ex: at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post
9494+ Ok(#(authority, rest)) -> {
9595+ use authority <- result.map(parse_authority(authority))
9696+9797+ case string.split_once(rest, "/") {
9898+ // Contains only collection
9999+ // ex: at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post
100100+ Error(_) -> AtUri(authority:, collection: rest, rkey: "")
101101+102102+ // Contains collection and rkey
103103+ // ex: at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3k5nobkf2w72g
104104+ Ok(#(collection, rkey)) -> AtUri(authority:, collection:, rkey:)
105105+ }
106106+ }
107107+ }
108108+}
109109+110110+fn parse_authority(string: String) -> Result(Authority, ParseError) {
111111+ use _ <- result.try_recover(case did.parse(string) {
112112+ Ok(value) -> Ok(Did(value))
113113+ Error(_) -> Error(InvalidAuthority(string))
114114+ })
115115+116116+ case handle.parse(string) {
117117+ Ok(value) -> Ok(Handle(value))
118118+ Error(_) -> Error(InvalidAuthority(string))
119119+ }
120120+}
121121+122122+/// Convert a At-URI into a String
123123+///
124124+/// ## Examples
125125+///
126126+/// ```gleam
127127+/// import possum/at_uri
128128+///
129129+/// let string =
130130+/// "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3k5nobkf2w72g"
131131+///
132132+/// let assert Ok(at_uri) = at_uri.parse(string)
133133+/// assert at.to_string(at_uri) == string
134134+/// ```
135135+pub fn to_string(self: AtUri) -> String {
136136+ let host = case self.authority {
137137+ Handle(value:) -> handle.to_string(value)
138138+ Did(value:) -> did.to_string(value)
139139+ }
140140+141141+ let path = case self.collection, self.rkey {
142142+ "", _ -> ""
143143+ collection, "" -> "/" <> collection
144144+ collection, rkey -> "/" <> string.join([collection, rkey], "/")
145145+ }
146146+147147+ "at://" <> host <> path
148148+}
+1
src/possum/did.gleam
···16161717pub const pattern = "^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$"
18181919+// TODO: change to ParseError
1920pub type DidError {
2021 InvalidRegexPattern(pattern: String)
2122 InvalidFormat(value: String)
+66
src/possum/handle.gleam
···11+//// DIDs are the long-term persistent identifiers for accounts in atproto,
22+//// but they can be opaque and unfriendly for human use. Handles are mutable
33+//// and human-friendly account usernames, in the form of a DNS hostname.
44+//// For example, "user.example.com".
55+66+import gleam/list
77+import gleam/regexp
88+import gleam/result
99+import gleam/string
1010+1111+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])?$"
1212+1313+pub type ParseError {
1414+ InvalidRegexPattern(pattern: String)
1515+ InvalidFormat(value: String)
1616+ /// "Reserved" top-level domains should not fail syntax validation,
1717+ /// but they must immediately fail any attempt at registration,
1818+ /// resolution, etc.
1919+ DisallowedTopLevelDomain(domain: String)
2020+}
2121+2222+/// Handles have a limited role in atproto,
2323+/// and need to be resolved to a DID in almost all situations.
2424+pub opaque type Handle {
2525+ Handle(value: String)
2626+}
2727+2828+pub fn to_string(handle: Handle) -> String {
2929+ handle.value
3030+}
3131+3232+/// Parse a string into a valid `Handle` type
3333+/// If the value is not a valid string then an error is returned.
3434+pub fn parse(content: String) -> Result(Handle, ParseError) {
3535+ use with <- result.try(
3636+ regexp.from_string(pattern)
3737+ |> result.replace_error(InvalidRegexPattern(pattern:)),
3838+ )
3939+4040+ use value <- result.try(case regexp.check(with:, content:) {
4141+ True -> Ok(content)
4242+ False -> Error(InvalidFormat(content))
4343+ })
4444+4545+ use value <- result.map(check_invalid_domains(value))
4646+ Handle(value:)
4747+}
4848+4949+fn check_invalid_domains(value: String) -> Result(String, ParseError) {
5050+ let disallowed_domains = [
5151+ ".alt",
5252+ ".arpa",
5353+ ".internal",
5454+ ".invalid",
5555+ ".local",
5656+ ".localhost",
5757+ ".onion",
5858+ ]
5959+6060+ list.try_fold(disallowed_domains, value, fn(acc, domain) {
6161+ case string.ends_with(acc, domain) {
6262+ True -> Error(DisallowedTopLevelDomain(domain:))
6363+ False -> Ok(acc)
6464+ }
6565+ })
6666+}