1defmodule 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}
154end
155
156defimpl String.Chars, for: Atex.AtURI do
157 def to_string(%Atex.AtURI{} = uri), do: Atex.AtURI.to_string(uri)
158end