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.

refactor: add support for multiple types of XRPC clients

+216 -108
+3
CHANGELOG.md
··· 13 13 - Remove `Atex.HTTP` and associated modules as the abstraction caused a bit too 14 14 much complexities for how early atex is. It may come back in the future as 15 15 something more fleshed out once we're more stable. 16 + - Rename `Atex.XRPC.Client` to `Atex.XRPC.LoginClient` 16 17 17 18 ### Features 18 19 19 20 - Add `Atex.OAuth` module with utilites for handling some OAuth functionality. 20 21 - Add `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but 21 22 complete OAuth flow, including storing the tokens in `Plug.Session`. 23 + - Add `Atex.XRPC.Client` behaviour for implementing custom client variants. 24 + - `Atex.XRPC` now delegates get/post options to the provided client struct. 22 25 23 26 ## [0.4.0] - 2025-08-27 24 27
+2 -2
lib/atex/oauth.ex
··· 381 381 end 382 382 383 383 @spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t() 384 - defp create_client_assertion(jwk, client_id, issuer) do 384 + def create_client_assertion(jwk, client_id, issuer) do 385 385 iat = System.os_time(:second) 386 386 jti = random_b64(20) 387 387 jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]} ··· 401 401 end 402 402 403 403 @spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t() 404 - defp create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do 404 + def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do 405 405 iat = System.os_time(:second) 406 406 jti = random_b64(20) 407 407 {_, public_jwk} = JOSE.JWK.to_public_map(jwk)
+45 -32
lib/atex/xrpc.ex
··· 1 1 defmodule Atex.XRPC do 2 - alias Atex.XRPC 2 + @moduledoc """ 3 + XRPC client module for AT Protocol RPC calls. 3 4 4 - # TODO: automatic user-agent, and env for changing it 5 + This module provides both authenticated and unauthenticated access to AT Protocol 6 + XRPC endpoints. The authenticated functions (`get/3`, `post/3`) work with any 7 + client that implements the `Atex.XRPC.Client`. 5 8 6 - # TODO: consistent struct shape/protocol for Lexicon schemas so that user can pass in 7 - # an object (hopefully validated by its module) without needing to specify the 8 - # name & opts separately, and possibly verify the output response against it? 9 + ## Example usage 10 + 11 + # Login-based client 12 + {:ok, client} = Atex.XRPC.LoginClient.login("https://bsky.social", "user.bsky.social", "password") 13 + {:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"]) 14 + 15 + # OAuth-based client (coming next) 16 + oauth_client = Atex.XRPC.OAuthClient.new_from_oauth_tokens(endpoint, access_token, refresh_token, dpop_key) 17 + {:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"]) 18 + 19 + ## Unauthenticated requests 20 + 21 + Unauthenticated functions (`unauthed_get/3`, `unauthed_post/3`) do not require a client 22 + and work directly with endpoints: 9 23 10 - # TODO: auto refresh, will need to return a client instance in each method. 24 + {:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."]) 25 + """ 11 26 12 27 @doc """ 13 28 Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons. 29 + 30 + Accepts any client that implements `Atex.XRPC.Client` and returns 31 + both the response and the (potentially updated) client. 14 32 """ 15 - @spec get(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()} 16 - def get(%XRPC.Client{} = client, name, opts \\ []) do 17 - opts = put_auth(opts, client.access_token) 18 - Req.get(url(client, name), opts) 33 + @spec get(Atex.XRPC.Client.client(), String.t(), keyword()) :: 34 + {:ok, Req.Response.t(), Atex.XRPC.Client.client()} 35 + | {:error, any(), Atex.XRPC.Client.client()} 36 + def get(client, name, opts \\ []) do 37 + client.__struct__.get(client, name, opts) 19 38 end 20 39 21 40 @doc """ 22 41 Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons. 42 + 43 + Accepts any client that implements `Atex.XRPC.Client` and returns 44 + both the response and the (potentially updated) client. 23 45 """ 24 - @spec post(XRPC.Client.t(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, any()} 25 - def post(%XRPC.Client{} = client, name, opts \\ []) do 26 - # TODO: look through available HTTP clients and see if they have a 27 - # consistent way of providing JSON bodies with auto content-type. If not, 28 - # create one for adapters. 29 - opts = put_auth(opts, client.access_token) 30 - Req.post(url(client, name), opts) 46 + @spec post(Atex.XRPC.Client.client(), String.t(), keyword()) :: 47 + {:ok, Req.Response.t(), Atex.XRPC.Client.client()} 48 + | {:error, any(), Atex.XRPC.Client.client()} 49 + def post(client, name, opts \\ []) do 50 + client.__struct__.post(client, name, opts) 31 51 end 32 52 33 53 @doc """ ··· 49 69 end 50 70 51 71 # TODO: use URI module for joining instead? 52 - @spec url(XRPC.Client.t() | String.t(), String.t()) :: String.t() 53 - defp url(%XRPC.Client{endpoint: endpoint}, name), do: url(endpoint, name) 54 - defp url(endpoint, name) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{name}" 55 - 56 72 @doc """ 57 - Put an `authorization` header into a keyword list of options to pass to a HTTP client. 73 + Create an XRPC url based on an endpoint and a resource name. 74 + 75 + ## Example 76 + 77 + iex> Atex.XRPC.url("https://bsky.app", "app.bsky.actor.getProfile") 78 + "https://bsky.app/xrpc/app.bsky.actor.getProfile" 58 79 """ 59 - @spec put_auth(keyword(), String.t()) :: keyword() 60 - def put_auth(opts, token), 61 - do: put_headers(opts, authorization: "Bearer #{token}") 62 - 63 - @spec put_headers(keyword(), keyword()) :: keyword() 64 - defp put_headers(opts, headers) do 65 - opts 66 - |> Keyword.put_new(:headers, []) 67 - |> Keyword.update(:headers, [], &Keyword.merge(&1, headers)) 68 - end 80 + @spec url(String.t(), String.t()) :: String.t() 81 + def url(endpoint, resource) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{resource}" 69 82 end
+18 -74
lib/atex/xrpc/client.ex
··· 1 1 defmodule Atex.XRPC.Client do 2 2 @moduledoc """ 3 - Struct to store client information for ATProto XRPC. 4 - """ 3 + Behaviour that defines the interface for XRPC clients. 5 4 6 - alias Atex.{XRPC, HTTP} 7 - use TypedStruct 5 + This behaviour allows different types of clients (login-based, OAuth-based, etc.) 6 + to implement authentication and request handling while maintaining a consistent interface. 8 7 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`. 8 + Implementations must handle token refresh internally when requests fail due to 9 + expired tokens, and return both the result and potentially updated client state. 22 10 """ 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 11 + 12 + @type client :: struct() 13 + @type request_opts :: keyword() 14 + @type request_result :: {:ok, Req.Response.t(), client()} | {:error, any(), client()} 29 15 30 16 @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. 17 + Perform an authenticated HTTP GET request on an XRPC resource. 33 18 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{...}} 19 + Implementations should handle token refresh if the request fails due to 20 + expired authentication, returning both the response and the (potentially updated) client. 41 21 """ 42 - @spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, any()} 43 - @spec login(String.t(), String.t(), String.t(), String.t() | nil) :: 44 - {:ok, t()} | {:error, any()} 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 22 + @callback get(client(), String.t(), request_opts()) :: request_result() 66 23 67 24 @doc """ 68 - Request a new `refresh_token` for the given client. 25 + Perform an authenticated HTTP POST request on an XRPC resource. 26 + 27 + Implementations should handle token refresh if the request fails due to 28 + expired authentication, returning both the response and the (potentially updated) client. 69 29 """ 70 - @spec refresh(t()) :: {:ok, t()} | {:error, any()} 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 30 + @callback post(client(), String.t(), request_opts()) :: request_result() 87 31 end
+148
lib/atex/xrpc/login_client.ex
··· 1 + defmodule Atex.XRPC.LoginClient do 2 + alias Atex.XRPC 3 + use TypedStruct 4 + 5 + @behaviour Atex.XRPC.Client 6 + 7 + typedstruct do 8 + field :endpoint, String.t(), enforce: true 9 + field :access_token, String.t() | nil 10 + field :refresh_token, String.t() | nil 11 + end 12 + 13 + @doc """ 14 + Create a new `Atex.XRPC.LoginClient` from an endpoint, and optionally an 15 + existing access/refresh token. 16 + 17 + Endpoint should be the base URL of a PDS, or an AppView in the case of 18 + unauthenticated requests (like Bluesky's public API), e.g. 19 + `https://bsky.social`. 20 + """ 21 + @spec new(String.t(), String.t() | nil, String.t() | nil) :: t() 22 + def new(endpoint, access_token \\ nil, refresh_token \\ nil) do 23 + %__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token} 24 + end 25 + 26 + @doc """ 27 + Create a new `Atex.XRPC.LoginClient` by logging in with an `identifier` and 28 + `password` to fetch an initial pair of access & refresh tokens. 29 + 30 + Also supports providing a MFA token in the situation that is required. 31 + 32 + Uses `com.atproto.server.createSession` under the hood, so `identifier` can be 33 + either a handle or a DID. 34 + 35 + ## Examples 36 + 37 + iex> Atex.XRPC.LoginClient.login("https://bsky.social", "example.com", "password123") 38 + {:ok, %Atex.XRPC.LoginClient{...}} 39 + """ 40 + @spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, any()} 41 + @spec login(String.t(), String.t(), String.t(), String.t() | nil) :: 42 + {:ok, t()} | {:error, any()} 43 + def login(endpoint, identifier, password, auth_factor_token \\ nil) do 44 + json = 45 + %{identifier: identifier, password: password} 46 + |> then( 47 + &if auth_factor_token do 48 + Map.merge(&1, %{authFactorToken: auth_factor_token}) 49 + else 50 + &1 51 + end 52 + ) 53 + 54 + response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json) 55 + 56 + case response do 57 + {:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} -> 58 + {:ok, new(endpoint, access_token, refresh_token)} 59 + 60 + err -> 61 + err 62 + end 63 + end 64 + 65 + @doc """ 66 + Request a new `refresh_token` for the given client. 67 + """ 68 + @spec refresh(t()) :: {:ok, t()} | {:error, any()} 69 + def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do 70 + request = 71 + Req.new(method: :post, url: XRPC.url(endpoint, "com.atproto.server.refreshSession")) 72 + |> put_auth(refresh_token) 73 + 74 + case Req.request(request) do 75 + {:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} -> 76 + {:ok, %{client | access_token: access_token, refresh_token: refresh_token}} 77 + 78 + {:ok, response} -> 79 + {:error, response} 80 + 81 + err -> 82 + err 83 + end 84 + end 85 + 86 + @impl true 87 + def get(%__MODULE__{} = client, resource, opts \\ []) do 88 + request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)]) 89 + end 90 + 91 + @impl true 92 + def post(%__MODULE__{} = client, resource, opts \\ []) do 93 + request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)]) 94 + end 95 + 96 + @spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any()} 97 + defp request(client, opts) do 98 + with {:ok, client} <- validate_client(client) do 99 + request = opts |> Req.new() |> put_auth(client.access_token) 100 + 101 + case Req.request(request) do 102 + {:ok, %{status: 200} = response} -> 103 + {:ok, response, client} 104 + 105 + {:ok, response} -> 106 + handle_failure(client, response, request) 107 + 108 + err -> 109 + err 110 + end 111 + end 112 + end 113 + 114 + @spec handle_failure(t(), Req.Response.t(), Req.Request.t()) :: 115 + {:ok, Req.Response.t(), t()} | {:error, any()} 116 + defp handle_failure(client, response, request) do 117 + IO.inspect(response, label: "got failure") 118 + 119 + if auth_error?(response.body) and client.refresh_token do 120 + case refresh(client) do 121 + {:ok, client} -> 122 + case Req.request(put_auth(request, client.access_token)) do 123 + {:ok, %{status: 200} = response} -> {:ok, response, client} 124 + {:ok, response} -> {:error, response} 125 + err -> err 126 + end 127 + 128 + err -> 129 + err 130 + end 131 + else 132 + {:error, response} 133 + end 134 + end 135 + 136 + @spec validate_client(t()) :: {:ok, t()} | {:error, any()} 137 + defp validate_client(%__MODULE__{access_token: nil}), do: {:error, :no_token} 138 + defp validate_client(%__MODULE__{} = client), do: {:ok, client} 139 + 140 + @spec auth_error?(body :: Req.Response.t()) :: boolean() 141 + defp auth_error?(%{status: status}) when status in [401, 403], do: true 142 + defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true 143 + defp auth_error?(_response), do: false 144 + 145 + @spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t() 146 + defp put_auth(request, token), 147 + do: Req.Request.put_header(request, "authorization", "Bearer #{token}") 148 + end