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

feat: expand XRPC to allow lexicon structs as input

ovyerus.com f628dd73 72f1ab6d

verified
+208 -15
+1 -1
.formatter.exs
··· 1 1 # Used by "mix format" 2 2 [ 3 - inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"], 3 + inputs: ["{mix,.formatter,.credo}.exs", "{config,examples,lib,test}/**/*.{ex,exs}"], 4 4 import_deps: [:typedstruct, :peri, :plug], 5 5 export: [ 6 6 locals_without_parens: [deflexicon: 1]
+2 -1
.gitignore
··· 31 31 secrets 32 32 node_modules 33 33 atproto-oauth-example 34 - .DS_Store 34 + .DS_Store 35 + CLAUDE.md
+3
CHANGELOG.md
··· 16 16 ### Added 17 17 18 18 - `deflexicon` now emits structs for records, objects, queries, and procedures. 19 + - `Atex.XRPC.get/3` and `Atex.XRPC.post/3` now support having a lexicon struct 20 + as the second argument instead of the method's name, making it easier to have 21 + properly checked API calls. 19 22 20 23 ## [0.5.0] - 2025-10-11 21 24
+60
examples/oauth.ex
··· 1 + defmodule ExampleOAuthPlug do 2 + use Plug.Router 3 + alias Atex.OAuth 4 + alias Atex.XRPC 5 + 6 + plug :put_secret_key_base 7 + 8 + plug Plug.Session, 9 + store: :cookie, 10 + key: "atex-oauth", 11 + signing_salt: "signing-salt" 12 + 13 + plug :match 14 + plug :dispatch 15 + 16 + forward "/oauth", to: Atex.OAuth.Plug 17 + 18 + get "/whoami" do 19 + conn = fetch_session(conn) 20 + 21 + with {:ok, client} <- XRPC.OAuthClient.from_conn(conn), 22 + {:ok, response, client} <- 23 + XRPC.post(client, %Com.Atproto.Repo.CreateRecord{ 24 + input: %Com.Atproto.Repo.CreateRecord.Input{ 25 + repo: client.did, 26 + collection: "app.bsky.feed.post", 27 + rkey: Atex.TID.now() |> to_string(), 28 + record: %App.Bsky.Feed.Post{ 29 + text: "Hello world from atex!", 30 + createdAt: NaiveDateTime.to_iso8601(NaiveDateTime.utc_now()) 31 + } 32 + } 33 + }) do 34 + IO.inspect(response, label: "output") 35 + 36 + conn 37 + |> XRPC.OAuthClient.update_plug(client) 38 + |> send_resp(200, response.uri) 39 + else 40 + :error -> 41 + send_resp(conn, 401, "Unauthorized") 42 + 43 + err -> 44 + IO.inspect(err, label: "xrpc failed") 45 + send_resp(conn, 500, "xrpc failed") 46 + end 47 + end 48 + 49 + match _ do 50 + send_resp(conn, 404, "oops") 51 + end 52 + 53 + def put_secret_key_base(conn, _) do 54 + put_in( 55 + conn.secret_key_base, 56 + # Don't use this in production 57 + "5ef1078e1617463a3eb3feb9b152e76587a75a6809e0485a125b6bb7ae468f086680771f700d77ff61dfdc8d8ee8a5c7848024a41cf5ad4b6eb3115f74ce6e46" 58 + ) 59 + end 60 + end
+142 -13
lib/atex/xrpc.ex
··· 12 12 {:ok, client} = Atex.XRPC.LoginClient.login("https://bsky.social", "user.bsky.social", "password") 13 13 {:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"]) 14 14 15 - # OAuth-based client (coming next) 16 - oauth_client = Atex.XRPC.OAuthClient.new_from_oauth_tokens(endpoint, access_token, refresh_token, dpop_key) 15 + # OAuth-based client 16 + {:ok, oauth_client} = Atex.XRPC.OAuthClient.from_conn(conn) 17 17 {:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"]) 18 18 19 19 ## Unauthenticated requests ··· 24 24 {:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."]) 25 25 """ 26 26 27 + alias Atex.XRPC.Client 28 + 27 29 @doc """ 28 30 Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons. 29 31 30 32 Accepts any client that implements `Atex.XRPC.Client` and returns 31 33 both the response and the (potentially updated) client. 34 + 35 + Can be called either with the XRPC operation name as a string, or with a lexicon 36 + struct (generated via `deflexicon`) for type safety and automatic parameter/response handling. 37 + 38 + When using a lexicon struct, the response body will be automatically converted to the 39 + corresponding type if an Output struct exists for the lexicon. 40 + 41 + ## Examples 42 + 43 + # Using string XRPC name 44 + {:ok, response, client} = 45 + Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "ovyerus.com"]) 46 + 47 + # Using lexicon struct with typed construction 48 + {:ok, response, client} = 49 + Atex.XRPC.get(client, %App.Bsky.Actor.GetProfile{ 50 + params: %App.Bsky.Actor.GetProfile.Params{actor: "ovyerus.com"} 51 + }) 32 52 """ 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 53 + @spec get(Client.client(), String.t() | struct(), keyword()) :: 54 + {:ok, Req.Response.t(), Client.client()} 55 + | {:error, any(), Client.client()} 56 + def get(client, name, opts \\ []) 57 + 58 + def get(client, name, opts) when is_binary(name) do 37 59 client.__struct__.get(client, name, opts) 38 60 end 39 61 62 + def get(client, %{__struct__: module} = query, opts) do 63 + opts = if Map.get(query, :params), do: Keyword.put(opts, :params, query.params), else: opts 64 + output_struct = Module.concat(module, Output) 65 + output_exists = Code.ensure_loaded?(output_struct) 66 + 67 + case client.__struct__.get(client, module.id(), opts) do 68 + {:ok, %{status: 200} = response, client} -> 69 + if output_exists do 70 + case output_struct.from_json(response.body) do 71 + {:ok, output} -> 72 + {:ok, %{response | body: output}, client} 73 + 74 + err -> 75 + err 76 + end 77 + else 78 + {:ok, response, client} 79 + end 80 + 81 + {:ok, _, _} = ok -> 82 + ok 83 + 84 + err -> 85 + err 86 + end 87 + end 88 + 40 89 @doc """ 41 - Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons. 90 + Perform a HTTP POST on a XRPC resource. Called a "procedure" in lexicons. 91 + 92 + Accepts any client that implements `Atex.XRPC.Client` and returns both the 93 + response and the (potentially updated) client. 94 + 95 + Can be called either with the XRPC operation name as a string, or with a 96 + lexicon struct (generated via `deflexicon`) for type safety and automatic 97 + input/parameter mapping. 98 + 99 + When using a lexicon struct, the response body will be automatically converted 100 + to the corresponding type if an Output struct exists for the lexicon. 101 + 102 + ## Examples 103 + 104 + # Using string XRPC name 105 + {:ok, response, client} = 106 + Atex.XRPC.post( 107 + client, 108 + "com.atproto.repo.createRecord", 109 + json: %{ 110 + repo: "did:plc:...", 111 + collection: "app.bsky.feed.post", 112 + rkey: Atex.TID.now() |> to_string(), 113 + record: %{ 114 + text: "Hello World", 115 + createdAt: NaiveDateTime.to_iso8601(NaiveDateTime.utc_now()) 116 + } 117 + } 118 + ) 42 119 43 - Accepts any client that implements `Atex.XRPC.Client` and returns 44 - both the response and the (potentially updated) client. 120 + # Using lexicon struct with typed construction 121 + {:ok, response, client} = 122 + Atex.XRPC.post(client, %Com.Atproto.Repo.CreateRecord{ 123 + input: %Com.Atproto.Repo.CreateRecord.Input{ 124 + repo: "did:plc:...", 125 + collection: "app.bsky.feed.post", 126 + rkey: Atex.TID.now() |> to_string(), 127 + record: %App.Bsky.Feed.Post{ 128 + text: "Hello World!", 129 + createdAt: NaiveDateTime.to_iso8601(NaiveDateTime.utc_now()) 130 + } 131 + } 132 + }) 45 133 """ 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 134 + @spec post(Client.client(), String.t() | struct(), keyword()) :: 135 + {:ok, Req.Response.t(), Client.client()} 136 + | {:error, any(), Client.client()} 137 + def post(client, name, opts \\ []) 138 + 139 + def post(client, name, opts) when is_binary(name) do 50 140 client.__struct__.post(client, name, opts) 141 + end 142 + 143 + def post(client, %{__struct__: module} = procedure, opts) do 144 + opts = 145 + opts 146 + |> then( 147 + &if Map.get(procedure, :params), do: Keyword.put(&1, :params, procedure.params), else: &1 148 + ) 149 + |> then( 150 + &cond do 151 + Map.get(procedure, :input) -> Keyword.put(&1, :json, procedure.input) 152 + Map.get(procedure, :raw_input) -> Keyword.put(&1, :body, procedure.raw_input) 153 + true -> &1 154 + end 155 + ) 156 + 157 + output_struct = Module.concat(module, Output) 158 + output_exists = Code.ensure_loaded?(output_struct) 159 + 160 + case client.__struct__.post(client, module.id(), opts) do 161 + {:ok, %{status: 200} = response, client} -> 162 + if output_exists do 163 + case output_struct.from_json(response.body) do 164 + {:ok, output} -> 165 + {:ok, %{response | body: output}, client} 166 + 167 + err -> 168 + err 169 + end 170 + else 171 + {:ok, response, client} 172 + end 173 + 174 + {:ok, _, _} = ok -> 175 + ok 176 + 177 + err -> 178 + err 179 + end 51 180 end 52 181 53 182 @doc """