···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 ]
18 end
1920- # Run "mix help deps" to learn about dependencies.
21 defp deps do
22 [
23- {:typedstruct, "~> 0.5"}
024 ]
25 end
26end
···17 ]
18 end
19020 defp deps do
21 [
22+ {:typedstruct, "~> 0.5"},
23+ {:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}
24 ]
25 end
26end