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.

feat: lexicon resolution module & mix task

+487 -3
+7
CHANGELOG.md
··· 12 12 13 13 - `Atex.XRPC.UnauthedClient` module for running unauthenticated XRPC fetches on 14 14 public APIs or PDSes. 15 + - `Atex.NSID.authority_domain/1` for deriving the `_lexicon.<authority>` DNS 16 + name from an NSID. 17 + - `Atex.Lexicon.Resolver` module for resolving published lexicons by NSID, 18 + following the 19 + [publication and resolution spec](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution). 20 + - `mix atex.lexicons.resolve` task for resolving one or more lexicons by NSID 21 + and writing to a JSON file. 15 22 16 23 ## [0.8.0] - 2026-03-29 17 24
+137
lib/atex/lexicon/resolver.ex
··· 1 + defmodule Atex.Lexicon.Resolver do 2 + @moduledoc """ 3 + Resolves published AT Protocol Lexicon schemas by NSID. 4 + 5 + Implements the [Lexicon publication and resolution](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution) 6 + specification, which uses DNS TXT records to locate the atproto repository 7 + that hosts a given NSID's schema, then fetches the record directly from 8 + the repository's PDS. 9 + 10 + Per the specification, DNS resolution is not hierarchical: if the TXT 11 + record for the exact authority domain is not found, resolution fails 12 + immediately without traversing the DNS hierarchy. 13 + 14 + ## Error returns 15 + 16 + `resolve/1` returns `{:error, reason}` on failure. Possible reasons: 17 + 18 + - `:invalid_nsid` - the given string is not a valid NSID. 19 + - `:dns_resolution_failed` - no valid `did=<did>` TXT record was found for 20 + the authority domain. 21 + - `:did_resolution_failed` - the DID from DNS could not be resolved to a 22 + DID document. 23 + - `:no_pds_endpoint` - the DID document does not contain a valid PDS service 24 + endpoint. 25 + - `:record_not_found` - the PDS has no lexicon record for the given NSID. 26 + - `:invalid_record` - the PDS returned a response that could not be 27 + interpreted as a lexicon record. 28 + - Any transport-level error from `Req`. 29 + """ 30 + 31 + alias Atex.{DID, NSID, Util, XRPC} 32 + alias Atex.IdentityResolver.DID, as: DIDResolver 33 + 34 + @collection "com.atproto.lexicon.schema" 35 + 36 + @doc """ 37 + Resolves the lexicon schema for the given NSID. 38 + 39 + Performs DNS-based authority lookup followed by an atproto record fetch to 40 + retrieve the raw lexicon JSON map. 41 + 42 + ## Parameters 43 + 44 + - `nsid` - A valid AT Protocol NSID string. 45 + 46 + ## Examples 47 + 48 + iex> Atex.Lexicon.Resolver.resolve("app.bsky.feed.post") 49 + {:ok, %{"lexicon" => 1, "id" => "app.bsky.feed.post", "defs" => %{...}}} 50 + 51 + iex> Atex.Lexicon.Resolver.resolve("not.valid!") 52 + {:error, :invalid_nsid} 53 + """ 54 + @spec resolve(String.t()) :: 55 + {:ok, map()} 56 + | {:error, 57 + :invalid_nsid 58 + | :dns_resolution_failed 59 + | :did_resolution_failed 60 + | :no_pds_endpoint 61 + | :record_not_found 62 + | :invalid_record 63 + | any()} 64 + def resolve(nsid) do 65 + with {:ok, authority_domain} <- NSID.authority_domain(nsid), 66 + {:ok, did} <- resolve_did_from_dns(authority_domain), 67 + {:ok, document} <- resolve_did_document(did), 68 + {:ok, pds_endpoint} <- get_pds_endpoint(document) do 69 + fetch_record(pds_endpoint, did, nsid) 70 + end 71 + end 72 + 73 + @spec resolve_did_from_dns(String.t()) :: {:ok, String.t()} | {:error, :dns_resolution_failed} 74 + defp resolve_did_from_dns(authority_domain) do 75 + authority_domain 76 + |> Util.query_dns(:txt) 77 + |> Enum.find_value(fn 78 + "did=" <> did -> if DID.match?(did), do: did 79 + _ -> nil 80 + end) 81 + |> case do 82 + nil -> {:error, :dns_resolution_failed} 83 + did -> {:ok, did} 84 + end 85 + end 86 + 87 + @spec resolve_did_document(String.t()) :: 88 + {:ok, DID.Document.t()} | {:error, :did_resolution_failed} 89 + defp resolve_did_document(did) do 90 + case DIDResolver.resolve(did) do 91 + {:ok, document} -> {:ok, document} 92 + _ -> {:error, :did_resolution_failed} 93 + end 94 + end 95 + 96 + @spec get_pds_endpoint(DID.Document.t()) :: 97 + {:ok, String.t()} | {:error, :no_pds_endpoint} 98 + defp get_pds_endpoint(document) do 99 + case DID.Document.get_pds_endpoint(document) do 100 + nil -> {:error, :no_pds_endpoint} 101 + endpoint -> {:ok, endpoint} 102 + end 103 + end 104 + 105 + @spec fetch_record(String.t(), String.t(), String.t()) :: 106 + {:ok, map()} | {:error, :record_not_found | :invalid_record | any()} 107 + defp fetch_record(pds_endpoint, did, nsid) do 108 + client = XRPC.UnauthedClient.new(pds_endpoint) 109 + 110 + case XRPC.get(client, "com.atproto.repo.getRecord", 111 + params: [repo: did, collection: @collection, rkey: nsid] 112 + ) do 113 + {:ok, %{status: 200, body: body}, _} -> parse_record_body(body) 114 + {:ok, %{status: 404}} -> {:error, :record_not_found} 115 + {:ok, %{status: status, body: body}} -> {:error, %{status: status, body: body}} 116 + {:error, reason} -> {:error, reason} 117 + end 118 + end 119 + 120 + @spec parse_record_body(map() | binary()) :: 121 + {:ok, map()} | {:error, :invalid_record} 122 + defp parse_record_body(body) when is_map(body) do 123 + case Map.fetch(body, "value") do 124 + {:ok, value} when is_map(value) -> {:ok, value} 125 + _ -> {:error, :invalid_record} 126 + end 127 + end 128 + 129 + defp parse_record_body(body) when is_binary(body) do 130 + case JSON.decode(body) do 131 + {:ok, map} -> parse_record_body(map) 132 + _ -> {:error, :invalid_record} 133 + end 134 + end 135 + 136 + defp parse_record_body(_), do: {:error, :invalid_record} 137 + end
+36 -3
lib/atex/nsid.ex
··· 8 8 @spec match?(String.t()) :: boolean() 9 9 def match?(value), do: Regex.match?(@re, value) 10 10 11 - # TODO: methods for fetching the authority and name from a nsid. 12 - # maybe stuff for fetching the repo that belongs to an authority 13 - 14 11 @spec to_atom(String.t()) :: atom() 15 12 def to_atom(nsid, fully_qualify \\ true) do 16 13 nsid ··· 52 49 nsid 53 50 else 54 51 "#{nsid}##{fragment}" 52 + end 53 + end 54 + 55 + @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.`. 61 + 62 + Returns `{:error, :invalid_nsid}` if the input is not a valid NSID. 63 + 64 + ## Examples 65 + 66 + iex> Atex.NSID.authority_domain("app.bsky.feed.post") 67 + {:ok, "_lexicon.feed.bsky.app"} 68 + 69 + iex> Atex.NSID.authority_domain("edu.university.dept.lab.blogging.getBlogPost") 70 + {:ok, "_lexicon.blogging.lab.dept.university.edu"} 71 + 72 + iex> Atex.NSID.authority_domain("invalid") 73 + {:error, :invalid_nsid} 74 + """ 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(".") 84 + 85 + {:ok, "_lexicon.#{authority}"} 86 + else 87 + {:error, :invalid_nsid} 55 88 end 56 89 end 57 90 end
+87
lib/mix/tasks/atex.lexicons.resolve.ex
··· 1 + defmodule Mix.Tasks.Atex.Lexicons.Resolve do 2 + @moduledoc """ 3 + Resolve published AT Protocol lexicons by NSID and write them to JSON files. 4 + 5 + Lexicon schemas are published as records in atproto repositories. This task 6 + resolves one or more NSIDs using DNS-based authority lookup (via 7 + `_lexicon.<authority>` TXT records) and fetches the schema directly from the 8 + authoritative PDS, then writes each schema to disk as a JSON file. 9 + 10 + See `Atex.Lexicon.Resolver` for programmatic usage. 11 + 12 + ## Usage 13 + 14 + mix atex.lexicons.resolve [OPTIONS] <NSID> [<NSID> ...] 15 + 16 + ## Arguments 17 + 18 + - `NSID` - One or more AT Protocol NSIDs to resolve. 19 + 20 + ## Options 21 + 22 + - `-o`/`--output` - Output directory for resolved lexicon JSON files 23 + (default: `lexicons`). 24 + 25 + ## Examples 26 + 27 + Resolve a single lexicon: 28 + 29 + mix atex.lexicons.resolve app.bsky.feed.post 30 + 31 + Resolve multiple lexicons into a custom directory: 32 + 33 + mix atex.lexicons.resolve --output priv/lexicons app.bsky.feed.post com.atproto.repo.createRecord 34 + """ 35 + @shortdoc "Resolve published AT Protocol lexicons by NSID." 36 + 37 + use Mix.Task 38 + 39 + alias Atex.Lexicon.Resolver 40 + 41 + @switches [output: :string] 42 + @aliases [o: :output] 43 + 44 + @impl true 45 + def run(args) do 46 + Mix.Task.run("app.start") 47 + 48 + {options, nsids} = OptionParser.parse!(args, switches: @switches, aliases: @aliases) 49 + 50 + output = Keyword.get(options, :output, "lexicons") 51 + 52 + if nsids == [] do 53 + Mix.shell().error("No NSIDs provided. Usage: mix atex.lexicons.resolve <NSID> [<NSID> ...]") 54 + else 55 + Mix.shell().info("Resolving #{length(nsids)} lexicon(s) into #{output}/") 56 + 57 + Enum.each(nsids, fn nsid -> 58 + Mix.shell().info("- Resolving #{nsid}...") 59 + 60 + case Resolver.resolve(nsid) do 61 + {:ok, lexicon} -> 62 + path = nsid_to_path(nsid, output) 63 + 64 + path 65 + |> Path.dirname() 66 + |> File.mkdir_p!() 67 + 68 + File.write!(path, Jason.encode!(lexicon, pretty: true)) 69 + Mix.shell().info(" Written to #{path}") 70 + 71 + {:error, reason} -> 72 + Mix.shell().error(" Failed to resolve #{nsid}: #{inspect(reason)}") 73 + end 74 + end) 75 + end 76 + end 77 + 78 + @doc false 79 + @spec nsid_to_path(String.t(), String.t()) :: String.t() 80 + def nsid_to_path(nsid, output) do 81 + nsid 82 + |> String.split(".") 83 + |> Enum.join("/") 84 + |> then(&(&1 <> ".json")) 85 + |> then(&Path.join(output, &1)) 86 + end 87 + end
+220
test/atex/lexicon/resolver_test.exs
··· 1 + defmodule Atex.Lexicon.ResolverTest do 2 + use ExUnit.Case, async: true 3 + 4 + import Mox 5 + 6 + alias Atex.Lexicon.Resolver 7 + alias Atex.Lexicon.Resolver.{MockDIDClient, MockDNSClient} 8 + alias Atex.NSID 9 + 10 + setup :verify_on_exit! 11 + 12 + # --------------------------------------------------------------------------- 13 + # NSID.authority_domain/1 14 + # --------------------------------------------------------------------------- 15 + 16 + describe "NSID.authority_domain/1" do 17 + test "converts a standard 4-part NSID" do 18 + assert {:ok, "_lexicon.feed.bsky.app"} = NSID.authority_domain("app.bsky.feed.post") 19 + end 20 + 21 + test "matches the spec example" do 22 + assert {:ok, "_lexicon.blogging.lab.dept.university.edu"} = 23 + NSID.authority_domain("edu.university.dept.lab.blogging.getBlogPost") 24 + end 25 + 26 + test "handles a minimal 3-segment NSID" do 27 + assert {:ok, "_lexicon.example.com"} = NSID.authority_domain("com.example.record") 28 + end 29 + 30 + test "handles NSIDs with numbers in segments" do 31 + assert {:ok, "_lexicon.v0.comet.sh"} = NSID.authority_domain("sh.comet.v0.feed") 32 + end 33 + 34 + test "returns error for a plain string without dots" do 35 + assert {:error, :invalid_nsid} = NSID.authority_domain("invalid") 36 + end 37 + 38 + test "returns error for an empty string" do 39 + assert {:error, :invalid_nsid} = NSID.authority_domain("") 40 + end 41 + 42 + test "returns error for a string with invalid characters" do 43 + assert {:error, :invalid_nsid} = NSID.authority_domain("not.valid!") 44 + end 45 + end 46 + 47 + # --------------------------------------------------------------------------- 48 + # Resolver.resolve/1 — invalid NSID (no network calls) 49 + # --------------------------------------------------------------------------- 50 + 51 + describe "resolve/1 with invalid NSID" do 52 + test "returns :invalid_nsid for a plain string" do 53 + assert {:error, :invalid_nsid} = Resolver.resolve("notannsid") 54 + end 55 + 56 + test "returns :invalid_nsid for an empty string" do 57 + assert {:error, :invalid_nsid} = Resolver.resolve("") 58 + end 59 + 60 + test "returns :invalid_nsid for a string with illegal characters" do 61 + assert {:error, :invalid_nsid} = Resolver.resolve("not.valid!") 62 + end 63 + end 64 + 65 + # --------------------------------------------------------------------------- 66 + # Resolver.resolve/1 — DNS failure 67 + # --------------------------------------------------------------------------- 68 + 69 + describe "resolve/1 with DNS failure" do 70 + test "returns :dns_resolution_failed when DNS returns no TXT records" do 71 + expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" -> [] end) 72 + 73 + assert {:error, :dns_resolution_failed} = Resolver.resolve("app.bsky.feed.post") 74 + end 75 + 76 + test "returns :dns_resolution_failed when TXT record has wrong prefix" do 77 + expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" -> 78 + [[~c"notadid=something"]] 79 + end) 80 + 81 + assert {:error, :dns_resolution_failed} = Resolver.resolve("app.bsky.feed.post") 82 + end 83 + 84 + test "returns :dns_resolution_failed when TXT value is not a valid DID" do 85 + expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" -> 86 + [[~c"did=notadidatall"]] 87 + end) 88 + 89 + assert {:error, :dns_resolution_failed} = Resolver.resolve("app.bsky.feed.post") 90 + end 91 + end 92 + 93 + # --------------------------------------------------------------------------- 94 + # Resolver.resolve/1 — DID resolution failure 95 + # --------------------------------------------------------------------------- 96 + 97 + describe "resolve/1 with DID resolution failure" do 98 + test "returns :did_resolution_failed when DID resolver errors" do 99 + expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" -> 100 + [[~c"did=did:plc:ewvi7nxzyoun6zhxrhs64oiz"]] 101 + end) 102 + 103 + expect(MockDIDClient, :resolve, fn "did:plc:ewvi7nxzyoun6zhxrhs64oiz" -> 104 + {:error, :not_found} 105 + end) 106 + 107 + assert {:error, :did_resolution_failed} = Resolver.resolve("app.bsky.feed.post") 108 + end 109 + end 110 + 111 + # --------------------------------------------------------------------------- 112 + # Resolver.resolve/1 — no PDS endpoint 113 + # --------------------------------------------------------------------------- 114 + 115 + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 116 + @pds "https://pds.example.com" 117 + @nsid "app.bsky.feed.post" 118 + 119 + @did_doc_no_pds %Atex.DID.Document{ 120 + "@context": ["https://www.w3.org/ns/did/v1"], 121 + id: @did 122 + } 123 + 124 + @did_doc_with_pds %Atex.DID.Document{ 125 + "@context": ["https://www.w3.org/ns/did/v1"], 126 + id: @did, 127 + service: [ 128 + %Atex.DID.Document.Service{ 129 + id: "#{@did}#atproto_pds", 130 + type: "AtprotoPersonalDataServer", 131 + service_endpoint: @pds 132 + } 133 + ] 134 + } 135 + 136 + defp stub_dns_and_did(did_doc) do 137 + expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" -> 138 + [[~c"did=#{@did}"]] 139 + end) 140 + 141 + expect(MockDIDClient, :resolve, fn @did -> {:ok, did_doc} end) 142 + end 143 + 144 + describe "resolve/1 with no PDS endpoint" do 145 + test "returns :no_pds_endpoint when DID document has no PDS service" do 146 + stub_dns_and_did(@did_doc_no_pds) 147 + 148 + assert {:error, :no_pds_endpoint} = Resolver.resolve(@nsid) 149 + end 150 + end 151 + 152 + # --------------------------------------------------------------------------- 153 + # Resolver.resolve/1 — record fetch (Req.Test plug) 154 + # --------------------------------------------------------------------------- 155 + 156 + @lexicon_value %{ 157 + "lexicon" => 1, 158 + "id" => @nsid, 159 + "defs" => %{ 160 + "main" => %{ 161 + "type" => "record", 162 + "key" => "tid", 163 + "record" => %{"type" => "object", "properties" => %{}} 164 + } 165 + } 166 + } 167 + 168 + defp stub_through_to_pds do 169 + expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" -> 170 + [[~c"did=#{@did}"]] 171 + end) 172 + 173 + expect(MockDIDClient, :resolve, fn @did -> {:ok, @did_doc_with_pds} end) 174 + end 175 + 176 + defp record_plug(record_resp) do 177 + fn conn -> 178 + case record_resp do 179 + :not_found -> 180 + Plug.Conn.send_resp(conn, 404, ~s({"error":"RecordNotFound"})) 181 + 182 + :missing_value -> 183 + Req.Test.json(conn, %{"uri" => "at://#{@did}/com.atproto.lexicon.schema/#{@nsid}"}) 184 + 185 + value -> 186 + Req.Test.json(conn, %{ 187 + "uri" => "at://#{@did}/com.atproto.lexicon.schema/#{@nsid}", 188 + "value" => value 189 + }) 190 + end 191 + end 192 + end 193 + 194 + describe "resolve/1 — record fetch (Req.Test plug)" do 195 + test "returns the lexicon value map on success" do 196 + stub_through_to_pds() 197 + 198 + result = Resolver.resolve(@nsid, plug: record_plug(@lexicon_value)) 199 + 200 + assert {:ok, lexicon} = result 201 + assert lexicon["id"] == @nsid 202 + assert lexicon["lexicon"] == 1 203 + assert is_map(lexicon["defs"]) 204 + end 205 + 206 + test "returns :record_not_found when PDS returns 404" do 207 + stub_through_to_pds() 208 + 209 + assert {:error, :record_not_found} = 210 + Resolver.resolve(@nsid, plug: record_plug(:not_found)) 211 + end 212 + 213 + test "returns :invalid_record when PDS response has no value key" do 214 + stub_through_to_pds() 215 + 216 + assert {:error, :invalid_record} = 217 + Resolver.resolve(@nsid, plug: record_plug(:missing_value)) 218 + end 219 + end 220 + end