···11# Used by "mix format"
22[
33- inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
33+ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
44+ import_deps: [:typedstruct]
45]
+158
lib/aturi.ex
···11+defmodule Atex.AtURI do
22+ @moduledoc """
33+ Struct and helper functions for manipulating `at://` URIs, which identify
44+ specific records within the AT Protocol. For more information on the URI
55+ scheme, refer to the ATProto spec: https://atproto.com/specs/at-uri-scheme.
66+77+ This module only supports the restricted URI syntax used for the Lexicon
88+ `at-uri` type, with no support for query strings or fragments. If/when the
99+ full syntax gets widespread use, this module will expand to accomodate them.
1010+1111+ Both URIs using DIDs and handles ("example.com") are supported.
1212+ """
1313+1414+ use TypedStruct
1515+1616+ @did ~S"did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]"
1717+ @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])?"
1818+ @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})?)"
1919+2020+ @authority "(?<authority>(?:#{@did})|(?:#{@handle}))"
2121+ @collection "(?<collection>#{@nsid})"
2222+ @rkey "(?<rkey>[a-zA-Z0-9.-_:~]{1,512})"
2323+2424+ @re ~r"^at://#{@authority}(?:/#{@collection}(?:/#{@rkey})?)?$"
2525+2626+ typedstruct do
2727+ field :authority, String.t(), enforce: true
2828+ field :collection, String.t() | nil
2929+ field :rkey, String.t() | nil
3030+ end
3131+3232+ @doc """
3333+ Create a new AtURI struct from a string by matching it against the regex.
3434+3535+ Returns `{:ok, aturi}` if a valid `at://` URI is given, otherwise it will return `:error`.
3636+3737+ ## Examples
3838+3939+ iex> Atex.AtURI.new("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
4040+ {:ok, %Atex.AtURI{
4141+ rkey: "3jwdwj2ctlk26",
4242+ collection: "app.bsky.feed.post",
4343+ authority: "did:plc:44ybard66vv44zksje25o7dz"
4444+ }}
4545+4646+ iex> Atex.AtURI.new("at:invalid/malformed")
4747+ :error
4848+4949+ Partial URIs pointing to a collection without a record key, or even just a given authority, are also supported:
5050+5151+ iex> Atex.AtURI.new("at://ovyerus.com/sh.comet.v0.feed.track")
5252+ {:ok, %Atex.AtURI{
5353+ rkey: nil,
5454+ collection: "sh.comet.v0.feed.track",
5555+ authority: "ovyerus.com"
5656+ }}
5757+5858+ iex> Atex.AtURI.new("at://did:web:comet.sh")
5959+ {:ok, %Atex.AtURI{
6060+ rkey: nil,
6161+ collection: nil,
6262+ authority: "did:web:comet.sh"
6363+ }}
6464+ """
6565+ @spec new(String.t()) :: {:ok, t()} | :error
6666+ def new(string) when is_binary(string) do
6767+ # TODO: test different ways to get a good error from regex on which part failed match?
6868+ case Regex.named_captures(@re, string) do
6969+ %{} = captures -> {:ok, from_named_captures(captures)}
7070+ nil -> :error
7171+ end
7272+ end
7373+7474+ @doc """
7575+ The same as `new/1` but raises an `ArgumentError` if an invalid string is given.
7676+7777+ ## Examples
7878+7979+ iex> Atex.AtURI.new!("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
8080+ %Atex.AtURI{
8181+ rkey: "3jwdwj2ctlk26",
8282+ collection: "app.bsky.feed.post",
8383+ authority: "did:plc:44ybard66vv44zksje25o7dz"
8484+ }
8585+8686+ iex> Atex.AtURI.new!("at:invalid/malformed")
8787+ ** (ArgumentError) Malformed at:// URI
8888+ """
8989+ @spec new!(String.t()) :: t()
9090+ def new!(string) when is_binary(string) do
9191+ case new(string) do
9292+ {:ok, uri} -> uri
9393+ :error -> raise ArgumentError, message: "Malformed at:// URI"
9494+ end
9595+ end
9696+9797+ @doc """
9898+ Check if a string is a valid `at://` URI.
9999+100100+ ## Examples
101101+102102+ iex> Atex.AtURI.match?("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
103103+ true
104104+105105+ iex> Atex.AtURI.match?("at://did:web:comet.sh")
106106+ true
107107+108108+ iex> Atex.AtURI.match?("at://ovyerus.com/sh.comet.v0.feed.track")
109109+ true
110110+111111+ iex> Atex.AtURI.match?("gobbledy gook")
112112+ false
113113+ """
114114+ @spec match?(String.t()) :: boolean()
115115+ def match?(string), do: Regex.match?(@re, string)
116116+117117+ @doc """
118118+ Format an `Atex.AtURI` to the canonical string representation.
119119+120120+ Also available via the `String.Chars` protocol.
121121+122122+ ## Examples
123123+124124+ iex> aturi = %Atex.AtURI{
125125+ ...> rkey: "3jwdwj2ctlk26",
126126+ ...> collection: "app.bsky.feed.post",
127127+ ...> authority: "did:plc:44ybard66vv44zksje25o7dz"
128128+ ...> }
129129+ iex> Atex.AtURI.to_string(aturi)
130130+ "at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26"
131131+132132+ iex> aturi = %Atex.AtURI{authority: "did:web:comet.sh"}
133133+ iex> to_string(aturi)
134134+ "at://did:web:comet.sh"
135135+ """
136136+ @spec to_string(t()) :: String.t()
137137+ def to_string(%__MODULE__{} = uri) do
138138+ "at://#{uri.authority}/#{uri.collection}/#{uri.rkey}"
139139+ |> String.trim_trailing("/")
140140+ end
141141+142142+ defp from_named_captures(%{"authority" => authority, "collection" => "", "rkey" => ""}),
143143+ do: %__MODULE__{authority: authority}
144144+145145+ defp from_named_captures(%{"authority" => authority, "collection" => collection, "rkey" => ""}),
146146+ do: %__MODULE__{authority: authority, collection: collection}
147147+148148+ defp from_named_captures(%{
149149+ "authority" => authority,
150150+ "collection" => collection,
151151+ "rkey" => rkey
152152+ }),
153153+ do: %__MODULE__{authority: authority, collection: collection, rkey: rkey}
154154+end
155155+156156+defimpl String.Chars, for: Atex.AtURI do
157157+ def to_string(%Atex.AtURI{} = uri), do: Atex.AtURI.to_string(uri)
158158+end
+2-2
mix.exs
···1717 ]
1818 end
19192020- # Run "mix help deps" to learn about dependencies.
2120 defp deps do
2221 [
2323- {:typedstruct, "~> 0.5"}
2222+ {:typedstruct, "~> 0.5"},
2323+ {:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}
2424 ]
2525 end
2626end