A set of utilities for working with the AT Protocol in Elixir.

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

ovyerus.com c1a10b9a faaa3c31

verified
+2 -1
.formatter.exs
···
# Used by "mix format"
[
-
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
···
# Used by "mix format"
[
+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
+
import_deps: [:typedstruct]
]
+158
lib/aturi.ex
···
···
+
defmodule Atex.AtURI do
+
@moduledoc """
+
Struct and helper functions for manipulating `at://` URIs, which identify
+
specific records within the AT Protocol. For more information on the URI
+
scheme, refer to the ATProto spec: https://atproto.com/specs/at-uri-scheme.
+
+
This module only supports the restricted URI syntax used for the Lexicon
+
`at-uri` type, with no support for query strings or fragments. If/when the
+
full syntax gets widespread use, this module will expand to accomodate them.
+
+
Both URIs using DIDs and handles ("example.com") are supported.
+
"""
+
+
use TypedStruct
+
+
@did ~S"did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]"
+
@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])?"
+
@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})?)"
+
+
@authority "(?<authority>(?:#{@did})|(?:#{@handle}))"
+
@collection "(?<collection>#{@nsid})"
+
@rkey "(?<rkey>[a-zA-Z0-9.-_:~]{1,512})"
+
+
@re ~r"^at://#{@authority}(?:/#{@collection}(?:/#{@rkey})?)?$"
+
+
typedstruct do
+
field :authority, String.t(), enforce: true
+
field :collection, String.t() | nil
+
field :rkey, String.t() | nil
+
end
+
+
@doc """
+
Create a new AtURI struct from a string by matching it against the regex.
+
+
Returns `{:ok, aturi}` if a valid `at://` URI is given, otherwise it will return `:error`.
+
+
## Examples
+
+
iex> Atex.AtURI.new("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
+
{:ok, %Atex.AtURI{
+
rkey: "3jwdwj2ctlk26",
+
collection: "app.bsky.feed.post",
+
authority: "did:plc:44ybard66vv44zksje25o7dz"
+
}}
+
+
iex> Atex.AtURI.new("at:invalid/malformed")
+
:error
+
+
Partial URIs pointing to a collection without a record key, or even just a given authority, are also supported:
+
+
iex> Atex.AtURI.new("at://ovyerus.com/sh.comet.v0.feed.track")
+
{:ok, %Atex.AtURI{
+
rkey: nil,
+
collection: "sh.comet.v0.feed.track",
+
authority: "ovyerus.com"
+
}}
+
+
iex> Atex.AtURI.new("at://did:web:comet.sh")
+
{:ok, %Atex.AtURI{
+
rkey: nil,
+
collection: nil,
+
authority: "did:web:comet.sh"
+
}}
+
"""
+
@spec new(String.t()) :: {:ok, t()} | :error
+
def new(string) when is_binary(string) do
+
# TODO: test different ways to get a good error from regex on which part failed match?
+
case Regex.named_captures(@re, string) do
+
%{} = captures -> {:ok, from_named_captures(captures)}
+
nil -> :error
+
end
+
end
+
+
@doc """
+
The same as `new/1` but raises an `ArgumentError` if an invalid string is given.
+
+
## Examples
+
+
iex> Atex.AtURI.new!("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
+
%Atex.AtURI{
+
rkey: "3jwdwj2ctlk26",
+
collection: "app.bsky.feed.post",
+
authority: "did:plc:44ybard66vv44zksje25o7dz"
+
}
+
+
iex> Atex.AtURI.new!("at:invalid/malformed")
+
** (ArgumentError) Malformed at:// URI
+
"""
+
@spec new!(String.t()) :: t()
+
def new!(string) when is_binary(string) do
+
case new(string) do
+
{:ok, uri} -> uri
+
:error -> raise ArgumentError, message: "Malformed at:// URI"
+
end
+
end
+
+
@doc """
+
Check if a string is a valid `at://` URI.
+
+
## Examples
+
+
iex> Atex.AtURI.match?("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
+
true
+
+
iex> Atex.AtURI.match?("at://did:web:comet.sh")
+
true
+
+
iex> Atex.AtURI.match?("at://ovyerus.com/sh.comet.v0.feed.track")
+
true
+
+
iex> Atex.AtURI.match?("gobbledy gook")
+
false
+
"""
+
@spec match?(String.t()) :: boolean()
+
def match?(string), do: Regex.match?(@re, string)
+
+
@doc """
+
Format an `Atex.AtURI` to the canonical string representation.
+
+
Also available via the `String.Chars` protocol.
+
+
## Examples
+
+
iex> aturi = %Atex.AtURI{
+
...> rkey: "3jwdwj2ctlk26",
+
...> collection: "app.bsky.feed.post",
+
...> authority: "did:plc:44ybard66vv44zksje25o7dz"
+
...> }
+
iex> Atex.AtURI.to_string(aturi)
+
"at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26"
+
+
iex> aturi = %Atex.AtURI{authority: "did:web:comet.sh"}
+
iex> to_string(aturi)
+
"at://did:web:comet.sh"
+
"""
+
@spec to_string(t()) :: String.t()
+
def to_string(%__MODULE__{} = uri) do
+
"at://#{uri.authority}/#{uri.collection}/#{uri.rkey}"
+
|> String.trim_trailing("/")
+
end
+
+
defp from_named_captures(%{"authority" => authority, "collection" => "", "rkey" => ""}),
+
do: %__MODULE__{authority: authority}
+
+
defp from_named_captures(%{"authority" => authority, "collection" => collection, "rkey" => ""}),
+
do: %__MODULE__{authority: authority, collection: collection}
+
+
defp from_named_captures(%{
+
"authority" => authority,
+
"collection" => collection,
+
"rkey" => rkey
+
}),
+
do: %__MODULE__{authority: authority, collection: collection, rkey: rkey}
+
end
+
+
defimpl String.Chars, for: Atex.AtURI do
+
def to_string(%Atex.AtURI{} = uri), do: Atex.AtURI.to_string(uri)
+
end
+2 -2
mix.exs
···
]
end
-
# Run "mix help deps" to learn about dependencies.
defp deps do
[
-
{:typedstruct, "~> 0.5"}
]
end
end
···
]
end
defp deps do
[
+
{:typedstruct, "~> 0.5"},
+
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}
]
end
end
+6
mix.lock
···
%{
"typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"},
}
···
%{
+
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
+
"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"},
+
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
+
"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"},
+
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
+
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"},
}
-8
test/atex_test.exs
···
-
defmodule AtexTest do
-
use ExUnit.Case
-
doctest Atex
-
-
test "greets the world" do
-
assert Atex.hello() == :world
-
end
-
end
···
+4
test/aturi_test.exs
···
···
+
defmodule AtURITest do
+
use ExUnit.Case, async: true
+
doctest Atex.AtURI
+
end