···1313- `Atex.TID` module for manipulating ATProto TIDs.
1414- `Atex.Base32Sortable` module for encoding/decoding numbers as
1515 `base32-sortable` strings.
1616+- Basic XRPC client.
16171718## [0.1.0] - 2025-06-07
1819
+3-2
README.md
···66## Current Roadmap (in no particular order)
7788- [x] `at://` parsing and struct
99-- [ ] XRPC client
1010-- [ ] CID & TID codecs
99+- [x] TID codecs
1010+- [x] XRPC client
1111- [ ] DID & handle resolution service with a cache
1212- [ ] Structs with validation for the common lexicons
1313 - [ ] Probably codegen for doing this with other lexicons
1414+- [ ] Extended XRPC client with support for validated inputs/outputs
1415- [ ] Oauth stuff
15161617## Installation
+71
lib/xrpc.ex
···11+defmodule Atex.XRPC do
22+ alias Atex.XRPC
33+44+ defp adapter do
55+ Application.get_env(:atex, :adapter, XRPC.Adapter.Req)
66+ end
77+88+ # TODO: automatic user-agent, and env for changing it
99+1010+ # TODO: consistent struct shape/protocol for Lexicon schemas so that user can pass in
1111+ # an object (hopefully validated by its module) without needing to specify the
1212+ # name & opts separately, and possibly verify the output response against it?
1313+1414+ # TODO: auto refresh, will need to return a client instance in each method.
1515+1616+ @doc """
1717+ Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
1818+ """
1919+ @spec get(XRPC.Client.t(), String.t(), keyword()) :: XRPC.Adapter.result()
2020+ def get(%XRPC.Client{} = client, name, opts \\ []) do
2121+ opts = put_auth(opts, client.access_token)
2222+ adapter().get(url(client, name), opts)
2323+ end
2424+2525+ @doc """
2626+ Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
2727+ """
2828+ @spec post(XRPC.Client.t(), String.t(), keyword()) :: XRPC.Adapter.result()
2929+ def post(%XRPC.Client{} = client, name, opts \\ []) do
3030+ # TODO: look through available HTTP clients and see if they have a
3131+ # consistent way of providing JSON bodies with auto content-type. If not,
3232+ # create one for adapters.
3333+ opts = put_auth(opts, client.access_token)
3434+ adapter().post(url(client, name), opts)
3535+ end
3636+3737+ @doc """
3838+ Like `get/3` but is unauthenticated by default.
3939+ """
4040+ @spec unauthed_get(String.t(), String.t(), keyword()) :: XRPC.Adapter.result()
4141+ def unauthed_get(endpoint, name, opts \\ []) do
4242+ adapter().get(url(endpoint, name), opts)
4343+ end
4444+4545+ @doc """
4646+ Like `post/3` but is unauthenticated by default.
4747+ """
4848+ @spec unauthed_post(String.t(), String.t(), keyword()) :: XRPC.Adapter.result()
4949+ def unauthed_post(endpoint, name, opts \\ []) do
5050+ adapter().post(url(endpoint, name), opts)
5151+ end
5252+5353+ # TODO: use URI module for joining instead?
5454+ @spec url(XRPC.Client.t() | String.t(), String.t()) :: String.t()
5555+ defp url(%XRPC.Client{endpoint: endpoint}, name), do: url(endpoint, name)
5656+ defp url(endpoint, name) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{name}"
5757+5858+ @doc """
5959+ Put an `authorization` header into a keyword list of options to pass to a HTTP client.
6060+ """
6161+ @spec put_auth(keyword(), String.t()) :: keyword()
6262+ def put_auth(opts, token),
6363+ do: put_headers(opts, authorization: "Bearer #{token}")
6464+6565+ @spec put_headers(keyword(), keyword()) :: keyword()
6666+ defp put_headers(opts, headers) do
6767+ opts
6868+ |> Keyword.put_new(:headers, [])
6969+ |> Keyword.update(:headers, [], &Keyword.merge(&1, headers))
7070+ end
7171+end
+12
lib/xrpc/adapter.ex
···11+defmodule Atex.XRPC.Adapter do
22+ @moduledoc """
33+ Behaviour for defining a HTTP client adapter to be used for XRPC.
44+ """
55+66+ @type success() :: {:ok, map()}
77+ @type error() :: {:error, integer(), map()} | {:error, term()}
88+ @type result() :: success() | error()
99+1010+ @callback get(url :: String.t(), opts :: keyword()) :: result()
1111+ @callback post(url :: String.t(), opts :: keyword()) :: result()
1212+end
+27
lib/xrpc/adapter/req.ex
···11+defmodule Atex.XRPC.Adapter.Req do
22+ @moduledoc """
33+ `Req` adapter for XRPC.
44+ """
55+66+ @behaviour Atex.XRPC.Adapter
77+88+ def get(url, opts) do
99+ Req.get(url, opts) |> adapt()
1010+ end
1111+1212+ def post(url, opts) do
1313+ Req.post(url, opts) |> adapt()
1414+ end
1515+1616+ defp adapt({:ok, %Req.Response{status: 200} = res}) do
1717+ {:ok, res.body}
1818+ end
1919+2020+ defp adapt({:ok, %Req.Response{} = res}) do
2121+ {:error, res.status, res.body}
2222+ end
2323+2424+ defp adapt({:error, exception}) do
2525+ {:error, exception}
2626+ end
2727+end
+87
lib/xrpc/client.ex
···11+defmodule Atex.XRPC.Client do
22+ @doc """
33+ Struct to store client information for ATProto XRPC.
44+ """
55+66+ alias Atex.XRPC
77+ use TypedStruct
88+99+ typedstruct do
1010+ field :endpoint, String.t(), enforce: true
1111+ field :access_token, String.t() | nil
1212+ field :refresh_token, String.t() | nil
1313+ end
1414+1515+ @doc """
1616+ Create a new `Atex.XRPC.Client` from an endpoint, and optionally an
1717+ access/refresh token.
1818+1919+ Endpoint should be the base URL of a PDS, or an AppView in the case of
2020+ unauthenticated requests (like Bluesky's public API), e.g.
2121+ `https://bsky.social`.
2222+ """
2323+ @spec new(String.t()) :: t()
2424+ @spec new(String.t(), String.t() | nil) :: t()
2525+ @spec new(String.t(), String.t() | nil, String.t() | nil) :: t()
2626+ def new(endpoint, access_token \\ nil, refresh_token \\ nil) do
2727+ %__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token}
2828+ end
2929+3030+ @doc """
3131+ Create a new `Atex.XRPC.Client` by logging in with an `identifier` and
3232+ `password` to fetch an initial pair of access & refresh tokens.
3333+3434+ Uses `com.atproto.server.createSession` under the hood, so `identifier` can be
3535+ either a handle or a DID.
3636+3737+ ## Examples
3838+3939+ iex> Atex.XRPC.Client.login("https://bsky.social", "example.com", "password123")
4040+ {:ok, %Atex.XRPC.Client{...}}
4141+ """
4242+ @spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | XRPC.Adapter.error()
4343+ @spec login(String.t(), String.t(), String.t(), String.t() | nil) ::
4444+ {:ok, t()} | XRPC.Adapter.error()
4545+ def login(endpoint, identifier, password, auth_factor_token \\ nil) do
4646+ json =
4747+ %{identifier: identifier, password: password}
4848+ |> then(
4949+ &if auth_factor_token do
5050+ Map.merge(&1, %{authFactorToken: auth_factor_token})
5151+ else
5252+ &1
5353+ end
5454+ )
5555+5656+ response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json)
5757+5858+ case response do
5959+ {:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} ->
6060+ {:ok, new(endpoint, access_token, refresh_token)}
6161+6262+ err ->
6363+ err
6464+ end
6565+ end
6666+6767+ @doc """
6868+ Request a new `refresh_token` for the given client.
6969+ """
7070+ @spec refresh(t()) :: {:ok, t()} | XRPC.Adapter.error()
7171+ def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do
7272+ response =
7373+ XRPC.unauthed_post(
7474+ endpoint,
7575+ "com.atproto.server.refreshSession",
7676+ XRPC.put_auth([], refresh_token)
7777+ )
7878+7979+ case response do
8080+ {:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} ->
8181+ %{client | access_token: access_token, refresh_token: refresh_token}
8282+8383+ err ->
8484+ err
8585+ end
8686+ end
8787+end