A set of utilities for working with the AT Protocol in Elixir.

feat: add basic XRPC client

ovyerus.com 3a22ac45 790aeb70

verified
+1
CHANGELOG.md
···
- `Atex.TID` module for manipulating ATProto TIDs.
- `Atex.Base32Sortable` module for encoding/decoding numbers as
`base32-sortable` strings.
+
- Basic XRPC client.
## [0.1.0] - 2025-06-07
+3 -2
README.md
···
## Current Roadmap (in no particular order)
- [x] `at://` parsing and struct
-
- [ ] XRPC client
-
- [ ] CID & TID codecs
+
- [x] TID codecs
+
- [x] XRPC client
- [ ] DID & handle resolution service with a cache
- [ ] Structs with validation for the common lexicons
- [ ] Probably codegen for doing this with other lexicons
+
- [ ] Extended XRPC client with support for validated inputs/outputs
- [ ] Oauth stuff
## Installation
+71
lib/xrpc.ex
···
+
defmodule Atex.XRPC do
+
alias Atex.XRPC
+
+
defp adapter do
+
Application.get_env(:atex, :adapter, XRPC.Adapter.Req)
+
end
+
+
# TODO: automatic user-agent, and env for changing it
+
+
# TODO: consistent struct shape/protocol for Lexicon schemas so that user can pass in
+
# an object (hopefully validated by its module) without needing to specify the
+
# name & opts separately, and possibly verify the output response against it?
+
+
# TODO: auto refresh, will need to return a client instance in each method.
+
+
@doc """
+
Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
+
"""
+
@spec get(XRPC.Client.t(), String.t(), keyword()) :: XRPC.Adapter.result()
+
def get(%XRPC.Client{} = client, name, opts \\ []) do
+
opts = put_auth(opts, client.access_token)
+
adapter().get(url(client, name), opts)
+
end
+
+
@doc """
+
Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
+
"""
+
@spec post(XRPC.Client.t(), String.t(), keyword()) :: XRPC.Adapter.result()
+
def post(%XRPC.Client{} = client, name, opts \\ []) do
+
# TODO: look through available HTTP clients and see if they have a
+
# consistent way of providing JSON bodies with auto content-type. If not,
+
# create one for adapters.
+
opts = put_auth(opts, client.access_token)
+
adapter().post(url(client, name), opts)
+
end
+
+
@doc """
+
Like `get/3` but is unauthenticated by default.
+
"""
+
@spec unauthed_get(String.t(), String.t(), keyword()) :: XRPC.Adapter.result()
+
def unauthed_get(endpoint, name, opts \\ []) do
+
adapter().get(url(endpoint, name), opts)
+
end
+
+
@doc """
+
Like `post/3` but is unauthenticated by default.
+
"""
+
@spec unauthed_post(String.t(), String.t(), keyword()) :: XRPC.Adapter.result()
+
def unauthed_post(endpoint, name, opts \\ []) do
+
adapter().post(url(endpoint, name), opts)
+
end
+
+
# TODO: use URI module for joining instead?
+
@spec url(XRPC.Client.t() | String.t(), String.t()) :: String.t()
+
defp url(%XRPC.Client{endpoint: endpoint}, name), do: url(endpoint, name)
+
defp url(endpoint, name) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{name}"
+
+
@doc """
+
Put an `authorization` header into a keyword list of options to pass to a HTTP client.
+
"""
+
@spec put_auth(keyword(), String.t()) :: keyword()
+
def put_auth(opts, token),
+
do: put_headers(opts, authorization: "Bearer #{token}")
+
+
@spec put_headers(keyword(), keyword()) :: keyword()
+
defp put_headers(opts, headers) do
+
opts
+
|> Keyword.put_new(:headers, [])
+
|> Keyword.update(:headers, [], &Keyword.merge(&1, headers))
+
end
+
end
+12
lib/xrpc/adapter.ex
···
+
defmodule Atex.XRPC.Adapter do
+
@moduledoc """
+
Behaviour for defining a HTTP client adapter to be used for XRPC.
+
"""
+
+
@type success() :: {:ok, map()}
+
@type error() :: {:error, integer(), map()} | {:error, term()}
+
@type result() :: success() | error()
+
+
@callback get(url :: String.t(), opts :: keyword()) :: result()
+
@callback post(url :: String.t(), opts :: keyword()) :: result()
+
end
+27
lib/xrpc/adapter/req.ex
···
+
defmodule Atex.XRPC.Adapter.Req do
+
@moduledoc """
+
`Req` adapter for XRPC.
+
"""
+
+
@behaviour Atex.XRPC.Adapter
+
+
def get(url, opts) do
+
Req.get(url, opts) |> adapt()
+
end
+
+
def post(url, opts) do
+
Req.post(url, opts) |> adapt()
+
end
+
+
defp adapt({:ok, %Req.Response{status: 200} = res}) do
+
{:ok, res.body}
+
end
+
+
defp adapt({:ok, %Req.Response{} = res}) do
+
{:error, res.status, res.body}
+
end
+
+
defp adapt({:error, exception}) do
+
{:error, exception}
+
end
+
end
+87
lib/xrpc/client.ex
···
+
defmodule Atex.XRPC.Client do
+
@doc """
+
Struct to store client information for ATProto XRPC.
+
"""
+
+
alias Atex.XRPC
+
use TypedStruct
+
+
typedstruct do
+
field :endpoint, String.t(), enforce: true
+
field :access_token, String.t() | nil
+
field :refresh_token, String.t() | nil
+
end
+
+
@doc """
+
Create a new `Atex.XRPC.Client` from an endpoint, and optionally an
+
access/refresh token.
+
+
Endpoint should be the base URL of a PDS, or an AppView in the case of
+
unauthenticated requests (like Bluesky's public API), e.g.
+
`https://bsky.social`.
+
"""
+
@spec new(String.t()) :: t()
+
@spec new(String.t(), String.t() | nil) :: t()
+
@spec new(String.t(), String.t() | nil, String.t() | nil) :: t()
+
def new(endpoint, access_token \\ nil, refresh_token \\ nil) do
+
%__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token}
+
end
+
+
@doc """
+
Create a new `Atex.XRPC.Client` by logging in with an `identifier` and
+
`password` to fetch an initial pair of access & refresh tokens.
+
+
Uses `com.atproto.server.createSession` under the hood, so `identifier` can be
+
either a handle or a DID.
+
+
## Examples
+
+
iex> Atex.XRPC.Client.login("https://bsky.social", "example.com", "password123")
+
{:ok, %Atex.XRPC.Client{...}}
+
"""
+
@spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | XRPC.Adapter.error()
+
@spec login(String.t(), String.t(), String.t(), String.t() | nil) ::
+
{:ok, t()} | XRPC.Adapter.error()
+
def login(endpoint, identifier, password, auth_factor_token \\ nil) do
+
json =
+
%{identifier: identifier, password: password}
+
|> then(
+
&if auth_factor_token do
+
Map.merge(&1, %{authFactorToken: auth_factor_token})
+
else
+
&1
+
end
+
)
+
+
response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json)
+
+
case response do
+
{:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} ->
+
{:ok, new(endpoint, access_token, refresh_token)}
+
+
err ->
+
err
+
end
+
end
+
+
@doc """
+
Request a new `refresh_token` for the given client.
+
"""
+
@spec refresh(t()) :: {:ok, t()} | XRPC.Adapter.error()
+
def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do
+
response =
+
XRPC.unauthed_post(
+
endpoint,
+
"com.atproto.server.refreshSession",
+
XRPC.put_auth([], refresh_token)
+
)
+
+
case response do
+
{:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} ->
+
%{client | access_token: access_token, refresh_token: refresh_token}
+
+
err ->
+
err
+
end
+
end
+
end
+2
mix.exs
···
defp deps do
[
+
{:multiformats_ex, "~> 0.2"},
+
{:req, "~> 0.5"},
{:typedstruct, "~> 0.5"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}
+10
mix.lock
···
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"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"},
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
+
"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"},
+
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"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"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
+
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
+
"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"},
+
"multiformats_ex": {:hex, :multiformats_ex, "0.2.0", "5b0a3faa1a770dc671aa8a89b6323cc20b0ecf67dc93dcd21312151fbea6b4ee", [:mix], [{:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "aa406d9addb06dc197e0e92212992486af6599158d357680f29f2d11e08d0423"},
+
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
+
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
+
"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"},
+
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"},
+
"varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"},
}