An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization

feat: module for dealing with `at://` URIs

ovyerus.com c1a10b9a faaa3c31

verified
+172 -11
+2 -1
.formatter.exs
··· 1 1 # Used by "mix format" 2 2 [ 3 - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 + import_deps: [:typedstruct] 4 5 ]
+158
lib/aturi.ex
··· 1 + defmodule Atex.AtURI do 2 + @moduledoc """ 3 + Struct and helper functions for manipulating `at://` URIs, which identify 4 + specific records within the AT Protocol. For more information on the URI 5 + scheme, refer to the ATProto spec: https://atproto.com/specs/at-uri-scheme. 6 + 7 + This module only supports the restricted URI syntax used for the Lexicon 8 + `at-uri` type, with no support for query strings or fragments. If/when the 9 + full syntax gets widespread use, this module will expand to accomodate them. 10 + 11 + Both URIs using DIDs and handles ("example.com") are supported. 12 + """ 13 + 14 + use TypedStruct 15 + 16 + @did ~S"did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]" 17 + @handle ~S"(?:[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])?" 18 + @nsid ~S"[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)" 19 + 20 + @authority "(?<authority>(?:#{@did})|(?:#{@handle}))" 21 + @collection "(?<collection>#{@nsid})" 22 + @rkey "(?<rkey>[a-zA-Z0-9.-_:~]{1,512})" 23 + 24 + @re ~r"^at://#{@authority}(?:/#{@collection}(?:/#{@rkey})?)?$" 25 + 26 + typedstruct do 27 + field :authority, String.t(), enforce: true 28 + field :collection, String.t() | nil 29 + field :rkey, String.t() | nil 30 + end 31 + 32 + @doc """ 33 + Create a new AtURI struct from a string by matching it against the regex. 34 + 35 + Returns `{:ok, aturi}` if a valid `at://` URI is given, otherwise it will return `:error`. 36 + 37 + ## Examples 38 + 39 + iex> Atex.AtURI.new("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26") 40 + {:ok, %Atex.AtURI{ 41 + rkey: "3jwdwj2ctlk26", 42 + collection: "app.bsky.feed.post", 43 + authority: "did:plc:44ybard66vv44zksje25o7dz" 44 + }} 45 + 46 + iex> Atex.AtURI.new("at:invalid/malformed") 47 + :error 48 + 49 + Partial URIs pointing to a collection without a record key, or even just a given authority, are also supported: 50 + 51 + iex> Atex.AtURI.new("at://ovyerus.com/sh.comet.v0.feed.track") 52 + {:ok, %Atex.AtURI{ 53 + rkey: nil, 54 + collection: "sh.comet.v0.feed.track", 55 + authority: "ovyerus.com" 56 + }} 57 + 58 + iex> Atex.AtURI.new("at://did:web:comet.sh") 59 + {:ok, %Atex.AtURI{ 60 + rkey: nil, 61 + collection: nil, 62 + authority: "did:web:comet.sh" 63 + }} 64 + """ 65 + @spec new(String.t()) :: {:ok, t()} | :error 66 + def new(string) when is_binary(string) do 67 + # TODO: test different ways to get a good error from regex on which part failed match? 68 + case Regex.named_captures(@re, string) do 69 + %{} = captures -> {:ok, from_named_captures(captures)} 70 + nil -> :error 71 + end 72 + end 73 + 74 + @doc """ 75 + The same as `new/1` but raises an `ArgumentError` if an invalid string is given. 76 + 77 + ## Examples 78 + 79 + iex> Atex.AtURI.new!("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26") 80 + %Atex.AtURI{ 81 + rkey: "3jwdwj2ctlk26", 82 + collection: "app.bsky.feed.post", 83 + authority: "did:plc:44ybard66vv44zksje25o7dz" 84 + } 85 + 86 + iex> Atex.AtURI.new!("at:invalid/malformed") 87 + ** (ArgumentError) Malformed at:// URI 88 + """ 89 + @spec new!(String.t()) :: t() 90 + def new!(string) when is_binary(string) do 91 + case new(string) do 92 + {:ok, uri} -> uri 93 + :error -> raise ArgumentError, message: "Malformed at:// URI" 94 + end 95 + end 96 + 97 + @doc """ 98 + Check if a string is a valid `at://` URI. 99 + 100 + ## Examples 101 + 102 + iex> Atex.AtURI.match?("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26") 103 + true 104 + 105 + iex> Atex.AtURI.match?("at://did:web:comet.sh") 106 + true 107 + 108 + iex> Atex.AtURI.match?("at://ovyerus.com/sh.comet.v0.feed.track") 109 + true 110 + 111 + iex> Atex.AtURI.match?("gobbledy gook") 112 + false 113 + """ 114 + @spec match?(String.t()) :: boolean() 115 + def match?(string), do: Regex.match?(@re, string) 116 + 117 + @doc """ 118 + Format an `Atex.AtURI` to the canonical string representation. 119 + 120 + Also available via the `String.Chars` protocol. 121 + 122 + ## Examples 123 + 124 + iex> aturi = %Atex.AtURI{ 125 + ...> rkey: "3jwdwj2ctlk26", 126 + ...> collection: "app.bsky.feed.post", 127 + ...> authority: "did:plc:44ybard66vv44zksje25o7dz" 128 + ...> } 129 + iex> Atex.AtURI.to_string(aturi) 130 + "at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26" 131 + 132 + iex> aturi = %Atex.AtURI{authority: "did:web:comet.sh"} 133 + iex> to_string(aturi) 134 + "at://did:web:comet.sh" 135 + """ 136 + @spec to_string(t()) :: String.t() 137 + def to_string(%__MODULE__{} = uri) do 138 + "at://#{uri.authority}/#{uri.collection}/#{uri.rkey}" 139 + |> String.trim_trailing("/") 140 + end 141 + 142 + defp from_named_captures(%{"authority" => authority, "collection" => "", "rkey" => ""}), 143 + do: %__MODULE__{authority: authority} 144 + 145 + defp from_named_captures(%{"authority" => authority, "collection" => collection, "rkey" => ""}), 146 + do: %__MODULE__{authority: authority, collection: collection} 147 + 148 + defp from_named_captures(%{ 149 + "authority" => authority, 150 + "collection" => collection, 151 + "rkey" => rkey 152 + }), 153 + do: %__MODULE__{authority: authority, collection: collection, rkey: rkey} 154 + end 155 + 156 + defimpl String.Chars, for: Atex.AtURI do 157 + def to_string(%Atex.AtURI{} = uri), do: Atex.AtURI.to_string(uri) 158 + end
+2 -2
mix.exs
··· 17 17 ] 18 18 end 19 19 20 - # Run "mix help deps" to learn about dependencies. 21 20 defp deps do 22 21 [ 23 - {:typedstruct, "~> 0.5"} 22 + {:typedstruct, "~> 0.5"}, 23 + {:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true} 24 24 ] 25 25 end 26 26 end
+6
mix.lock
··· 1 1 %{ 2 + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 3 + "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 4 + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 5 + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 6 + "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 7 + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 2 8 "typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"}, 3 9 }
-8
test/atex_test.exs
··· 1 - defmodule AtexTest do 2 - use ExUnit.Case 3 - doctest Atex 4 - 5 - test "greets the world" do 6 - assert Atex.hello() == :world 7 - end 8 - end
+4
test/aturi_test.exs
··· 1 + defmodule AtURITest do 2 + use ExUnit.Case, async: true 3 + doctest Atex.AtURI 4 + end