diff --git a/alicia/README.md b/alicia/README.md index 58e665f..132c7d2 100644 --- a/alicia/README.md +++ b/alicia/README.md @@ -1,6 +1,6 @@ # alicia -An ATProtocol SDK in Gleam. +An ATProtocol SDK in Gleam, inspired by [indigo](https://github.com/bluesky-social/indigo/). ## License diff --git a/alicia/identity/birdie_snapshots/parse_empty_handle.accepted b/alicia/identity/birdie_snapshots/parse_empty_handle.accepted new file mode 100644 index 0000000..64f9c72 --- /dev/null +++ b/alicia/identity/birdie_snapshots/parse_empty_handle.accepted @@ -0,0 +1,7 @@ +--- +version: 1.5.3 +title: Parse empty handle +file: ./test/alicia_identity_test.gleam +test_name: empty_handle_parse_test +--- +Error(HandleEmpty) diff --git a/alicia/identity/birdie_snapshots/parse_invalid_handle.accepted b/alicia/identity/birdie_snapshots/parse_invalid_handle.accepted new file mode 100644 index 0000000..498203d --- /dev/null +++ b/alicia/identity/birdie_snapshots/parse_invalid_handle.accepted @@ -0,0 +1,7 @@ +--- +version: 1.5.3 +title: Parse invalid handle +file: ./test/alicia_identity_test.gleam +test_name: invalid_handle_parse_test +--- +Error(HandleRegexFailure("lesbian#skin")) diff --git a/alicia/identity/birdie_snapshots/parse_valid_handle.accepted b/alicia/identity/birdie_snapshots/parse_valid_handle.accepted new file mode 100644 index 0000000..2ab36a9 --- /dev/null +++ b/alicia/identity/birdie_snapshots/parse_valid_handle.accepted @@ -0,0 +1,7 @@ +--- +version: 1.5.3 +title: Parse valid handle +file: ./test/alicia_identity_test.gleam +test_name: handle_parse_test +--- +Ok("lesbian.skin") diff --git a/alicia/identity/gleam.toml b/alicia/identity/gleam.toml index 5e0b3b2..6845a98 100644 --- a/alicia/identity/gleam.toml +++ b/alicia/identity/gleam.toml @@ -14,6 +14,9 @@ version = "1.0.0" [dependencies] gleam_stdlib = ">= 0.44.0 and < 2.0.0" +gleam_regexp = ">= 1.1.1 and < 2.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" +birdie = ">= 1.5.3 and < 2.0.0" +pprint = ">= 1.0.6 and < 2.0.0" diff --git a/alicia/identity/manifest.toml b/alicia/identity/manifest.toml index 834b83a..3e77e77 100644 --- a/alicia/identity/manifest.toml +++ b/alicia/identity/manifest.toml @@ -2,10 +2,35 @@ # You typically do not need to edit this file packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birdie", version = "1.5.3", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "envoy", "filepath", "glance", "gleam_community_ansi", "gleam_stdlib", "global_value", "justin", "rank", "simplifile", "term_size", "tom", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "64E7D9CC9E84272DA07061628E1B8F31F34FCD2008BCED47AB8FD58457CA63E2" }, + { name = "edit_distance", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "7DC465C34695F9E57D79FC65670C53C992CE342BF29E0AA41FF44F61AF62FC56" }, + { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "glam", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "237C2CE218A2A0A5D46D625F8EF5B78F964BC91018B78D692B17E1AB84295229" }, + { name = "glance", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "49E0ED4793BB3F56C3E5ED00528D70CAE21D263F70A735604124B95C5F62E2DB" }, + { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, + { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, { name = "gleam_stdlib", version = "0.67.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6CE3E4189A8B8EC2F73AB61A2FBDE49F159D6C9C61C49E3B3082E439F260D3D0" }, + { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, + { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, + { name = "global_value", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "global_value", source = "hex", outer_checksum = "23F74C91A7B819C43ABCCBF49DAD5BB8799D81F2A3736BA9A534BD47F309FF4F" }, + { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, + { name = "pprint", version = "1.0.6", build_tools = ["gleam"], requirements = ["glam", "gleam_stdlib"], otp_app = "pprint", source = "hex", outer_checksum = "4E9B34AE03B2E81D60F230B9BAF1792BE1AC37AFB5564B8DEBEE56BAEC866B7D" }, + { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, + { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" }, + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, + { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, ] [requirements] +birdie = { version = ">= 1.5.3 and < 2.0.0" } +gleam_regexp = { version = ">= 1.1.1 and < 2.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +pprint = { version = ">= 1.0.6 and < 2.0.0" } diff --git a/alicia/identity/src/alicia/identity.gleam b/alicia/identity/src/alicia/identity.gleam index e69de29..4b847db 100644 --- a/alicia/identity/src/alicia/identity.gleam +++ b/alicia/identity/src/alicia/identity.gleam @@ -0,0 +1,203 @@ +import alicia/identity/did +import gleam/bool +import gleam/dict +import gleam/list +import gleam/regexp +import gleam/result +import gleam/string +import gleam/uri + +pub const invalid_handle = "handle.invalid" + +pub type IdentityError { + NoServicesFound + ServiceNotFound(String) + UrlParseFailure + RegexCompileFailure + HandleEmpty + HandleTooLong + HandleRegexFailure(String) + HandleNotDeclared +} + +pub type Handle = + String + +pub type Identity { + Identity( + did: did.Did, + handle: Handle, + also_known_as: List(String), + services: dict.Dict(String, ServiceEndpoint), + keys: dict.Dict(String, VerificationMethod), + ) +} + +pub type ServiceEndpoint { + ServiceEndpoint(type_: String, url: String) +} + +pub type VerificationMethod { + VerificationMethod(type_: String, public_key_multibase: String) +} + +pub fn parse_identity(doc doc: did.Document) -> Identity { + let keys = + list.fold(doc.verification_method, dict.new(), fn(keys, vm) { + { + use #(_, right) <- result.try(string.split_once(vm.id, "#")) + // ignore keys not controlled by this DID + use <- bool.guard(when: vm.controller != doc.did, return: Error(Nil)) + // don't overwrite existing keys with this ID fragment + use <- bool.guard(when: dict.has_key(keys, right), return: Error(Nil)) + Ok(dict.insert( + keys, + right, + VerificationMethod( + type_: vm.type_, + public_key_multibase: vm.public_key_multibase, + ), + )) + } + |> result.unwrap(keys) + }) + + let services = + list.fold(doc.service, dict.new(), fn(services, svc) { + { + use #(_, right) <- result.try(string.split_once(svc.id, "#")) + // don't overwrite existing services with this ID fragment + use <- bool.guard( + when: dict.has_key(services, right), + return: Error(Nil), + ) + Ok(dict.insert( + services, + right, + ServiceEndpoint(type_: svc.type_, url: svc.service_endpoint), + )) + } + |> result.unwrap(services) + }) + Identity( + did: doc.did, + handle: invalid_handle, + also_known_as: doc.also_known_as, + services:, + keys:, + ) +} + +pub fn service_endpoint( + identity identity: Identity, + id id: String, +) -> Result(String, IdentityError) { + use <- bool.guard( + when: dict.is_empty(identity.services), + return: Error(NoServicesFound), + ) + use endpoint <- result.try( + dict.get(identity.services, id) + |> result.replace_error(ServiceNotFound(id)), + ) + use _ <- result.try( + uri.parse(endpoint.url) + |> result.replace_error(UrlParseFailure), + ) + // TODO: Possibly return Uri here instead of just using it to validate the + // url? - Depends on usecase in future. + Ok(endpoint.url) +} + +pub fn pds_endpoint( + identity identity: Identity, +) -> Result(String, IdentityError) { + service_endpoint(identity:, id: "atproto_pds") +} + +pub fn handle_regex() -> Result(regexp.Regexp, IdentityError) { + regexp.from_string( + "^([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])?$", + ) + |> result.replace_error(RegexCompileFailure) +} + +pub fn parse_handle(handle handle: String) -> Result(Handle, IdentityError) { + use <- bool.guard(when: string.is_empty(handle), return: Error(HandleEmpty)) + use <- bool.guard( + when: string.length(handle) > 253, + return: Error(HandleTooLong), + ) + use regex <- result.try(handle_regex()) + use <- bool.guard( + when: !regexp.check(regex, handle), + return: Error(HandleRegexFailure(handle)), + ) + Ok(handle) +} + +pub fn normalise_handle(handle handle: Handle) -> Handle { + string.lowercase(handle) +} + +pub fn declared_handle( + identity identity: Identity, +) -> Result(Handle, IdentityError) { + let valid_akas = + list.map(identity.also_known_as, fn(handle) { + case handle { + "at://" <> rest -> Ok(rest) + _ -> Error(HandleNotDeclared) + } + }) + use handle <- result.try( + valid_akas + |> result.values + |> list.first + |> result.replace_error(HandleNotDeclared), + ) + use handle <- result.try(parse_handle(handle)) + Ok(normalise_handle(handle)) +} + +pub fn public_key(identity identity: Identity, id id: String) -> String { + todo as "unimplemented" +} + +pub fn repo_public_key(identity identity: Identity) -> String { + public_key(identity:, id: "atproto") +} + +pub fn did_document(identity identity: Identity) -> did.Document { + let verification_method = + dict.fold(identity.keys, [], fn(acc, handle, vm) { + [ + did.DocumentVerificationMethod( + id: identity.did <> "#" <> handle, + type_: vm.type_, + controller: identity.did, + public_key_multibase: vm.public_key_multibase, + ), + ..acc + ] + }) + + let service = + dict.fold(identity.services, [], fn(acc, handle, svc) { + [ + did.DocumentService( + id: "#" <> handle, + type_: svc.type_, + service_endpoint: svc.url, + ), + ..acc + ] + }) + + did.Document( + did: identity.did, + also_known_as: identity.also_known_as, + verification_method:, + service:, + ) +} diff --git a/alicia/identity/src/alicia/identity/did.gleam b/alicia/identity/src/alicia/identity/did.gleam new file mode 100644 index 0000000..9c54b68 --- /dev/null +++ b/alicia/identity/src/alicia/identity/did.gleam @@ -0,0 +1,62 @@ +import gleam/dynamic/decode + +pub type Did = + String + +pub type Document { + Document( + did: Did, + also_known_as: List(String), + verification_method: List(DocumentVerificationMethod), + service: List(DocumentService), + ) +} + +pub fn document_decoder() -> decode.Decoder(Document) { + use did <- decode.field("did", decode.string) + use also_known_as <- decode.field("alsoKnownAs", decode.list(decode.string)) + use verification_method <- decode.field( + "verificationMethod", + decode.list(document_verification_method_decoder()), + ) + use service <- decode.field( + "service", + decode.list(document_service_decoder()), + ) + decode.success(Document(did:, also_known_as:, verification_method:, service:)) +} + +pub type DocumentVerificationMethod { + DocumentVerificationMethod( + id: String, + type_: String, + controller: String, + public_key_multibase: String, + ) +} + +pub fn document_verification_method_decoder() -> decode.Decoder( + DocumentVerificationMethod, +) { + use id <- decode.field("id", decode.string) + use type_ <- decode.field("type", decode.string) + use controller <- decode.field("controller", decode.string) + use public_key_multibase <- decode.field("publicKeyMultibase", decode.string) + decode.success(DocumentVerificationMethod( + id:, + type_:, + controller:, + public_key_multibase:, + )) +} + +pub type DocumentService { + DocumentService(id: String, type_: String, service_endpoint: String) +} + +pub fn document_service_decoder() -> decode.Decoder(DocumentService) { + use id <- decode.field("id", decode.string) + use type_ <- decode.field("type", decode.string) + use service_endpoint <- decode.field("serviceEndpoint", decode.string) + decode.success(DocumentService(id:, type_:, service_endpoint:)) +} diff --git a/alicia/identity/test/alicia_identity_test.gleam b/alicia/identity/test/alicia_identity_test.gleam index fba3c88..02e762c 100644 --- a/alicia/identity/test/alicia_identity_test.gleam +++ b/alicia/identity/test/alicia_identity_test.gleam @@ -1,13 +1,26 @@ +import alicia/identity +import birdie import gleeunit +import pprint pub fn main() -> Nil { gleeunit.main() } -// gleeunit test functions end in `_test` -pub fn hello_world_test() { - let name = "Joe" - let greeting = "Hello, " <> name <> "!" +pub fn handle_parse_test() { + identity.parse_handle("lesbian.skin") + |> pprint.format + |> birdie.snap(title: "Parse valid handle") +} + +pub fn empty_handle_parse_test() { + identity.parse_handle("") + |> pprint.format + |> birdie.snap(title: "Parse empty handle") +} - assert greeting == "Hello, Joe!" +pub fn invalid_handle_parse_test() { + identity.parse_handle("lesbian#skin") + |> pprint.format + |> birdie.snap(title: "Parse invalid handle") }