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

feat: add basic XRPC client

ovyerus.com 3a22ac45 790aeb70

verified
+213 -2
+1
CHANGELOG.md
··· 13 13 - `Atex.TID` module for manipulating ATProto TIDs. 14 14 - `Atex.Base32Sortable` module for encoding/decoding numbers as 15 15 `base32-sortable` strings. 16 + - Basic XRPC client. 16 17 17 18 ## [0.1.0] - 2025-06-07 18 19
+3 -2
README.md
··· 6 6 ## Current Roadmap (in no particular order) 7 7 8 8 - [x] `at://` parsing and struct 9 - - [ ] XRPC client 10 - - [ ] CID & TID codecs 9 + - [x] TID codecs 10 + - [x] XRPC client 11 11 - [ ] DID & handle resolution service with a cache 12 12 - [ ] Structs with validation for the common lexicons 13 13 - [ ] Probably codegen for doing this with other lexicons 14 + - [ ] Extended XRPC client with support for validated inputs/outputs 14 15 - [ ] Oauth stuff 15 16 16 17 ## Installation
+71
lib/xrpc.ex
··· 1 + defmodule Atex.XRPC do 2 + alias Atex.XRPC 3 + 4 + defp adapter do 5 + Application.get_env(:atex, :adapter, XRPC.Adapter.Req) 6 + end 7 + 8 + # TODO: automatic user-agent, and env for changing it 9 + 10 + # TODO: consistent struct shape/protocol for Lexicon schemas so that user can pass in 11 + # an object (hopefully validated by its module) without needing to specify the 12 + # name & opts separately, and possibly verify the output response against it? 13 + 14 + # TODO: auto refresh, will need to return a client instance in each method. 15 + 16 + @doc """ 17 + Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons. 18 + """ 19 + @spec get(XRPC.Client.t(), String.t(), keyword()) :: XRPC.Adapter.result() 20 + def get(%XRPC.Client{} = client, name, opts \\ []) do 21 + opts = put_auth(opts, client.access_token) 22 + adapter().get(url(client, name), opts) 23 + end 24 + 25 + @doc """ 26 + Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons. 27 + """ 28 + @spec post(XRPC.Client.t(), String.t(), keyword()) :: XRPC.Adapter.result() 29 + def post(%XRPC.Client{} = client, name, opts \\ []) do 30 + # TODO: look through available HTTP clients and see if they have a 31 + # consistent way of providing JSON bodies with auto content-type. If not, 32 + # create one for adapters. 33 + opts = put_auth(opts, client.access_token) 34 + adapter().post(url(client, name), opts) 35 + end 36 + 37 + @doc """ 38 + Like `get/3` but is unauthenticated by default. 39 + """ 40 + @spec unauthed_get(String.t(), String.t(), keyword()) :: XRPC.Adapter.result() 41 + def unauthed_get(endpoint, name, opts \\ []) do 42 + adapter().get(url(endpoint, name), opts) 43 + end 44 + 45 + @doc """ 46 + Like `post/3` but is unauthenticated by default. 47 + """ 48 + @spec unauthed_post(String.t(), String.t(), keyword()) :: XRPC.Adapter.result() 49 + def unauthed_post(endpoint, name, opts \\ []) do 50 + adapter().post(url(endpoint, name), opts) 51 + end 52 + 53 + # TODO: use URI module for joining instead? 54 + @spec url(XRPC.Client.t() | String.t(), String.t()) :: String.t() 55 + defp url(%XRPC.Client{endpoint: endpoint}, name), do: url(endpoint, name) 56 + defp url(endpoint, name) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{name}" 57 + 58 + @doc """ 59 + Put an `authorization` header into a keyword list of options to pass to a HTTP client. 60 + """ 61 + @spec put_auth(keyword(), String.t()) :: keyword() 62 + def put_auth(opts, token), 63 + do: put_headers(opts, authorization: "Bearer #{token}") 64 + 65 + @spec put_headers(keyword(), keyword()) :: keyword() 66 + defp put_headers(opts, headers) do 67 + opts 68 + |> Keyword.put_new(:headers, []) 69 + |> Keyword.update(:headers, [], &Keyword.merge(&1, headers)) 70 + end 71 + end
+12
lib/xrpc/adapter.ex
··· 1 + defmodule Atex.XRPC.Adapter do 2 + @moduledoc """ 3 + Behaviour for defining a HTTP client adapter to be used for XRPC. 4 + """ 5 + 6 + @type success() :: {:ok, map()} 7 + @type error() :: {:error, integer(), map()} | {:error, term()} 8 + @type result() :: success() | error() 9 + 10 + @callback get(url :: String.t(), opts :: keyword()) :: result() 11 + @callback post(url :: String.t(), opts :: keyword()) :: result() 12 + end
+27
lib/xrpc/adapter/req.ex
··· 1 + defmodule Atex.XRPC.Adapter.Req do 2 + @moduledoc """ 3 + `Req` adapter for XRPC. 4 + """ 5 + 6 + @behaviour Atex.XRPC.Adapter 7 + 8 + def get(url, opts) do 9 + Req.get(url, opts) |> adapt() 10 + end 11 + 12 + def post(url, opts) do 13 + Req.post(url, opts) |> adapt() 14 + end 15 + 16 + defp adapt({:ok, %Req.Response{status: 200} = res}) do 17 + {:ok, res.body} 18 + end 19 + 20 + defp adapt({:ok, %Req.Response{} = res}) do 21 + {:error, res.status, res.body} 22 + end 23 + 24 + defp adapt({:error, exception}) do 25 + {:error, exception} 26 + end 27 + end
+87
lib/xrpc/client.ex
··· 1 + defmodule Atex.XRPC.Client do 2 + @doc """ 3 + Struct to store client information for ATProto XRPC. 4 + """ 5 + 6 + alias Atex.XRPC 7 + use TypedStruct 8 + 9 + typedstruct do 10 + field :endpoint, String.t(), enforce: true 11 + field :access_token, String.t() | nil 12 + field :refresh_token, String.t() | nil 13 + end 14 + 15 + @doc """ 16 + Create a new `Atex.XRPC.Client` from an endpoint, and optionally an 17 + access/refresh token. 18 + 19 + Endpoint should be the base URL of a PDS, or an AppView in the case of 20 + unauthenticated requests (like Bluesky's public API), e.g. 21 + `https://bsky.social`. 22 + """ 23 + @spec new(String.t()) :: t() 24 + @spec new(String.t(), String.t() | nil) :: t() 25 + @spec new(String.t(), String.t() | nil, String.t() | nil) :: t() 26 + def new(endpoint, access_token \\ nil, refresh_token \\ nil) do 27 + %__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token} 28 + end 29 + 30 + @doc """ 31 + Create a new `Atex.XRPC.Client` by logging in with an `identifier` and 32 + `password` to fetch an initial pair of access & refresh tokens. 33 + 34 + Uses `com.atproto.server.createSession` under the hood, so `identifier` can be 35 + either a handle or a DID. 36 + 37 + ## Examples 38 + 39 + iex> Atex.XRPC.Client.login("https://bsky.social", "example.com", "password123") 40 + {:ok, %Atex.XRPC.Client{...}} 41 + """ 42 + @spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | XRPC.Adapter.error() 43 + @spec login(String.t(), String.t(), String.t(), String.t() | nil) :: 44 + {:ok, t()} | XRPC.Adapter.error() 45 + def login(endpoint, identifier, password, auth_factor_token \\ nil) do 46 + json = 47 + %{identifier: identifier, password: password} 48 + |> then( 49 + &if auth_factor_token do 50 + Map.merge(&1, %{authFactorToken: auth_factor_token}) 51 + else 52 + &1 53 + end 54 + ) 55 + 56 + response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json) 57 + 58 + case response do 59 + {:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} -> 60 + {:ok, new(endpoint, access_token, refresh_token)} 61 + 62 + err -> 63 + err 64 + end 65 + end 66 + 67 + @doc """ 68 + Request a new `refresh_token` for the given client. 69 + """ 70 + @spec refresh(t()) :: {:ok, t()} | XRPC.Adapter.error() 71 + def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do 72 + response = 73 + XRPC.unauthed_post( 74 + endpoint, 75 + "com.atproto.server.refreshSession", 76 + XRPC.put_auth([], refresh_token) 77 + ) 78 + 79 + case response do 80 + {:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} -> 81 + %{client | access_token: access_token, refresh_token: refresh_token} 82 + 83 + err -> 84 + err 85 + end 86 + end 87 + end
+2
mix.exs
··· 26 26 27 27 defp deps do 28 28 [ 29 + {:multiformats_ex, "~> 0.2"}, 30 + {:req, "~> 0.5"}, 29 31 {:typedstruct, "~> 0.5"}, 30 32 {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 31 33 {:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}
+10
mix.lock
··· 4 4 "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 5 5 "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 6 6 "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 7 + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 8 + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 7 9 "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 8 10 "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 9 11 "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 10 12 "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 13 + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 14 + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 15 + "multiformats_ex": {:hex, :multiformats_ex, "0.2.0", "5b0a3faa1a770dc671aa8a89b6323cc20b0ecf67dc93dcd21312151fbea6b4ee", [:mix], [{:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "aa406d9addb06dc197e0e92212992486af6599158d357680f29f2d11e08d0423"}, 16 + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 11 17 "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 18 + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 19 + "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, 20 + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 12 21 "typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"}, 22 + "varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"}, 13 23 }