A set of utilities for working with the AT Protocol in Elixir.
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