diff --git a/src/at/handle.gleam b/src/at/handle.gleam new file mode 100644 index 0000000..5ee39c4 --- /dev/null +++ b/src/at/handle.gleam @@ -0,0 +1,108 @@ +// IMPORTS --------------------------------------------------------------------- + +import at/internal/hostname +import gleam/dynamic/decode.{type Decoder} +import gleam/string + +// TYPES ----------------------------------------------------------------------- + +/// +/// +pub opaque type Handle { + Handle(hostname: String, kind: HandleKind) +} + +/// +/// +pub type HandleKind { + Normal + Reserved + Invalid + Test +} + +// CONSTANTS ------------------------------------------------------------------- + +@internal +pub const zero = Handle(hostname: "handle.invalid", kind: Invalid) + +// CONSTRUCTORS ---------------------------------------------------------------- + +/// +/// +pub fn from_string(value: String) -> Result(Handle, Nil) { + case string.lowercase(value) { + "handle.invalid" as lowercase -> + Ok(Handle(hostname: lowercase, kind: Invalid)) + + lowercase -> + case hostname.parse(lowercase) { + Ok(hostname.Normal) -> Ok(Handle(hostname: lowercase, kind: Normal)) + Ok(hostname.Reserved) -> Ok(Handle(hostname: lowercase, kind: Reserved)) + Ok(hostname.Test) -> Ok(Handle(hostname: lowercase, kind: Test)) + Error(Nil) -> Error(Nil) + } + } +} + +/// +/// +pub fn from_at_string(value: String) -> Result(Handle, Nil) { + case value { + "at://" <> hostname -> from_string(hostname) + _ -> Error(Nil) + } +} + +/// +/// +pub fn decoder() -> Decoder(Handle) { + use string <- decode.then(decode.string) + + case from_string(string) { + Ok(handle) -> decode.success(handle) + Error(_) -> decode.failure(zero, "handle.Handle") + } +} + +pub fn at_decoder() -> Decoder(Handle) { + use string <- decode.then(decode.string) + + case from_at_string(string) { + Ok(handle) -> decode.success(handle) + Error(_) -> decode.failure(zero, "handle.Handle") + } +} + +// QUERIES --------------------------------------------------------------------- + +pub fn kind(handle: Handle) -> HandleKind { + handle.kind +} + +pub fn is_invalid(handle: Handle) -> Bool { + case handle.kind { + Invalid -> True + Normal | Reserved | Test -> False + } +} + +pub fn is_reserved(handle: Handle) -> Bool { + case handle.kind { + Reserved -> True + Normal | Invalid | Test -> False + } +} + +pub fn is_test(handle: Handle) -> Bool { + case handle.kind { + Test -> True + Normal | Invalid | Reserved -> False + } +} + +// CONVERSIONS ----------------------------------------------------------------- + +pub fn to_string(handle: Handle) -> String { + handle.hostname +} diff --git a/src/at/rkey.gleam b/src/at/rkey.gleam new file mode 100644 index 0000000..c331914 --- /dev/null +++ b/src/at/rkey.gleam @@ -0,0 +1,198 @@ +// IMPORTS --------------------------------------------------------------------- + +import at/nsid +import at/tid +import gleam/dynamic/decode +import gleam/result + +// TYPES ----------------------------------------------------------------------- + +/// +/// +pub opaque type Rkey { + Rkey(value: Value) +} + +/// +/// +pub type Value { + Tid(tid.Tid) + Nsid(nsid.Nsid) + Literal(String) + Any(String) +} + +// CONSTANTS ------------------------------------------------------------------- + +@internal +pub const zero = Rkey(Literal("self")) + +// CONSTRUCTORS ---------------------------------------------------------------- + +/// +/// +pub fn from_string(value: String) -> Result(Rkey, Nil) { + case value { + "." | ".." -> Error(Nil) + "literal:self" -> Ok(Rkey(Literal("self"))) + "literal:" <> rest -> from_raw_string(rest, Literal) + value -> + from_tid_string(value) + |> result.lazy_or(fn() { from_nsid_string(value) }) + |> result.lazy_or(fn() { from_raw_string(value, Any) }) + } +} + +fn from_tid_string(value: String) -> Result(Rkey, Nil) { + case tid.from_string(value) { + Ok(tid) -> Ok(Rkey(Tid(tid))) + Error(_) -> Error(Nil) + } +} + +fn from_nsid_string(value: String) -> Result(Rkey, Nil) { + case nsid.from_string(value) { + Ok(nsid) -> Ok(Rkey(Nsid(nsid))) + Error(_) -> Error(Nil) + } +} + +fn from_raw_string( + value: String, + constructor: fn(String) -> Value, +) -> Result(Rkey, Nil) { + case is_valid_rkey(value, 0) { + True -> Ok(Rkey(constructor(value))) + False -> Error(Nil) + } +} + +fn is_valid_rkey(remaining: String, length: Int) -> Bool { + case remaining { + // a valid record key must not exceed 512 characters. + _ if length > 512 -> False + + // a valid record key must not be empty. + "" if length < 1 -> False + + "" -> True + + // can only contain ASCII letters, digits, and some specific symbols. + // [0-9] + "0" <> rest + | "1" <> rest + | "2" <> rest + | "3" <> rest + | "4" <> rest + | "5" <> rest + | "6" <> rest + | "7" <> rest + | "8" <> rest + | "9" <> rest + | // [A-Z] + "A" <> rest + | "B" <> rest + | "C" <> rest + | "D" <> rest + | "E" <> rest + | "F" <> rest + | "G" <> rest + | "H" <> rest + | "I" <> rest + | "J" <> rest + | "K" <> rest + | "L" <> rest + | "M" <> rest + | "N" <> rest + | "O" <> rest + | "P" <> rest + | "Q" <> rest + | "R" <> rest + | "S" <> rest + | "T" <> rest + | "U" <> rest + | "V" <> rest + | "W" <> rest + | "X" <> rest + | "Y" <> rest + | "Z" <> rest + | // [a-z] + "a" <> rest + | "b" <> rest + | "c" <> rest + | "d" <> rest + | "e" <> rest + | "f" <> rest + | "g" <> rest + | "h" <> rest + | "i" <> rest + | "j" <> rest + | "k" <> rest + | "l" <> rest + | "m" <> rest + | "n" <> rest + | "o" <> rest + | "p" <> rest + | "q" <> rest + | "r" <> rest + | "s" <> rest + | "t" <> rest + | "u" <> rest + | "v" <> rest + | "w" <> rest + | "x" <> rest + | "y" <> rest + | "z" <> rest + | // [.-_:~] + "." <> rest + | "-" <> rest + | "_" <> rest + | ":" <> rest + | "~" <> rest -> is_valid_rkey(rest, length + 1) + + // everything else isn't. + _ -> False + } +} + +/// +/// +pub fn from_tid(tid: tid.Tid) -> Rkey { + Rkey(Tid(tid)) +} + +/// +/// +pub fn from_nsid(nsid: nsid.Nsid) -> Rkey { + Rkey(Nsid(nsid)) +} + +/// +/// +pub fn decoder() -> decode.Decoder(Rkey) { + use string <- decode.then(decode.string) + + case from_string(string) { + Ok(rkey) -> decode.success(rkey) + Error(_) -> decode.failure(zero, "at.Rkey") + } +} + +// QUERIES --------------------------------------------------------------------- + +pub fn value(rkey: Rkey) -> Value { + rkey.value +} + +// CONVERSIONS ----------------------------------------------------------------- + +/// +/// +pub fn to_string(rkey: Rkey) -> String { + case rkey.value { + Tid(tid) -> tid.to_string(tid) + Nsid(nsid) -> nsid.to_string(nsid) + Literal(string) -> "literal:" <> string + Any(string) -> string + } +} diff --git a/src/at/uri.gleam b/src/at/uri.gleam new file mode 100644 index 0000000..a19569f --- /dev/null +++ b/src/at/uri.gleam @@ -0,0 +1,131 @@ +// IMPORTS --------------------------------------------------------------------- + +import at/did.{type Did} +import at/handle.{type Handle} +import at/nsid.{type Nsid} +import at/rkey +import gleam/dynamic/decode.{type Decoder} +import gleam/json.{type Json} +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string + +// TYPES ----------------------------------------------------------------------- + +/// +/// +pub type Uri { + Uri(authority: Authority, collection: Option(Collection)) +} + +/// +/// +pub type Authority { + Did(Did) + Handle(Handle) +} + +/// +/// +pub type Collection { + Collection(id: Nsid, rkey: Option(rkey.Rkey)) +} + +// CONSTANTS ------------------------------------------------------------------- + +@internal +pub const zero = Uri(authority: Did(did.zero), collection: None) + +// CONSTRUCTORS ---------------------------------------------------------------- + +/// +/// +pub fn from_string(uri: String) -> Result(Uri, Nil) { + case uri { + "at://" <> uri -> + case string.split_once(uri, "/") { + Ok(#(authority, collection)) -> { + use authority <- result.try(parse_authority_part(authority)) + use collection <- result.try(parse_collection_part(collection)) + + Ok(Uri(authority: authority, collection: Some(collection))) + } + + Error(_) -> { + use authority <- result.try(parse_authority_part(uri)) + + Ok(Uri(authority: authority, collection: None)) + } + } + + _ -> Error(Nil) + } +} + +fn parse_authority_part(uri: String) -> Result(Authority, Nil) { + case uri { + "did:" <> _ -> did.from_string(uri) |> result.map(Did) + _ -> handle.from_string(uri) |> result.map(Handle) + } +} + +fn parse_collection_part(uri: String) -> Result(Collection, Nil) { + case string.split_once(uri, "/") { + Ok(#(collection, rkey)) -> { + use id <- result.try(nsid.from_string(collection)) + use rkey <- result.try(rkey.from_string(rkey)) + + Ok(Collection(id: id, rkey: Some(rkey))) + } + + Error(_) -> { + use id <- result.try(nsid.from_string(uri)) + + Ok(Collection(id: id, rkey: None)) + } + } +} + +pub fn decoder() -> Decoder(Uri) { + use string <- decode.then(decode.string) + + case from_string(string) { + Ok(uri) -> decode.success(uri) + Error(_) -> decode.failure(zero, "uri.Uri") + } +} + +// CONVERSIONS ----------------------------------------------------------------- + +/// +/// +pub fn to_string(uri: Uri) -> String { + case uri.collection { + None -> "at://" <> authority_to_string(uri.authority) + Some(collection) -> + "at://" + <> authority_to_string(uri.authority) + <> "/" + <> collection_to_string(collection) + } +} + +fn authority_to_string(authority: Authority) -> String { + case authority { + Did(did) -> did.to_string(did) + Handle(handle) -> handle.to_string(handle) + } +} + +fn collection_to_string(collection: Collection) -> String { + case collection.rkey { + Some(rkey) -> nsid.to_string(collection.id) <> "/" <> rkey.to_string(rkey) + None -> nsid.to_string(collection.id) + } +} + +/// +/// +pub fn to_json(uri: Uri) -> Json { + json.string(to_string(uri)) +}