1defmodule Atex.AtURI do
2 @moduledoc """
3 Struct and helper functions for manipulating `at://` URIs, which identify
4 specific records within the AT Protocol.
5
6 ATProto spec: https://atproto.com/specs/at-uri-scheme
7
8 This module only supports the restricted URI syntax used for the Lexicon
9 `at-uri` type, with no support for query strings or fragments. If/when the
10 full syntax gets widespread use, this module will expand to accomodate them.
11
12 Both URIs using DIDs and handles ("example.com") are supported.
13 """
14
15 use TypedStruct
16
17 @did ~S"did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]"
18 @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])?"
19 @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})?)"
20
21 @authority "(?<authority>(?:#{@did})|(?:#{@handle}))"
22 @collection "(?<collection>#{@nsid})"
23 @rkey "(?<rkey>[a-zA-Z0-9.-_:~]{1,512})"
24
25 @re ~r"^at://#{@authority}(?:/#{@collection}(?:/#{@rkey})?)?$"
26
27 typedstruct do
28 field :authority, String.t(), enforce: true
29 field :collection, String.t() | nil
30 field :rkey, String.t() | nil
31 end
32
33 @doc """
34 Create a new AtURI struct from a string by matching it against the regex.
35
36 Returns `{:ok, aturi}` if a valid `at://` URI is given, otherwise it will return `:error`.
37
38 ## Examples
39
40 iex> Atex.AtURI.new("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
41 {:ok, %Atex.AtURI{
42 rkey: "3jwdwj2ctlk26",
43 collection: "app.bsky.feed.post",
44 authority: "did:plc:44ybard66vv44zksje25o7dz"
45 }}
46
47 iex> Atex.AtURI.new("at:invalid/malformed")
48 :error
49
50 Partial URIs pointing to a collection without a record key, or even just a given authority, are also supported:
51
52 iex> Atex.AtURI.new("at://ovyerus.com/sh.comet.v0.feed.track")
53 {:ok, %Atex.AtURI{
54 rkey: nil,
55 collection: "sh.comet.v0.feed.track",
56 authority: "ovyerus.com"
57 }}
58
59 iex> Atex.AtURI.new("at://did:web:comet.sh")
60 {:ok, %Atex.AtURI{
61 rkey: nil,
62 collection: nil,
63 authority: "did:web:comet.sh"
64 }}
65 """
66 @spec new(String.t()) :: {:ok, t()} | :error
67 def new(string) when is_binary(string) do
68 # TODO: test different ways to get a good error from regex on which part failed match?
69 case Regex.named_captures(@re, string) do
70 %{} = captures -> {:ok, from_named_captures(captures)}
71 nil -> :error
72 end
73 end
74
75 @doc """
76 The same as `new/1` but raises an `ArgumentError` if an invalid string is given.
77
78 ## Examples
79
80 iex> Atex.AtURI.new!("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
81 %Atex.AtURI{
82 rkey: "3jwdwj2ctlk26",
83 collection: "app.bsky.feed.post",
84 authority: "did:plc:44ybard66vv44zksje25o7dz"
85 }
86
87 iex> Atex.AtURI.new!("at:invalid/malformed")
88 ** (ArgumentError) Malformed at:// URI
89 """
90 @spec new!(String.t()) :: t()
91 def new!(string) when is_binary(string) do
92 case new(string) do
93 {:ok, uri} -> uri
94 :error -> raise ArgumentError, message: "Malformed at:// URI"
95 end
96 end
97
98 @doc """
99 Check if a string is a valid `at://` URI.
100
101 ## Examples
102
103 iex> Atex.AtURI.match?("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
104 true
105
106 iex> Atex.AtURI.match?("at://did:web:comet.sh")
107 true
108
109 iex> Atex.AtURI.match?("at://ovyerus.com/sh.comet.v0.feed.track")
110 true
111
112 iex> Atex.AtURI.match?("gobbledy gook")
113 false
114 """
115 @spec match?(String.t()) :: boolean()
116 def match?(string), do: Regex.match?(@re, string)
117
118 @doc """
119 Format an `Atex.AtURI` to the canonical string representation.
120
121 Also available via the `String.Chars` protocol.
122
123 ## Examples
124
125 iex> aturi = %Atex.AtURI{
126 ...> rkey: "3jwdwj2ctlk26",
127 ...> collection: "app.bsky.feed.post",
128 ...> authority: "did:plc:44ybard66vv44zksje25o7dz"
129 ...> }
130 iex> Atex.AtURI.to_string(aturi)
131 "at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26"
132
133 iex> aturi = %Atex.AtURI{authority: "did:web:comet.sh"}
134 iex> to_string(aturi)
135 "at://did:web:comet.sh"
136 """
137 @spec to_string(t()) :: String.t()
138 def to_string(%__MODULE__{} = uri) do
139 "at://#{uri.authority}/#{uri.collection}/#{uri.rkey}"
140 |> String.trim_trailing("/")
141 end
142
143 defp from_named_captures(%{"authority" => authority, "collection" => "", "rkey" => ""}),
144 do: %__MODULE__{authority: authority}
145
146 defp from_named_captures(%{"authority" => authority, "collection" => collection, "rkey" => ""}),
147 do: %__MODULE__{authority: authority, collection: collection}
148
149 defp from_named_captures(%{
150 "authority" => authority,
151 "collection" => collection,
152 "rkey" => rkey
153 }),
154 do: %__MODULE__{authority: authority, collection: collection, rkey: rkey}
155end
156
157defimpl String.Chars, for: Atex.AtURI do
158 def to_string(uri), do: Atex.AtURI.to_string(uri)
159end