···1313- Remove `Atex.HTTP` and associated modules as the abstraction caused a bit too
1414 much complexities for how early atex is. It may come back in the future as
1515 something more fleshed out once we're more stable.
1616+- Rename `Atex.XRPC.Client` to `Atex.XRPC.LoginClient`
16171718### Features
18191920- Add `Atex.OAuth` module with utilites for handling some OAuth functionality.
2021- Add `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but
2122 complete OAuth flow, including storing the tokens in `Plug.Session`.
2323+- Add `Atex.XRPC.Client` behaviour for implementing custom client variants.
2424+- `Atex.XRPC` now delegates get/post options to the provided client struct.
22252326## [0.4.0] - 2025-08-27
2427
···11defmodule Atex.XRPC do
22- alias Atex.XRPC
22+ @moduledoc """
33+ XRPC client module for AT Protocol RPC calls.
3444- # TODO: automatic user-agent, and env for changing it
55+ This module provides both authenticated and unauthenticated access to AT Protocol
66+ XRPC endpoints. The authenticated functions (`get/3`, `post/3`) work with any
77+ client that implements the `Atex.XRPC.Client`.
5866- # TODO: consistent struct shape/protocol for Lexicon schemas so that user can pass in
77- # an object (hopefully validated by its module) without needing to specify the
88- # name & opts separately, and possibly verify the output response against it?
99+ ## Example usage
1010+1111+ # Login-based client
1212+ {:ok, client} = Atex.XRPC.LoginClient.login("https://bsky.social", "user.bsky.social", "password")
1313+ {:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
1414+1515+ # OAuth-based client (coming next)
1616+ oauth_client = Atex.XRPC.OAuthClient.new_from_oauth_tokens(endpoint, access_token, refresh_token, dpop_key)
1717+ {:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
1818+1919+ ## Unauthenticated requests
2020+2121+ Unauthenticated functions (`unauthed_get/3`, `unauthed_post/3`) do not require a client
2222+ and work directly with endpoints:
9231010- # TODO: auto refresh, will need to return a client instance in each method.
2424+ {:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."])
2525+ """
11261227 @doc """
1328 Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
2929+3030+ Accepts any client that implements `Atex.XRPC.Client` and returns
3131+ both the response and the (potentially updated) client.
1432 """
1515- @spec get(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()}
1616- def get(%XRPC.Client{} = client, name, opts \\ []) do
1717- opts = put_auth(opts, client.access_token)
1818- Req.get(url(client, name), opts)
3333+ @spec get(Atex.XRPC.Client.client(), String.t(), keyword()) ::
3434+ {:ok, Req.Response.t(), Atex.XRPC.Client.client()}
3535+ | {:error, any(), Atex.XRPC.Client.client()}
3636+ def get(client, name, opts \\ []) do
3737+ client.__struct__.get(client, name, opts)
1938 end
20392140 @doc """
2241 Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
4242+4343+ Accepts any client that implements `Atex.XRPC.Client` and returns
4444+ both the response and the (potentially updated) client.
2345 """
2424- @spec post(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()}
2525- def post(%XRPC.Client{} = client, name, opts \\ []) do
2626- # TODO: look through available HTTP clients and see if they have a
2727- # consistent way of providing JSON bodies with auto content-type. If not,
2828- # create one for adapters.
2929- opts = put_auth(opts, client.access_token)
3030- Req.post(url(client, name), opts)
4646+ @spec post(Atex.XRPC.Client.client(), String.t(), keyword()) ::
4747+ {:ok, Req.Response.t(), Atex.XRPC.Client.client()}
4848+ | {:error, any(), Atex.XRPC.Client.client()}
4949+ def post(client, name, opts \\ []) do
5050+ client.__struct__.post(client, name, opts)
3151 end
32523353 @doc """
···4969 end
50705171 # TODO: use URI module for joining instead?
5252- @spec url(XRPC.Client.t() | String.t(), String.t()) :: String.t()
5353- defp url(%XRPC.Client{endpoint: endpoint}, name), do: url(endpoint, name)
5454- defp url(endpoint, name) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{name}"
5555-5672 @doc """
5757- Put an `authorization` header into a keyword list of options to pass to a HTTP client.
7373+ Create an XRPC url based on an endpoint and a resource name.
7474+7575+ ## Example
7676+7777+ iex> Atex.XRPC.url("https://bsky.app", "app.bsky.actor.getProfile")
7878+ "https://bsky.app/xrpc/app.bsky.actor.getProfile"
5879 """
5959- @spec put_auth(keyword(), String.t()) :: keyword()
6060- def put_auth(opts, token),
6161- do: put_headers(opts, authorization: "Bearer #{token}")
6262-6363- @spec put_headers(keyword(), keyword()) :: keyword()
6464- defp put_headers(opts, headers) do
6565- opts
6666- |> Keyword.put_new(:headers, [])
6767- |> Keyword.update(:headers, [], &Keyword.merge(&1, headers))
6868- end
8080+ @spec url(String.t(), String.t()) :: String.t()
8181+ def url(endpoint, resource) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{resource}"
6982end
+18-74
lib/atex/xrpc/client.ex
···11defmodule Atex.XRPC.Client do
22 @moduledoc """
33- Struct to store client information for ATProto XRPC.
44- """
33+ Behaviour that defines the interface for XRPC clients.
5466- alias Atex.{XRPC, HTTP}
77- use TypedStruct
55+ This behaviour allows different types of clients (login-based, OAuth-based, etc.)
66+ to implement authentication and request handling while maintaining a consistent interface.
8799- 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`.
88+ Implementations must handle token refresh internally when requests fail due to
99+ expired tokens, and return both the result and potentially updated client state.
2210 """
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
1111+1212+ @type client :: struct()
1313+ @type request_opts :: keyword()
1414+ @type request_result :: {:ok, Req.Response.t(), client()} | {:error, any(), client()}
29153016 @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.
1717+ Perform an authenticated HTTP GET request on an XRPC resource.
33183434- 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{...}}
1919+ Implementations should handle token refresh if the request fails due to
2020+ expired authentication, returning both the response and the (potentially updated) client.
4121 """
4242- @spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, any()}
4343- @spec login(String.t(), String.t(), String.t(), String.t() | nil) ::
4444- {:ok, t()} | {:error, any()}
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
2222+ @callback get(client(), String.t(), request_opts()) :: request_result()
66236724 @doc """
6868- Request a new `refresh_token` for the given client.
2525+ Perform an authenticated HTTP POST request on an XRPC resource.
2626+2727+ Implementations should handle token refresh if the request fails due to
2828+ expired authentication, returning both the response and the (potentially updated) client.
6929 """
7070- @spec refresh(t()) :: {:ok, t()} | {:error, any()}
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
3030+ @callback post(client(), String.t(), request_opts()) :: request_result()
8731end
+148
lib/atex/xrpc/login_client.ex
···11+defmodule Atex.XRPC.LoginClient do
22+ alias Atex.XRPC
33+ use TypedStruct
44+55+ @behaviour Atex.XRPC.Client
66+77+ typedstruct do
88+ field :endpoint, String.t(), enforce: true
99+ field :access_token, String.t() | nil
1010+ field :refresh_token, String.t() | nil
1111+ end
1212+1313+ @doc """
1414+ Create a new `Atex.XRPC.LoginClient` from an endpoint, and optionally an
1515+ existing access/refresh token.
1616+1717+ Endpoint should be the base URL of a PDS, or an AppView in the case of
1818+ unauthenticated requests (like Bluesky's public API), e.g.
1919+ `https://bsky.social`.
2020+ """
2121+ @spec new(String.t(), String.t() | nil, String.t() | nil) :: t()
2222+ def new(endpoint, access_token \\ nil, refresh_token \\ nil) do
2323+ %__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token}
2424+ end
2525+2626+ @doc """
2727+ Create a new `Atex.XRPC.LoginClient` by logging in with an `identifier` and
2828+ `password` to fetch an initial pair of access & refresh tokens.
2929+3030+ Also supports providing a MFA token in the situation that is required.
3131+3232+ Uses `com.atproto.server.createSession` under the hood, so `identifier` can be
3333+ either a handle or a DID.
3434+3535+ ## Examples
3636+3737+ iex> Atex.XRPC.LoginClient.login("https://bsky.social", "example.com", "password123")
3838+ {:ok, %Atex.XRPC.LoginClient{...}}
3939+ """
4040+ @spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, any()}
4141+ @spec login(String.t(), String.t(), String.t(), String.t() | nil) ::
4242+ {:ok, t()} | {:error, any()}
4343+ def login(endpoint, identifier, password, auth_factor_token \\ nil) do
4444+ json =
4545+ %{identifier: identifier, password: password}
4646+ |> then(
4747+ &if auth_factor_token do
4848+ Map.merge(&1, %{authFactorToken: auth_factor_token})
4949+ else
5050+ &1
5151+ end
5252+ )
5353+5454+ response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json)
5555+5656+ case response do
5757+ {:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} ->
5858+ {:ok, new(endpoint, access_token, refresh_token)}
5959+6060+ err ->
6161+ err
6262+ end
6363+ end
6464+6565+ @doc """
6666+ Request a new `refresh_token` for the given client.
6767+ """
6868+ @spec refresh(t()) :: {:ok, t()} | {:error, any()}
6969+ def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do
7070+ request =
7171+ Req.new(method: :post, url: XRPC.url(endpoint, "com.atproto.server.refreshSession"))
7272+ |> put_auth(refresh_token)
7373+7474+ case Req.request(request) do
7575+ {:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} ->
7676+ {:ok, %{client | access_token: access_token, refresh_token: refresh_token}}
7777+7878+ {:ok, response} ->
7979+ {:error, response}
8080+8181+ err ->
8282+ err
8383+ end
8484+ end
8585+8686+ @impl true
8787+ def get(%__MODULE__{} = client, resource, opts \\ []) do
8888+ request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)])
8989+ end
9090+9191+ @impl true
9292+ def post(%__MODULE__{} = client, resource, opts \\ []) do
9393+ request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)])
9494+ end
9595+9696+ @spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any()}
9797+ defp request(client, opts) do
9898+ with {:ok, client} <- validate_client(client) do
9999+ request = opts |> Req.new() |> put_auth(client.access_token)
100100+101101+ case Req.request(request) do
102102+ {:ok, %{status: 200} = response} ->
103103+ {:ok, response, client}
104104+105105+ {:ok, response} ->
106106+ handle_failure(client, response, request)
107107+108108+ err ->
109109+ err
110110+ end
111111+ end
112112+ end
113113+114114+ @spec handle_failure(t(), Req.Response.t(), Req.Request.t()) ::
115115+ {:ok, Req.Response.t(), t()} | {:error, any()}
116116+ defp handle_failure(client, response, request) do
117117+ IO.inspect(response, label: "got failure")
118118+119119+ if auth_error?(response.body) and client.refresh_token do
120120+ case refresh(client) do
121121+ {:ok, client} ->
122122+ case Req.request(put_auth(request, client.access_token)) do
123123+ {:ok, %{status: 200} = response} -> {:ok, response, client}
124124+ {:ok, response} -> {:error, response}
125125+ err -> err
126126+ end
127127+128128+ err ->
129129+ err
130130+ end
131131+ else
132132+ {:error, response}
133133+ end
134134+ end
135135+136136+ @spec validate_client(t()) :: {:ok, t()} | {:error, any()}
137137+ defp validate_client(%__MODULE__{access_token: nil}), do: {:error, :no_token}
138138+ defp validate_client(%__MODULE__{} = client), do: {:ok, client}
139139+140140+ @spec auth_error?(body :: Req.Response.t()) :: boolean()
141141+ defp auth_error?(%{status: status}) when status in [401, 403], do: true
142142+ defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true
143143+ defp auth_error?(_response), do: false
144144+145145+ @spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t()
146146+ defp put_auth(request, token),
147147+ do: Req.Request.put_header(request, "authorization", "Bearer #{token}")
148148+end