diff --git a/CHANGELOG.md b/CHANGELOG.md index ef61d64..147fb2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Experimental module for `at-uri` strings +- `at_uri` module for validating at-uris +- `handle` module for validating atproto handles ### Changed diff --git a/src/possum/at_uri.gleam b/src/possum/at_uri.gleam new file mode 100644 index 0000000..c328470 --- /dev/null +++ b/src/possum/at_uri.gleam @@ -0,0 +1,148 @@ +//// The AT URI scheme (at://) makes it easy to reference individual records in +//// a specific repository, identified by either DID or handle. +//// +//// AT URIs are not content-addressed, so the contents of the record they +//// refer to may also change over time. + +import gleam/result +import gleam/string +import possum/did +import possum/handle + +pub type ParseError { + /// Only "at://" is supported + InvalidScheme(value: String) + /// Authority is neither a valid DID or Handle + InvalidAuthority(value: String) +} + +pub type Authority { + /// User handle, like: "tangled.org" + /// + /// AT URIs referencing handles are not durable. + /// If a user changes their handle, any AT URIs using that handle will become + /// invalid and could potentially point to a record in another repo if + /// the handle is reused. + Handle(value: handle.Handle) + /// User DID, like: "did:plc:wshs7t2adsemcrrd4snkeqli" + /// + /// When referencing records, especially from other repositories, best practice + /// is to use a DID in the authority part, not a handle. + Did(value: did.Did) +} + +pub opaque type AtUri { + AtUri( + /// Identifier, can be a DID or a Handle + authority: Authority, + /// Path + collection: String, + /// Query string + rkey: String, + ) +} + +// Return the authority part of the Uri +pub fn authority(self: AtUri) -> Authority { + self.authority +} + +// Return the collecion part of the Uri +pub fn collection(self: AtUri) -> String { + self.collection +} + +// Return the rkey part of the Uri +pub fn rkey(self: AtUri) -> String { + self.rkey +} + +/// Parse a String into a valid At-Uri type +/// If the value is not a valid string then an error is returned. +/// +/// Handles are not supported, use a DID instead. +/// +/// ## Examples +/// +/// ```gleam +/// import possum/at_uri +/// +/// let string = "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3k5nobkf2w72g" +/// let assert Ok(at_uri) = at_uri.parse(string) +pub fn parse(content: String) -> Result(AtUri, ParseError) { + use rest <- result.try(case content { + "at://" <> rest -> Ok(rest) + + // Other schemes like "HTTP" and "HTTPS" + _ -> + case string.split_once(content, on: "://") { + Ok(scheme) -> Error(InvalidScheme(scheme.0)) + Error(_) -> Error(InvalidScheme("")) + } + }) + + case string.split_once(rest, "/") { + // Contains only authority (DID or handle) + // ex: at://kacaii.dev + Error(_) | Ok(#(_, "")) -> { + use authority <- result.map(parse_authority(rest)) + AtUri(authority:, collection: "", rkey: "") + } + + // Contains at least authority and collection + // ex: at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post + Ok(#(authority, rest)) -> { + use authority <- result.map(parse_authority(authority)) + + case string.split_once(rest, "/") { + // Contains only collection + // ex: at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post + Error(_) -> AtUri(authority:, collection: rest, rkey: "") + + // Contains collection and rkey + // ex: at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3k5nobkf2w72g + Ok(#(collection, rkey)) -> AtUri(authority:, collection:, rkey:) + } + } + } +} + +fn parse_authority(string: String) -> Result(Authority, ParseError) { + use _ <- result.try_recover(case did.parse(string) { + Ok(value) -> Ok(Did(value)) + Error(_) -> Error(InvalidAuthority(string)) + }) + + case handle.parse(string) { + Ok(value) -> Ok(Handle(value)) + Error(_) -> Error(InvalidAuthority(string)) + } +} + +/// Convert a At-URI into a String +/// +/// ## Examples +/// +/// ```gleam +/// import possum/at_uri +/// +/// let string = +/// "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3k5nobkf2w72g" +/// +/// let assert Ok(at_uri) = at_uri.parse(string) +/// assert at.to_string(at_uri) == string +/// ``` +pub fn to_string(self: AtUri) -> String { + let host = case self.authority { + Handle(value:) -> handle.to_string(value) + Did(value:) -> did.to_string(value) + } + + let path = case self.collection, self.rkey { + "", _ -> "" + collection, "" -> "/" <> collection + collection, rkey -> "/" <> string.join([collection, rkey], "/") + } + + "at://" <> host <> path +} diff --git a/src/possum/did.gleam b/src/possum/did.gleam index 7929fdd..83df0d7 100644 --- a/src/possum/did.gleam +++ b/src/possum/did.gleam @@ -16,6 +16,7 @@ import gleam/string pub const pattern = "^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$" +// TODO: change to ParseError pub type DidError { InvalidRegexPattern(pattern: String) InvalidFormat(value: String) diff --git a/src/possum/handle.gleam b/src/possum/handle.gleam new file mode 100644 index 0000000..4768ffe --- /dev/null +++ b/src/possum/handle.gleam @@ -0,0 +1,66 @@ +//// DIDs are the long-term persistent identifiers for accounts in atproto, +//// but they can be opaque and unfriendly for human use. Handles are mutable +//// and human-friendly account usernames, in the form of a DNS hostname. +//// For example, "user.example.com". + +import gleam/list +import gleam/regexp +import gleam/result +import gleam/string + +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])?$" + +pub type ParseError { + InvalidRegexPattern(pattern: String) + InvalidFormat(value: String) + /// "Reserved" top-level domains should not fail syntax validation, + /// but they must immediately fail any attempt at registration, + /// resolution, etc. + DisallowedTopLevelDomain(domain: String) +} + +/// Handles have a limited role in atproto, +/// and need to be resolved to a DID in almost all situations. +pub opaque type Handle { + Handle(value: String) +} + +pub fn to_string(handle: Handle) -> String { + handle.value +} + +/// Parse a string into a valid `Handle` type +/// If the value is not a valid string then an error is returned. +pub fn parse(content: String) -> Result(Handle, ParseError) { + use with <- result.try( + regexp.from_string(pattern) + |> result.replace_error(InvalidRegexPattern(pattern:)), + ) + + use value <- result.try(case regexp.check(with:, content:) { + True -> Ok(content) + False -> Error(InvalidFormat(content)) + }) + + use value <- result.map(check_invalid_domains(value)) + Handle(value:) +} + +fn check_invalid_domains(value: String) -> Result(String, ParseError) { + let disallowed_domains = [ + ".alt", + ".arpa", + ".internal", + ".invalid", + ".local", + ".localhost", + ".onion", + ] + + list.try_fold(disallowed_domains, value, fn(acc, domain) { + case string.ends_with(acc, domain) { + True -> Error(DisallowedTopLevelDomain(domain:)) + False -> Ok(acc) + } + }) +} diff --git a/test/possum_test.gleam b/test/possum_test.gleam index 3608950..5653192 100644 --- a/test/possum_test.gleam +++ b/test/possum_test.gleam @@ -11,6 +11,7 @@ import gleam/result import gleeunit import global_value import possum +import possum/at_uri import possum/did pub fn main() -> Nil { @@ -159,3 +160,25 @@ pub fn get_record_test() -> Nil { let assert Ok(_) = json.parse(response.body, decoder) Nil } + +pub fn at_uri_parsing_test() -> Nil { + // Valid syntax and lexicon + let assert Ok(_) = + at_uri.parse( + "at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3k5nobkf2w72g", + ) + + let assert Ok(_) = + "at://retr0.id/app.bsky.feed.post/3k5nobkf2w72g" + |> at_uri.parse + + let assert Ok(_) = + "at://foo.com/com.example.foo/123" + |> at_uri.parse + + let assert Error(at_uri.InvalidAuthority(_)) = + "at://example.com:3000" + |> at_uri.parse + + Nil +}