An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: turn Atex.NSID into a struct

+515 -83
+6 -2
CHANGELOG.md
··· 8 8 9 9 ## [Unreleased] 10 10 11 + ### Breaking Changes 12 + 13 + - `Atex.NSID` is now a struct (`%Atex.NSID{authority, name, fragment}`). Public 14 + functions now accept and return structs. You can use `new/1`, `new!/1` or the 15 + new `~NSID""` for constructing from a NSID string. 16 + 11 17 ### Added 12 18 13 19 - `Atex.Repo` module for building, mutating, signing, serialising, and loading ··· 15 21 efficient processing of large repository exports. 16 22 - `Atex.XRPC.UnauthedClient` module for running unauthenticated XRPC fetches on 17 23 public APIs or PDSes. 18 - - `Atex.NSID.authority_domain/1` for deriving the `_lexicon.<authority>` DNS 19 - name from an NSID. 20 24 - `Atex.Lexicon.Resolver` module for resolving published lexicons by NSID, 21 25 following the 22 26 [publication and resolution spec](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution).
+2 -2
README.md
··· 4 4 5 5 ## Feature map 6 6 7 - - [ ] ATProto strings 7 + - [x] ATProto strings 8 8 - [x] `at://` links 9 9 - [x] TIDs 10 - - [ ] NSIDs 10 + - [x] NSIDs 11 11 - [x] Identity resolution with bi-directional validation and caching. 12 12 - [x] Macro and codegen for converting Lexicon definitions to runtime schemas 13 13 and structs.
+25 -12
lib/atex/lexicon.ex
··· 85 85 |> then(&Recase.Enumerable.atomize_keys/1) 86 86 |> then(&Atex.Lexicon.Schema.lexicon!/1) 87 87 88 + nsid = Atex.NSID.new!(lexicon.id) 89 + 88 90 defs = 89 91 lexicon.defs 90 - |> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end) 92 + |> Enum.flat_map(fn {def_name, def} -> def_to_schema(nsid, def_name, def) end) 91 93 |> Enum.map(fn 92 94 {schema_key, quoted_schema, quoted_type} -> {schema_key, quoted_schema, quoted_type, nil} 93 95 x -> x ··· 135 137 136 138 # - [ ] `t()` type should be the struct in it. (add to non-main structs too?) 137 139 138 - @spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) :: 140 + @spec def_to_schema(nsid :: Atex.NSID.t(), def_name :: String.t(), lexicon_def :: map()) :: 139 141 list( 140 142 { 141 143 key :: atom(), ··· 152 154 153 155 defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do 154 156 # TODO: record rkey format validator 155 - type_name = Atex.NSID.canonical_name(nsid, to_string(def_name)) 157 + type_name = Atex.NSID.canonical_name(%{nsid | fragment: to_string(def_name)}) 156 158 157 159 record = 158 160 put_in(record, [:properties, :"$type"], %{ ··· 218 220 {key, %{default: default}} -> {key, default} 219 221 {key, _field} -> {key, nil} 220 222 end) 221 - |> then(&(&1 ++ [{:"$type", if(def_name == :main, do: nsid, else: "#{nsid}##{def_name}")}])) 223 + |> then( 224 + &(&1 ++ 225 + [ 226 + {:"$type", 227 + if(def_name == :main, 228 + do: Atex.NSID.to_string(nsid), 229 + else: "#{nsid.authority}.#{nsid.name}##{def_name}" 230 + )} 231 + ]) 232 + ) 222 233 223 234 enforced_keys = 224 235 properties |> Map.keys() |> Enum.filter(&(to_string(&1) in required && &1 != :"$type")) ··· 477 488 defp def_to_schema(nsid, def_name, %{type: "ref", ref: ref}) do 478 489 target_module = 479 490 nsid 480 - |> Atex.NSID.expand_possible_fragment_shorthand(ref) 491 + |> Atex.NSID.expand_fragment_shorthand(ref) 481 492 |> ref_to_module() 482 493 483 494 {quoted_schema, quoted_type} = field_to_schema(%{type: "ref", ref: ref}, nsid) ··· 494 505 target_modules = 495 506 Enum.map(refs, fn ref -> 496 507 nsid 497 - |> Atex.NSID.expand_possible_fragment_shorthand(ref) 508 + |> Atex.NSID.expand_fragment_shorthand(ref) 498 509 |> ref_to_module() 499 510 end) 500 511 ··· 530 541 [{atomise(def_name), quoted_schema, quoted_type}] 531 542 end 532 543 533 - @spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) :: 544 + @spec field_to_schema(field_def :: %{type: String.t()}, nsid :: Atex.NSID.t()) :: 534 545 {quoted_schema :: term(), quoted_typespec :: term()} 535 546 defp field_to_schema(%{type: "string"} = field, _nsid) do 536 547 fixed_schema = const_or_enum(field) ··· 655 666 defp field_to_schema(%{type: "ref", ref: ref}, nsid) do 656 667 {nsid, fragment} = 657 668 nsid 658 - |> Atex.NSID.expand_possible_fragment_shorthand(ref) 669 + |> Atex.NSID.expand_fragment_shorthand(ref) 670 + |> Atex.NSID.new!() 659 671 |> Atex.NSID.to_atom_with_fragment() 660 672 661 673 fragment = Recase.to_snake(fragment) ··· 678 690 |> Enum.map(fn ref -> 679 691 {nsid, fragment} = 680 692 nsid 681 - |> Atex.NSID.expand_possible_fragment_shorthand(ref) 693 + |> Atex.NSID.expand_fragment_shorthand(ref) 694 + |> Atex.NSID.new!() 682 695 |> Atex.NSID.to_atom_with_fragment() 683 696 684 697 fragment = Recase.to_snake(fragment) ··· 731 744 defp atomise(x) when is_atom(x), do: x 732 745 defp atomise(x) when is_binary(x), do: String.to_atom(x) 733 746 734 - # Resolves a fully-expanded NSID (possibly with a `#fragment`) to the 747 + # Resolves a fully-expanded NSID string (possibly with a `#fragment`) to the 735 748 # Elixir module atom that `deflexicon` generates for it. When the fragment is 736 749 # `main` (or absent), the module is the root NSID module. Otherwise it is a 737 750 # PascalCase-named submodule of the root NSID module. 738 - defp ref_to_module(expanded_nsid) do 739 - {nsid_atom, fragment} = Atex.NSID.to_atom_with_fragment(expanded_nsid) 751 + defp ref_to_module(expanded_nsid) when is_binary(expanded_nsid) do 752 + {nsid_atom, fragment} = expanded_nsid |> Atex.NSID.new!() |> Atex.NSID.to_atom_with_fragment() 740 753 741 754 if fragment == :main do 742 755 nsid_atom
+2 -1
lib/atex/lexicon/resolver.ex
··· 62 62 | :invalid_record 63 63 | any()} 64 64 def resolve(nsid) do 65 - with {:ok, authority_domain} <- NSID.authority_domain(nsid), 65 + with {:ok, parsed} <- NSID.new(nsid), 66 + authority_domain = NSID.authority_domain(parsed), 66 67 {:ok, did} <- resolve_did_from_dns(authority_domain), 67 68 {:ok, document} <- resolve_did_document(did), 68 69 {:ok, pds_endpoint} <- get_pds_endpoint(document) do
+292 -52
lib/atex/nsid.ex
··· 1 1 defmodule Atex.NSID do 2 + @moduledoc """ 3 + Represents an AT Protocol Namespaced Identifier (NSID). 4 + 5 + An NSID consists of a **domain authority** (reversed domain name, e.g. 6 + `"app.bsky.feed"`) and a **name** segment (e.g. `"post"`), optionally 7 + followed by a **fragment** (e.g. `"view"`), which is a Lexicon-level concept. 8 + 9 + ## Structure 10 + 11 + - `authority` - the reversed-domain portion, e.g. `"app.bsky.feed"` 12 + - `name` - the final camelCase segment, e.g. `"post"` 13 + - `fragment` - optional fragment string, e.g. `"view"` (nil for plain NSIDs) 14 + 15 + ## Construction 16 + 17 + iex> Atex.NSID.new("app.bsky.feed.post") 18 + {:ok, ~NSID"app.bsky.feed.post"} 19 + 20 + iex> Atex.NSID.new("app.bsky.feed.post#view") 21 + {:ok, ~NSID"app.bsky.feed.post#view"} 22 + 23 + iex> Atex.NSID.new("invalid") 24 + {:error, :invalid_nsid} 25 + 26 + iex> Atex.NSID.new!("app.bsky.feed.post") 27 + ~NSID"app.bsky.feed.post" 28 + 29 + ## Sigil 30 + 31 + Use `~NSID"..."` for convenient literal construction. Raises `ArgumentError` 32 + at the call site if the string is not a valid NSID. 33 + 34 + import Atex.NSID, only: [sigil_NSID: 2] 35 + nsid = ~NSID"com.atproto.sync.getRecord" 36 + """ 37 + 2 38 @re ~r/^[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})?)$/ 3 - # TODO: regex with support for fragment 4 39 40 + use TypedStruct 41 + 42 + typedstruct do 43 + field :authority, String.t(), enforce: true 44 + field :name, String.t(), enforce: true 45 + field :fragment, String.t() | nil 46 + end 47 + 48 + @doc """ 49 + Returns the compiled NSID validation regex. 50 + 51 + Useful for embedding into schema validators. 52 + 53 + ## Examples 54 + 55 + iex> Atex.NSID.re() 56 + ~r/^[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})?)$/ 57 + """ 5 58 @spec re() :: Regex.t() 6 59 def re, do: @re 7 60 61 + @doc """ 62 + Returns `true` if the given string is a syntactically valid NSID (without 63 + fragment), `false` otherwise. 64 + 65 + ## Examples 66 + 67 + iex> Atex.NSID.match?("app.bsky.feed.post") 68 + true 69 + 70 + iex> Atex.NSID.match?("invalid") 71 + false 72 + """ 8 73 @spec match?(String.t()) :: boolean() 9 74 def match?(value), do: Regex.match?(@re, value) 10 75 11 - @spec to_atom(String.t()) :: atom() 12 - def to_atom(nsid, fully_qualify \\ true) do 13 - nsid 14 - |> String.split(".") 15 - |> Enum.map(&Recase.to_pascal/1) 16 - |> then(fn parts -> 76 + @doc """ 77 + Parses a string into an `%Atex.NSID{}` struct. 78 + 79 + Accepts an optional `#fragment` suffix. Returns `{:error, :invalid_nsid}` if 80 + the base NSID portion is not syntactically valid. 81 + 82 + ## Examples 83 + 84 + iex> Atex.NSID.new("app.bsky.feed.post") 85 + {:ok, ~NSID"app.bsky.feed.post"} 86 + 87 + iex> Atex.NSID.new("app.bsky.feed.post#view") 88 + {:ok, ~NSID"app.bsky.feed.post#view"} 89 + 90 + iex> Atex.NSID.new("invalid") 91 + {:error, :invalid_nsid} 92 + """ 93 + @spec new(String.t()) :: {:ok, t()} | {:error, :invalid_nsid} 94 + def new(string) when is_binary(string) do 95 + {base, fragment} = split_fragment(string) 96 + 97 + if match?(base) do 98 + {authority, name} = split_authority_name(base) 99 + {:ok, %__MODULE__{authority: authority, name: name, fragment: fragment}} 100 + else 101 + {:error, :invalid_nsid} 102 + end 103 + end 104 + 105 + @doc """ 106 + Parses a string into an `%Atex.NSID{}` struct, raising `ArgumentError` on 107 + invalid input. 108 + 109 + ## Examples 110 + 111 + iex> Atex.NSID.new!("app.bsky.feed.post") 112 + ~NSID"app.bsky.feed.post" 113 + 114 + iex> Atex.NSID.new!("bad") 115 + ** (ArgumentError) invalid NSID: "bad" 116 + """ 117 + @spec new!(String.t()) :: t() 118 + def new!(string) when is_binary(string) do 119 + case new(string) do 120 + {:ok, nsid} -> nsid 121 + {:error, :invalid_nsid} -> raise ArgumentError, "invalid NSID: #{inspect(string)}" 122 + end 123 + end 124 + 125 + @doc """ 126 + Sigil for constructing an `%Atex.NSID{}` at runtime, raising `ArgumentError` 127 + for invalid input. 128 + 129 + ## Examples 130 + 131 + iex> import Atex.NSID, only: [sigil_NSID: 2] 132 + iex> ~NSID"app.bsky.feed.post" 133 + ~NSID"app.bsky.feed.post" 134 + """ 135 + defmacro sigil_NSID({:<<>>, _meta, [string]}, []) when is_binary(string) do 136 + nsid = Atex.NSID.new!(string) 137 + 138 + quote do 139 + unquote(Macro.escape(nsid)) 140 + end 141 + end 142 + 143 + defmacro sigil_NSID({:<<>>, _meta, _parts}, []) do 144 + quote do 145 + Atex.NSID.new!( 146 + unquote({:<<>>, [], [{:"::", [], [{:fragments, [], nil}, {:binary, [], nil}]}]}) 147 + ) 148 + end 149 + end 150 + 151 + @doc """ 152 + Converts an `%Atex.NSID{}` to its canonical string representation. 153 + 154 + Includes the fragment if present. 155 + 156 + ## Examples 157 + 158 + iex> Atex.NSID.to_string(~NSID"app.bsky.feed.post") 159 + "app.bsky.feed.post" 160 + 161 + iex> Atex.NSID.to_string(~NSID"app.bsky.feed.post#view") 162 + "app.bsky.feed.post#view" 163 + """ 164 + @spec to_string(t()) :: String.t() 165 + def to_string(%__MODULE__{authority: authority, name: name, fragment: nil}) do 166 + "#{authority}.#{name}" 167 + end 168 + 169 + def to_string(%__MODULE__{authority: authority, name: name, fragment: fragment}) do 170 + "#{authority}.#{name}##{fragment}" 171 + end 172 + 173 + @doc """ 174 + Converts an `%Atex.NSID{}` to an Elixir module atom. 175 + 176 + The fragment is ignored; only the base NSID segments are used. 177 + 178 + ## Examples 179 + 180 + iex> Atex.NSID.to_atom(~NSID"app.bsky.feed.post") 181 + App.Bsky.Feed.Post 182 + 183 + iex> Atex.NSID.to_atom(~NSID"app.bsky.feed.post", false) 184 + :"Elixir.App.Bsky.Feed.Post" 185 + """ 186 + @spec to_atom(t(), boolean()) :: atom() 187 + def to_atom(%__MODULE__{authority: authority, name: name}, fully_qualify \\ true) do 188 + parts = 189 + "#{authority}.#{name}" 190 + |> String.split(".") 191 + |> Enum.map(&Recase.to_pascal/1) 192 + 193 + parts = 17 194 if fully_qualify do 18 195 ["Elixir" | parts] 19 196 else 20 197 parts 21 198 end 22 - end) 199 + 200 + parts 23 201 |> Enum.join(".") 24 202 |> String.to_atom() 25 203 end 26 204 27 - @spec to_atom_with_fragment(String.t()) :: {atom(), atom()} 28 - def to_atom_with_fragment(nsid) do 29 - if !String.contains?(nsid, "#") do 30 - {to_atom(nsid), :main} 31 - else 32 - [nsid, fragment] = String.split(nsid, "#") 33 - {to_atom(nsid), String.to_atom(fragment)} 34 - end 205 + @doc """ 206 + Converts an `%Atex.NSID{}` to a `{module_atom, fragment_atom}` pair. 207 + 208 + The fragment defaults to `:main` when absent. 209 + 210 + ## Examples 211 + 212 + iex> Atex.NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post") 213 + {App.Bsky.Feed.Post, :main} 214 + 215 + iex> Atex.NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post#view") 216 + {App.Bsky.Feed.Post, :view} 217 + """ 218 + @spec to_atom_with_fragment(t()) :: {atom(), atom()} 219 + def to_atom_with_fragment(%__MODULE__{fragment: nil} = nsid) do 220 + {to_atom(nsid), :main} 35 221 end 36 222 37 - @spec expand_possible_fragment_shorthand(String.t(), String.t()) :: String.t() 38 - def expand_possible_fragment_shorthand(main_nsid, possible_fragment) do 39 - if String.starts_with?(possible_fragment, "#") do 40 - main_nsid <> possible_fragment 41 - else 42 - possible_fragment 43 - end 223 + def to_atom_with_fragment(%__MODULE__{fragment: fragment} = nsid) do 224 + {to_atom(nsid), String.to_atom(fragment)} 44 225 end 45 226 46 - @spec canonical_name(String.t(), String.t()) :: String.t() 47 - def canonical_name(nsid, fragment) do 48 - if fragment == "main" do 49 - nsid 227 + @doc """ 228 + Expands a possible fragment shorthand relative to this NSID. 229 + 230 + If `ref` starts with `"#"`, it is treated as a fragment shorthand and 231 + prefixed with the base NSID string. Otherwise `ref` is returned unchanged. 232 + 233 + ## Examples 234 + 235 + iex> Atex.NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "#view") 236 + "app.bsky.feed.post#view" 237 + 238 + iex> Atex.NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "com.example.other") 239 + "com.example.other" 240 + """ 241 + @spec expand_fragment_shorthand(t(), String.t()) :: String.t() 242 + def expand_fragment_shorthand(%__MODULE__{} = nsid, ref) when is_binary(ref) do 243 + base = "#{nsid.authority}.#{nsid.name}" 244 + 245 + if String.starts_with?(ref, "#") do 246 + base <> ref 50 247 else 51 - "#{nsid}##{fragment}" 248 + ref 52 249 end 53 250 end 54 251 55 252 @doc """ 56 - Returns the DNS authority domain for a given NSID, as used for lexicon 57 - resolution via DNS TXT records. 58 - 59 - The authority domain is derived by stripping the final name segment from the 60 - NSID, reversing the remaining authority parts, and prepending `_lexicon.`. 253 + Returns the canonical Lexicon name for this NSID. 61 254 62 - Returns `{:error, :invalid_nsid}` if the input is not a valid NSID. 255 + Returns the plain NSID string when the fragment is `"main"` or `nil`, and 256 + `"nsid#fragment"` otherwise. 63 257 64 258 ## Examples 65 259 66 - iex> Atex.NSID.authority_domain("app.bsky.feed.post") 67 - {:ok, "_lexicon.feed.bsky.app"} 260 + iex> Atex.NSID.canonical_name(~NSID"app.bsky.feed.post") 261 + "app.bsky.feed.post" 68 262 69 - iex> Atex.NSID.authority_domain("edu.university.dept.lab.blogging.getBlogPost") 70 - {:ok, "_lexicon.blogging.lab.dept.university.edu"} 263 + iex> Atex.NSID.canonical_name(~NSID"app.bsky.feed.post#view") 264 + "app.bsky.feed.post#view" 71 265 72 - iex> Atex.NSID.authority_domain("invalid") 73 - {:error, :invalid_nsid} 266 + iex> Atex.NSID.canonical_name(%Atex.NSID{authority: "app.bsky.feed", name: "post", fragment: "main"}) 267 + "app.bsky.feed.post" 74 268 """ 75 - @spec authority_domain(String.t()) :: {:ok, String.t()} | {:error, :invalid_nsid} 76 - def authority_domain(nsid) do 77 - if match?(nsid) do 78 - authority = 79 - nsid 80 - |> String.split(".") 81 - |> Enum.drop(-1) 82 - |> Enum.reverse() 83 - |> Enum.join(".") 269 + @spec canonical_name(t()) :: String.t() 270 + def canonical_name(%__MODULE__{fragment: fragment} = nsid) 271 + when is_nil(fragment) or fragment == "main" do 272 + "#{nsid.authority}.#{nsid.name}" 273 + end 274 + 275 + def canonical_name(%__MODULE__{} = nsid) do 276 + "#{nsid.authority}.#{nsid.name}##{nsid.fragment}" 277 + end 278 + 279 + @doc """ 280 + Returns the DNS authority domain for this NSID, as used for lexicon 281 + resolution via DNS TXT records. 84 282 85 - {:ok, "_lexicon.#{authority}"} 86 - else 87 - {:error, :invalid_nsid} 283 + The authority domain is derived by reversing the authority segments and 284 + prepending `_lexicon.`. 285 + 286 + ## Examples 287 + 288 + iex> Atex.NSID.authority_domain(~NSID"app.bsky.feed.post") 289 + "_lexicon.feed.bsky.app" 290 + 291 + iex> Atex.NSID.authority_domain(~NSID"edu.university.dept.lab.blogging.getBlogPost") 292 + "_lexicon.blogging.lab.dept.university.edu" 293 + """ 294 + @spec authority_domain(t()) :: String.t() 295 + def authority_domain(%__MODULE__{authority: authority}) do 296 + reversed = 297 + authority 298 + |> String.split(".") 299 + |> Enum.reverse() 300 + |> Enum.join(".") 301 + 302 + "_lexicon.#{reversed}" 303 + end 304 + 305 + # --- Private helpers --- 306 + 307 + defp split_fragment(string) do 308 + case String.split(string, "#", parts: 2) do 309 + [base, fragment] -> {base, fragment} 310 + [base] -> {base, nil} 311 + end 312 + end 313 + 314 + defp split_authority_name(base) do 315 + segments = String.split(base, ".") 316 + name = List.last(segments) 317 + authority = segments |> Enum.drop(-1) |> Enum.join(".") 318 + {authority, name} 319 + end 320 + 321 + defimpl String.Chars do 322 + def to_string(nsid), do: Atex.NSID.to_string(nsid) 323 + end 324 + 325 + defimpl Inspect do 326 + def inspect(%Atex.NSID{} = nsid, _opts) do 327 + "~NSID\"#{Atex.NSID.to_string(nsid)}\"" 88 328 end 89 329 end 90 330 end
+188 -14
test/atex/nsid_test.exs
··· 1 1 defmodule Atex.NSIDTest do 2 2 use ExUnit.Case, async: true 3 3 4 + import Atex.NSID, only: [sigil_NSID: 2] 5 + 4 6 alias Atex.NSID 5 7 6 8 # --------------------------------------------------------------------------- 7 - # NSID.authority_domain/1 9 + # NSID.new/1 8 10 # --------------------------------------------------------------------------- 9 11 10 - describe "NSID.authority_domain/1" do 11 - test "converts a standard 4-part NSID" do 12 - assert {:ok, "_lexicon.feed.bsky.app"} = NSID.authority_domain("app.bsky.feed.post") 12 + describe "NSID.new/1" do 13 + test "parses a standard 4-part NSID" do 14 + assert {:ok, %NSID{authority: "app.bsky.feed", name: "post", fragment: nil}} = 15 + NSID.new("app.bsky.feed.post") 13 16 end 14 17 15 - test "matches the spec example" do 16 - assert {:ok, "_lexicon.blogging.lab.dept.university.edu"} = 17 - NSID.authority_domain("edu.university.dept.lab.blogging.getBlogPost") 18 + test "parses a minimal 3-segment NSID" do 19 + assert {:ok, %NSID{authority: "com.example", name: "record", fragment: nil}} = 20 + NSID.new("com.example.record") 18 21 end 19 22 20 - test "handles a minimal 3-segment NSID" do 21 - assert {:ok, "_lexicon.example.com"} = NSID.authority_domain("com.example.record") 23 + test "parses an NSID with a fragment" do 24 + assert {:ok, %NSID{authority: "app.bsky.feed", name: "post", fragment: "view"}} = 25 + NSID.new("app.bsky.feed.post#view") 22 26 end 23 27 24 - test "handles NSIDs with numbers in segments" do 25 - assert {:ok, "_lexicon.v0.comet.sh"} = NSID.authority_domain("sh.comet.v0.feed") 28 + test "parses an NSID with numbers in authority segments" do 29 + assert {:ok, %NSID{authority: "sh.comet.v0", name: "feed", fragment: nil}} = 30 + NSID.new("sh.comet.v0.feed") 26 31 end 27 32 28 33 test "returns error for a plain string without dots" do 29 - assert {:error, :invalid_nsid} = NSID.authority_domain("invalid") 34 + assert {:error, :invalid_nsid} = NSID.new("invalid") 30 35 end 31 36 32 37 test "returns error for an empty string" do 33 - assert {:error, :invalid_nsid} = NSID.authority_domain("") 38 + assert {:error, :invalid_nsid} = NSID.new("") 34 39 end 35 40 36 41 test "returns error for a string with invalid characters" do 37 - assert {:error, :invalid_nsid} = NSID.authority_domain("not.valid!") 42 + assert {:error, :invalid_nsid} = NSID.new("not.valid!") 43 + end 44 + 45 + test "returns error for a two-segment string (no name)" do 46 + assert {:error, :invalid_nsid} = NSID.new("com.example") 47 + end 48 + end 49 + 50 + # --------------------------------------------------------------------------- 51 + # NSID.new!/1 52 + # --------------------------------------------------------------------------- 53 + 54 + describe "NSID.new!/1" do 55 + test "returns the struct for a valid NSID" do 56 + assert %NSID{authority: "app.bsky.feed", name: "post"} = NSID.new!("app.bsky.feed.post") 57 + end 58 + 59 + test "raises ArgumentError for an invalid NSID" do 60 + assert_raise ArgumentError, ~r/invalid NSID/, fn -> 61 + NSID.new!("bad") 62 + end 63 + end 64 + end 65 + 66 + # --------------------------------------------------------------------------- 67 + # ~NSID sigil 68 + # --------------------------------------------------------------------------- 69 + 70 + describe "~NSID sigil" do 71 + test "constructs the correct struct" do 72 + assert %NSID{authority: "app.bsky.feed", name: "post", fragment: nil} = 73 + ~NSID"app.bsky.feed.post" 74 + end 75 + 76 + test "constructs the correct struct with a fragment" do 77 + assert %NSID{authority: "app.bsky.feed", name: "post", fragment: "view"} = 78 + ~NSID"app.bsky.feed.post#view" 79 + end 80 + end 81 + 82 + # --------------------------------------------------------------------------- 83 + # String.Chars / to_string 84 + # --------------------------------------------------------------------------- 85 + 86 + describe "String.Chars" do 87 + test "renders a plain NSID" do 88 + assert "app.bsky.feed.post" = to_string(~NSID"app.bsky.feed.post") 89 + end 90 + 91 + test "renders an NSID with a fragment" do 92 + assert "app.bsky.feed.post#view" = to_string(~NSID"app.bsky.feed.post#view") 93 + end 94 + 95 + test "interpolates correctly in a string" do 96 + nsid = ~NSID"app.bsky.feed.post" 97 + assert "type: app.bsky.feed.post" = "type: #{nsid}" 98 + end 99 + end 100 + 101 + # --------------------------------------------------------------------------- 102 + # NSID.match?/1 103 + # --------------------------------------------------------------------------- 104 + 105 + describe "NSID.match?/1" do 106 + test "returns true for a valid NSID" do 107 + assert NSID.match?("app.bsky.feed.post") 108 + end 109 + 110 + test "returns false for an invalid string" do 111 + refute NSID.match?("invalid") 112 + end 113 + 114 + test "returns false for a fragment-bearing string" do 115 + refute NSID.match?("app.bsky.feed.post#view") 116 + end 117 + end 118 + 119 + # --------------------------------------------------------------------------- 120 + # NSID.to_atom/2 121 + # --------------------------------------------------------------------------- 122 + 123 + describe "NSID.to_atom/2" do 124 + test "converts to a fully-qualified module atom by default" do 125 + assert App.Bsky.Feed.Post = NSID.to_atom(~NSID"app.bsky.feed.post") 126 + end 127 + 128 + test "converts without full qualification when false is passed" do 129 + result = NSID.to_atom(~NSID"app.bsky.feed.post", false) 130 + assert result == :"App.Bsky.Feed.Post" 131 + end 132 + 133 + test "ignores any fragment" do 134 + assert App.Bsky.Feed.Post = NSID.to_atom(~NSID"app.bsky.feed.post#view") 135 + end 136 + end 137 + 138 + # --------------------------------------------------------------------------- 139 + # NSID.to_atom_with_fragment/1 140 + # --------------------------------------------------------------------------- 141 + 142 + describe "NSID.to_atom_with_fragment/1" do 143 + test "returns {module, :main} for a plain NSID" do 144 + assert {App.Bsky.Feed.Post, :main} = NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post") 145 + end 146 + 147 + test "returns {module, fragment_atom} when fragment is present" do 148 + assert {App.Bsky.Feed.Post, :view} = 149 + NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post#view") 150 + end 151 + end 152 + 153 + # --------------------------------------------------------------------------- 154 + # NSID.expand_fragment_shorthand/2 155 + # --------------------------------------------------------------------------- 156 + 157 + describe "NSID.expand_fragment_shorthand/2" do 158 + test "expands a shorthand fragment" do 159 + assert "app.bsky.feed.post#view" = 160 + NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "#view") 161 + end 162 + 163 + test "passes through a fully-qualified NSID unchanged" do 164 + assert "com.example.other" = 165 + NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "com.example.other") 166 + end 167 + 168 + test "passes through a non-fragment string unchanged" do 169 + assert "main" = NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "main") 170 + end 171 + end 172 + 173 + # --------------------------------------------------------------------------- 174 + # NSID.canonical_name/1 175 + # --------------------------------------------------------------------------- 176 + 177 + describe "NSID.canonical_name/1" do 178 + test "returns the plain NSID when fragment is nil" do 179 + assert "app.bsky.feed.post" = NSID.canonical_name(~NSID"app.bsky.feed.post") 180 + end 181 + 182 + test "returns the plain NSID when fragment is \"main\"" do 183 + nsid = %NSID{authority: "app.bsky.feed", name: "post", fragment: "main"} 184 + assert "app.bsky.feed.post" = NSID.canonical_name(nsid) 185 + end 186 + 187 + test "returns nsid#fragment when fragment is non-main" do 188 + assert "app.bsky.feed.post#view" = NSID.canonical_name(~NSID"app.bsky.feed.post#view") 189 + end 190 + end 191 + 192 + # --------------------------------------------------------------------------- 193 + # NSID.authority_domain/1 194 + # --------------------------------------------------------------------------- 195 + 196 + describe "NSID.authority_domain/1" do 197 + test "converts a standard 4-part NSID" do 198 + assert "_lexicon.feed.bsky.app" = NSID.authority_domain(~NSID"app.bsky.feed.post") 199 + end 200 + 201 + test "matches the spec example" do 202 + assert "_lexicon.blogging.lab.dept.university.edu" = 203 + NSID.authority_domain(~NSID"edu.university.dept.lab.blogging.getBlogPost") 204 + end 205 + 206 + test "handles a minimal 3-segment NSID" do 207 + assert "_lexicon.example.com" = NSID.authority_domain(~NSID"com.example.record") 208 + end 209 + 210 + test "handles NSIDs with numbers in segments" do 211 + assert "_lexicon.v0.comet.sh" = NSID.authority_domain(~NSID"sh.comet.v0.feed") 38 212 end 39 213 end 40 214 end