diff --git a/CHANGELOG.md b/CHANGELOG.md index d45aeeb..b69be9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to ## [Unreleased] +### Breaking Changes + +- `Atex.NSID` is now a struct (`%Atex.NSID{authority, name, fragment}`). Public + functions now accept and return structs. You can use `new/1`, `new!/1` or the + new `~NSID""` for constructing from a NSID string. + ### Added - `Atex.Repo` module for building, mutating, signing, serialising, and loading @@ -15,8 +21,6 @@ and this project adheres to efficient processing of large repository exports. - `Atex.XRPC.UnauthedClient` module for running unauthenticated XRPC fetches on public APIs or PDSes. -- `Atex.NSID.authority_domain/1` for deriving the `_lexicon.` DNS - name from an NSID. - `Atex.Lexicon.Resolver` module for resolving published lexicons by NSID, following the [publication and resolution spec](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution). diff --git a/README.md b/README.md index 6a88812..c508a2c 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ An Elixir toolkit for the [AT Protocol](https://atproto.com). ## Feature map -- [ ] ATProto strings +- [x] ATProto strings - [x] `at://` links - [x] TIDs - - [ ] NSIDs + - [x] NSIDs - [x] Identity resolution with bi-directional validation and caching. - [x] Macro and codegen for converting Lexicon definitions to runtime schemas and structs. diff --git a/lib/atex/lexicon.ex b/lib/atex/lexicon.ex index 75e7e90..e4386a6 100644 --- a/lib/atex/lexicon.ex +++ b/lib/atex/lexicon.ex @@ -85,9 +85,11 @@ defmodule Atex.Lexicon do |> then(&Recase.Enumerable.atomize_keys/1) |> then(&Atex.Lexicon.Schema.lexicon!/1) + nsid = Atex.NSID.new!(lexicon.id) + defs = lexicon.defs - |> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end) + |> Enum.flat_map(fn {def_name, def} -> def_to_schema(nsid, def_name, def) end) |> Enum.map(fn {schema_key, quoted_schema, quoted_type} -> {schema_key, quoted_schema, quoted_type, nil} x -> x @@ -135,7 +137,7 @@ defmodule Atex.Lexicon do # - [ ] `t()` type should be the struct in it. (add to non-main structs too?) - @spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) :: + @spec def_to_schema(nsid :: Atex.NSID.t(), def_name :: String.t(), lexicon_def :: map()) :: list( { key :: atom(), @@ -152,7 +154,7 @@ defmodule Atex.Lexicon do defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do # TODO: record rkey format validator - type_name = Atex.NSID.canonical_name(nsid, to_string(def_name)) + type_name = Atex.NSID.canonical_name(%{nsid | fragment: to_string(def_name)}) record = put_in(record, [:properties, :"$type"], %{ @@ -218,7 +220,16 @@ defmodule Atex.Lexicon do {key, %{default: default}} -> {key, default} {key, _field} -> {key, nil} end) - |> then(&(&1 ++ [{:"$type", if(def_name == :main, do: nsid, else: "#{nsid}##{def_name}")}])) + |> then( + &(&1 ++ + [ + {:"$type", + if(def_name == :main, + do: Atex.NSID.to_string(nsid), + else: "#{nsid.authority}.#{nsid.name}##{def_name}" + )} + ]) + ) enforced_keys = properties |> Map.keys() |> Enum.filter(&(to_string(&1) in required && &1 != :"$type")) @@ -477,7 +488,7 @@ defmodule Atex.Lexicon do defp def_to_schema(nsid, def_name, %{type: "ref", ref: ref}) do target_module = nsid - |> Atex.NSID.expand_possible_fragment_shorthand(ref) + |> Atex.NSID.expand_fragment_shorthand(ref) |> ref_to_module() {quoted_schema, quoted_type} = field_to_schema(%{type: "ref", ref: ref}, nsid) @@ -494,7 +505,7 @@ defmodule Atex.Lexicon do target_modules = Enum.map(refs, fn ref -> nsid - |> Atex.NSID.expand_possible_fragment_shorthand(ref) + |> Atex.NSID.expand_fragment_shorthand(ref) |> ref_to_module() end) @@ -530,7 +541,7 @@ defmodule Atex.Lexicon do [{atomise(def_name), quoted_schema, quoted_type}] end - @spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) :: + @spec field_to_schema(field_def :: %{type: String.t()}, nsid :: Atex.NSID.t()) :: {quoted_schema :: term(), quoted_typespec :: term()} defp field_to_schema(%{type: "string"} = field, _nsid) do fixed_schema = const_or_enum(field) @@ -655,7 +666,8 @@ defmodule Atex.Lexicon do defp field_to_schema(%{type: "ref", ref: ref}, nsid) do {nsid, fragment} = nsid - |> Atex.NSID.expand_possible_fragment_shorthand(ref) + |> Atex.NSID.expand_fragment_shorthand(ref) + |> Atex.NSID.new!() |> Atex.NSID.to_atom_with_fragment() fragment = Recase.to_snake(fragment) @@ -678,7 +690,8 @@ defmodule Atex.Lexicon do |> Enum.map(fn ref -> {nsid, fragment} = nsid - |> Atex.NSID.expand_possible_fragment_shorthand(ref) + |> Atex.NSID.expand_fragment_shorthand(ref) + |> Atex.NSID.new!() |> Atex.NSID.to_atom_with_fragment() fragment = Recase.to_snake(fragment) @@ -731,12 +744,12 @@ defmodule Atex.Lexicon do defp atomise(x) when is_atom(x), do: x defp atomise(x) when is_binary(x), do: String.to_atom(x) - # Resolves a fully-expanded NSID (possibly with a `#fragment`) to the + # Resolves a fully-expanded NSID string (possibly with a `#fragment`) to the # Elixir module atom that `deflexicon` generates for it. When the fragment is # `main` (or absent), the module is the root NSID module. Otherwise it is a # PascalCase-named submodule of the root NSID module. - defp ref_to_module(expanded_nsid) do - {nsid_atom, fragment} = Atex.NSID.to_atom_with_fragment(expanded_nsid) + defp ref_to_module(expanded_nsid) when is_binary(expanded_nsid) do + {nsid_atom, fragment} = expanded_nsid |> Atex.NSID.new!() |> Atex.NSID.to_atom_with_fragment() if fragment == :main do nsid_atom diff --git a/lib/atex/lexicon/resolver.ex b/lib/atex/lexicon/resolver.ex index 0a98eac..851682b 100644 --- a/lib/atex/lexicon/resolver.ex +++ b/lib/atex/lexicon/resolver.ex @@ -62,7 +62,8 @@ defmodule Atex.Lexicon.Resolver do | :invalid_record | any()} def resolve(nsid) do - with {:ok, authority_domain} <- NSID.authority_domain(nsid), + with {:ok, parsed} <- NSID.new(nsid), + authority_domain = NSID.authority_domain(parsed), {:ok, did} <- resolve_did_from_dns(authority_domain), {:ok, document} <- resolve_did_document(did), {:ok, pds_endpoint} <- get_pds_endpoint(document) do diff --git a/lib/atex/nsid.ex b/lib/atex/nsid.ex index 0d85f6f..fc9ee52 100644 --- a/lib/atex/nsid.ex +++ b/lib/atex/nsid.ex @@ -1,90 +1,330 @@ defmodule Atex.NSID do + @moduledoc """ + Represents an AT Protocol Namespaced Identifier (NSID). + + An NSID consists of a **domain authority** (reversed domain name, e.g. + `"app.bsky.feed"`) and a **name** segment (e.g. `"post"`), optionally + followed by a **fragment** (e.g. `"view"`), which is a Lexicon-level concept. + + ## Structure + + - `authority` - the reversed-domain portion, e.g. `"app.bsky.feed"` + - `name` - the final camelCase segment, e.g. `"post"` + - `fragment` - optional fragment string, e.g. `"view"` (nil for plain NSIDs) + + ## Construction + + iex> Atex.NSID.new("app.bsky.feed.post") + {:ok, ~NSID"app.bsky.feed.post"} + + iex> Atex.NSID.new("app.bsky.feed.post#view") + {:ok, ~NSID"app.bsky.feed.post#view"} + + iex> Atex.NSID.new("invalid") + {:error, :invalid_nsid} + + iex> Atex.NSID.new!("app.bsky.feed.post") + ~NSID"app.bsky.feed.post" + + ## Sigil + + Use `~NSID"..."` for convenient literal construction. Raises `ArgumentError` + at the call site if the string is not a valid NSID. + + import Atex.NSID, only: [sigil_NSID: 2] + nsid = ~NSID"com.atproto.sync.getRecord" + """ + @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})?)$/ - # TODO: regex with support for fragment + use TypedStruct + + typedstruct do + field :authority, String.t(), enforce: true + field :name, String.t(), enforce: true + field :fragment, String.t() | nil + end + + @doc """ + Returns the compiled NSID validation regex. + + Useful for embedding into schema validators. + + ## Examples + + iex> Atex.NSID.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})?)$/ + """ @spec re() :: Regex.t() def re, do: @re + @doc """ + Returns `true` if the given string is a syntactically valid NSID (without + fragment), `false` otherwise. + + ## Examples + + iex> Atex.NSID.match?("app.bsky.feed.post") + true + + iex> Atex.NSID.match?("invalid") + false + """ @spec match?(String.t()) :: boolean() def match?(value), do: Regex.match?(@re, value) - @spec to_atom(String.t()) :: atom() - def to_atom(nsid, fully_qualify \\ true) do - nsid - |> String.split(".") - |> Enum.map(&Recase.to_pascal/1) - |> then(fn parts -> + @doc """ + Parses a string into an `%Atex.NSID{}` struct. + + Accepts an optional `#fragment` suffix. Returns `{:error, :invalid_nsid}` if + the base NSID portion is not syntactically valid. + + ## Examples + + iex> Atex.NSID.new("app.bsky.feed.post") + {:ok, ~NSID"app.bsky.feed.post"} + + iex> Atex.NSID.new("app.bsky.feed.post#view") + {:ok, ~NSID"app.bsky.feed.post#view"} + + iex> Atex.NSID.new("invalid") + {:error, :invalid_nsid} + """ + @spec new(String.t()) :: {:ok, t()} | {:error, :invalid_nsid} + def new(string) when is_binary(string) do + {base, fragment} = split_fragment(string) + + if match?(base) do + {authority, name} = split_authority_name(base) + {:ok, %__MODULE__{authority: authority, name: name, fragment: fragment}} + else + {:error, :invalid_nsid} + end + end + + @doc """ + Parses a string into an `%Atex.NSID{}` struct, raising `ArgumentError` on + invalid input. + + ## Examples + + iex> Atex.NSID.new!("app.bsky.feed.post") + ~NSID"app.bsky.feed.post" + + iex> Atex.NSID.new!("bad") + ** (ArgumentError) invalid NSID: "bad" + """ + @spec new!(String.t()) :: t() + def new!(string) when is_binary(string) do + case new(string) do + {:ok, nsid} -> nsid + {:error, :invalid_nsid} -> raise ArgumentError, "invalid NSID: #{inspect(string)}" + end + end + + @doc """ + Sigil for constructing an `%Atex.NSID{}` at runtime, raising `ArgumentError` + for invalid input. + + ## Examples + + iex> import Atex.NSID, only: [sigil_NSID: 2] + iex> ~NSID"app.bsky.feed.post" + ~NSID"app.bsky.feed.post" + """ + defmacro sigil_NSID({:<<>>, _meta, [string]}, []) when is_binary(string) do + nsid = Atex.NSID.new!(string) + + quote do + unquote(Macro.escape(nsid)) + end + end + + defmacro sigil_NSID({:<<>>, _meta, _parts}, []) do + quote do + Atex.NSID.new!( + unquote({:<<>>, [], [{:"::", [], [{:fragments, [], nil}, {:binary, [], nil}]}]}) + ) + end + end + + @doc """ + Converts an `%Atex.NSID{}` to its canonical string representation. + + Includes the fragment if present. + + ## Examples + + iex> Atex.NSID.to_string(~NSID"app.bsky.feed.post") + "app.bsky.feed.post" + + iex> Atex.NSID.to_string(~NSID"app.bsky.feed.post#view") + "app.bsky.feed.post#view" + """ + @spec to_string(t()) :: String.t() + def to_string(%__MODULE__{authority: authority, name: name, fragment: nil}) do + "#{authority}.#{name}" + end + + def to_string(%__MODULE__{authority: authority, name: name, fragment: fragment}) do + "#{authority}.#{name}##{fragment}" + end + + @doc """ + Converts an `%Atex.NSID{}` to an Elixir module atom. + + The fragment is ignored; only the base NSID segments are used. + + ## Examples + + iex> Atex.NSID.to_atom(~NSID"app.bsky.feed.post") + App.Bsky.Feed.Post + + iex> Atex.NSID.to_atom(~NSID"app.bsky.feed.post", false) + :"Elixir.App.Bsky.Feed.Post" + """ + @spec to_atom(t(), boolean()) :: atom() + def to_atom(%__MODULE__{authority: authority, name: name}, fully_qualify \\ true) do + parts = + "#{authority}.#{name}" + |> String.split(".") + |> Enum.map(&Recase.to_pascal/1) + + parts = if fully_qualify do ["Elixir" | parts] else parts end - end) + + parts |> Enum.join(".") |> String.to_atom() end - @spec to_atom_with_fragment(String.t()) :: {atom(), atom()} - def to_atom_with_fragment(nsid) do - if !String.contains?(nsid, "#") do - {to_atom(nsid), :main} - else - [nsid, fragment] = String.split(nsid, "#") - {to_atom(nsid), String.to_atom(fragment)} - end + @doc """ + Converts an `%Atex.NSID{}` to a `{module_atom, fragment_atom}` pair. + + The fragment defaults to `:main` when absent. + + ## Examples + + iex> Atex.NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post") + {App.Bsky.Feed.Post, :main} + + iex> Atex.NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post#view") + {App.Bsky.Feed.Post, :view} + """ + @spec to_atom_with_fragment(t()) :: {atom(), atom()} + def to_atom_with_fragment(%__MODULE__{fragment: nil} = nsid) do + {to_atom(nsid), :main} end - @spec expand_possible_fragment_shorthand(String.t(), String.t()) :: String.t() - def expand_possible_fragment_shorthand(main_nsid, possible_fragment) do - if String.starts_with?(possible_fragment, "#") do - main_nsid <> possible_fragment - else - possible_fragment - end + def to_atom_with_fragment(%__MODULE__{fragment: fragment} = nsid) do + {to_atom(nsid), String.to_atom(fragment)} end - @spec canonical_name(String.t(), String.t()) :: String.t() - def canonical_name(nsid, fragment) do - if fragment == "main" do - nsid + @doc """ + Expands a possible fragment shorthand relative to this NSID. + + If `ref` starts with `"#"`, it is treated as a fragment shorthand and + prefixed with the base NSID string. Otherwise `ref` is returned unchanged. + + ## Examples + + iex> Atex.NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "#view") + "app.bsky.feed.post#view" + + iex> Atex.NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "com.example.other") + "com.example.other" + """ + @spec expand_fragment_shorthand(t(), String.t()) :: String.t() + def expand_fragment_shorthand(%__MODULE__{} = nsid, ref) when is_binary(ref) do + base = "#{nsid.authority}.#{nsid.name}" + + if String.starts_with?(ref, "#") do + base <> ref else - "#{nsid}##{fragment}" + ref end end @doc """ - Returns the DNS authority domain for a given NSID, as used for lexicon - resolution via DNS TXT records. - - The authority domain is derived by stripping the final name segment from the - NSID, reversing the remaining authority parts, and prepending `_lexicon.`. + Returns the canonical Lexicon name for this NSID. - Returns `{:error, :invalid_nsid}` if the input is not a valid NSID. + Returns the plain NSID string when the fragment is `"main"` or `nil`, and + `"nsid#fragment"` otherwise. ## Examples - iex> Atex.NSID.authority_domain("app.bsky.feed.post") - {:ok, "_lexicon.feed.bsky.app"} + iex> Atex.NSID.canonical_name(~NSID"app.bsky.feed.post") + "app.bsky.feed.post" - iex> Atex.NSID.authority_domain("edu.university.dept.lab.blogging.getBlogPost") - {:ok, "_lexicon.blogging.lab.dept.university.edu"} + iex> Atex.NSID.canonical_name(~NSID"app.bsky.feed.post#view") + "app.bsky.feed.post#view" - iex> Atex.NSID.authority_domain("invalid") - {:error, :invalid_nsid} + iex> Atex.NSID.canonical_name(%Atex.NSID{authority: "app.bsky.feed", name: "post", fragment: "main"}) + "app.bsky.feed.post" """ - @spec authority_domain(String.t()) :: {:ok, String.t()} | {:error, :invalid_nsid} - def authority_domain(nsid) do - if match?(nsid) do - authority = - nsid - |> String.split(".") - |> Enum.drop(-1) - |> Enum.reverse() - |> Enum.join(".") - - {:ok, "_lexicon.#{authority}"} - else - {:error, :invalid_nsid} + @spec canonical_name(t()) :: String.t() + def canonical_name(%__MODULE__{fragment: fragment} = nsid) + when is_nil(fragment) or fragment == "main" do + "#{nsid.authority}.#{nsid.name}" + end + + def canonical_name(%__MODULE__{} = nsid) do + "#{nsid.authority}.#{nsid.name}##{nsid.fragment}" + end + + @doc """ + Returns the DNS authority domain for this NSID, as used for lexicon + resolution via DNS TXT records. + + The authority domain is derived by reversing the authority segments and + prepending `_lexicon.`. + + ## Examples + + iex> Atex.NSID.authority_domain(~NSID"app.bsky.feed.post") + "_lexicon.feed.bsky.app" + + iex> Atex.NSID.authority_domain(~NSID"edu.university.dept.lab.blogging.getBlogPost") + "_lexicon.blogging.lab.dept.university.edu" + """ + @spec authority_domain(t()) :: String.t() + def authority_domain(%__MODULE__{authority: authority}) do + reversed = + authority + |> String.split(".") + |> Enum.reverse() + |> Enum.join(".") + + "_lexicon.#{reversed}" + end + + # --- Private helpers --- + + defp split_fragment(string) do + case String.split(string, "#", parts: 2) do + [base, fragment] -> {base, fragment} + [base] -> {base, nil} + end + end + + defp split_authority_name(base) do + segments = String.split(base, ".") + name = List.last(segments) + authority = segments |> Enum.drop(-1) |> Enum.join(".") + {authority, name} + end + + defimpl String.Chars do + def to_string(nsid), do: Atex.NSID.to_string(nsid) + end + + defimpl Inspect do + def inspect(%Atex.NSID{} = nsid, _opts) do + "~NSID\"#{Atex.NSID.to_string(nsid)}\"" end end end diff --git a/test/atex/nsid_test.exs b/test/atex/nsid_test.exs index a9b7d40..48c510f 100644 --- a/test/atex/nsid_test.exs +++ b/test/atex/nsid_test.exs @@ -1,40 +1,214 @@ defmodule Atex.NSIDTest do use ExUnit.Case, async: true + import Atex.NSID, only: [sigil_NSID: 2] + alias Atex.NSID # --------------------------------------------------------------------------- - # NSID.authority_domain/1 + # NSID.new/1 # --------------------------------------------------------------------------- - describe "NSID.authority_domain/1" do - test "converts a standard 4-part NSID" do - assert {:ok, "_lexicon.feed.bsky.app"} = NSID.authority_domain("app.bsky.feed.post") + describe "NSID.new/1" do + test "parses a standard 4-part NSID" do + assert {:ok, %NSID{authority: "app.bsky.feed", name: "post", fragment: nil}} = + NSID.new("app.bsky.feed.post") end - test "matches the spec example" do - assert {:ok, "_lexicon.blogging.lab.dept.university.edu"} = - NSID.authority_domain("edu.university.dept.lab.blogging.getBlogPost") + test "parses a minimal 3-segment NSID" do + assert {:ok, %NSID{authority: "com.example", name: "record", fragment: nil}} = + NSID.new("com.example.record") end - test "handles a minimal 3-segment NSID" do - assert {:ok, "_lexicon.example.com"} = NSID.authority_domain("com.example.record") + test "parses an NSID with a fragment" do + assert {:ok, %NSID{authority: "app.bsky.feed", name: "post", fragment: "view"}} = + NSID.new("app.bsky.feed.post#view") end - test "handles NSIDs with numbers in segments" do - assert {:ok, "_lexicon.v0.comet.sh"} = NSID.authority_domain("sh.comet.v0.feed") + test "parses an NSID with numbers in authority segments" do + assert {:ok, %NSID{authority: "sh.comet.v0", name: "feed", fragment: nil}} = + NSID.new("sh.comet.v0.feed") end test "returns error for a plain string without dots" do - assert {:error, :invalid_nsid} = NSID.authority_domain("invalid") + assert {:error, :invalid_nsid} = NSID.new("invalid") end test "returns error for an empty string" do - assert {:error, :invalid_nsid} = NSID.authority_domain("") + assert {:error, :invalid_nsid} = NSID.new("") end test "returns error for a string with invalid characters" do - assert {:error, :invalid_nsid} = NSID.authority_domain("not.valid!") + assert {:error, :invalid_nsid} = NSID.new("not.valid!") + end + + test "returns error for a two-segment string (no name)" do + assert {:error, :invalid_nsid} = NSID.new("com.example") + end + end + + # --------------------------------------------------------------------------- + # NSID.new!/1 + # --------------------------------------------------------------------------- + + describe "NSID.new!/1" do + test "returns the struct for a valid NSID" do + assert %NSID{authority: "app.bsky.feed", name: "post"} = NSID.new!("app.bsky.feed.post") + end + + test "raises ArgumentError for an invalid NSID" do + assert_raise ArgumentError, ~r/invalid NSID/, fn -> + NSID.new!("bad") + end + end + end + + # --------------------------------------------------------------------------- + # ~NSID sigil + # --------------------------------------------------------------------------- + + describe "~NSID sigil" do + test "constructs the correct struct" do + assert %NSID{authority: "app.bsky.feed", name: "post", fragment: nil} = + ~NSID"app.bsky.feed.post" + end + + test "constructs the correct struct with a fragment" do + assert %NSID{authority: "app.bsky.feed", name: "post", fragment: "view"} = + ~NSID"app.bsky.feed.post#view" + end + end + + # --------------------------------------------------------------------------- + # String.Chars / to_string + # --------------------------------------------------------------------------- + + describe "String.Chars" do + test "renders a plain NSID" do + assert "app.bsky.feed.post" = to_string(~NSID"app.bsky.feed.post") + end + + test "renders an NSID with a fragment" do + assert "app.bsky.feed.post#view" = to_string(~NSID"app.bsky.feed.post#view") + end + + test "interpolates correctly in a string" do + nsid = ~NSID"app.bsky.feed.post" + assert "type: app.bsky.feed.post" = "type: #{nsid}" + end + end + + # --------------------------------------------------------------------------- + # NSID.match?/1 + # --------------------------------------------------------------------------- + + describe "NSID.match?/1" do + test "returns true for a valid NSID" do + assert NSID.match?("app.bsky.feed.post") + end + + test "returns false for an invalid string" do + refute NSID.match?("invalid") + end + + test "returns false for a fragment-bearing string" do + refute NSID.match?("app.bsky.feed.post#view") + end + end + + # --------------------------------------------------------------------------- + # NSID.to_atom/2 + # --------------------------------------------------------------------------- + + describe "NSID.to_atom/2" do + test "converts to a fully-qualified module atom by default" do + assert App.Bsky.Feed.Post = NSID.to_atom(~NSID"app.bsky.feed.post") + end + + test "converts without full qualification when false is passed" do + result = NSID.to_atom(~NSID"app.bsky.feed.post", false) + assert result == :"App.Bsky.Feed.Post" + end + + test "ignores any fragment" do + assert App.Bsky.Feed.Post = NSID.to_atom(~NSID"app.bsky.feed.post#view") + end + end + + # --------------------------------------------------------------------------- + # NSID.to_atom_with_fragment/1 + # --------------------------------------------------------------------------- + + describe "NSID.to_atom_with_fragment/1" do + test "returns {module, :main} for a plain NSID" do + assert {App.Bsky.Feed.Post, :main} = NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post") + end + + test "returns {module, fragment_atom} when fragment is present" do + assert {App.Bsky.Feed.Post, :view} = + NSID.to_atom_with_fragment(~NSID"app.bsky.feed.post#view") + end + end + + # --------------------------------------------------------------------------- + # NSID.expand_fragment_shorthand/2 + # --------------------------------------------------------------------------- + + describe "NSID.expand_fragment_shorthand/2" do + test "expands a shorthand fragment" do + assert "app.bsky.feed.post#view" = + NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "#view") + end + + test "passes through a fully-qualified NSID unchanged" do + assert "com.example.other" = + NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "com.example.other") + end + + test "passes through a non-fragment string unchanged" do + assert "main" = NSID.expand_fragment_shorthand(~NSID"app.bsky.feed.post", "main") + end + end + + # --------------------------------------------------------------------------- + # NSID.canonical_name/1 + # --------------------------------------------------------------------------- + + describe "NSID.canonical_name/1" do + test "returns the plain NSID when fragment is nil" do + assert "app.bsky.feed.post" = NSID.canonical_name(~NSID"app.bsky.feed.post") + end + + test "returns the plain NSID when fragment is \"main\"" do + nsid = %NSID{authority: "app.bsky.feed", name: "post", fragment: "main"} + assert "app.bsky.feed.post" = NSID.canonical_name(nsid) + end + + test "returns nsid#fragment when fragment is non-main" do + assert "app.bsky.feed.post#view" = NSID.canonical_name(~NSID"app.bsky.feed.post#view") + end + end + + # --------------------------------------------------------------------------- + # NSID.authority_domain/1 + # --------------------------------------------------------------------------- + + describe "NSID.authority_domain/1" do + test "converts a standard 4-part NSID" do + assert "_lexicon.feed.bsky.app" = NSID.authority_domain(~NSID"app.bsky.feed.post") + end + + test "matches the spec example" do + assert "_lexicon.blogging.lab.dept.university.edu" = + NSID.authority_domain(~NSID"edu.university.dept.lab.blogging.getBlogPost") + end + + test "handles a minimal 3-segment NSID" do + assert "_lexicon.example.com" = NSID.authority_domain(~NSID"com.example.record") + end + + test "handles NSIDs with numbers in segments" do + assert "_lexicon.v0.comet.sh" = NSID.authority_domain(~NSID"sh.comet.v0.feed") end end end