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

feat: expand XRPC to allow lexicon structs as input

ovyerus.com f628dd73 72f1ab6d

verified
Changed files
+208 -15
examples
lib
atex
+1 -1
.formatter.exs
···
# Used by "mix format"
[
-
inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"],
import_deps: [:typedstruct, :peri, :plug],
export: [
locals_without_parens: [deflexicon: 1]
···
# Used by "mix format"
[
+
inputs: ["{mix,.formatter,.credo}.exs", "{config,examples,lib,test}/**/*.{ex,exs}"],
import_deps: [:typedstruct, :peri, :plug],
export: [
locals_without_parens: [deflexicon: 1]
+2 -1
.gitignore
···
secrets
node_modules
atproto-oauth-example
-
.DS_Store
···
secrets
node_modules
atproto-oauth-example
+
.DS_Store
+
CLAUDE.md
+3
CHANGELOG.md
···
### Added
- `deflexicon` now emits structs for records, objects, queries, and procedures.
## [0.5.0] - 2025-10-11
···
### Added
- `deflexicon` now emits structs for records, objects, queries, and procedures.
+
- `Atex.XRPC.get/3` and `Atex.XRPC.post/3` now support having a lexicon struct
+
as the second argument instead of the method's name, making it easier to have
+
properly checked API calls.
## [0.5.0] - 2025-10-11
+60
examples/oauth.ex
···
···
+
defmodule ExampleOAuthPlug do
+
use Plug.Router
+
alias Atex.OAuth
+
alias Atex.XRPC
+
+
plug :put_secret_key_base
+
+
plug Plug.Session,
+
store: :cookie,
+
key: "atex-oauth",
+
signing_salt: "signing-salt"
+
+
plug :match
+
plug :dispatch
+
+
forward "/oauth", to: Atex.OAuth.Plug
+
+
get "/whoami" do
+
conn = fetch_session(conn)
+
+
with {:ok, client} <- XRPC.OAuthClient.from_conn(conn),
+
{:ok, response, client} <-
+
XRPC.post(client, %Com.Atproto.Repo.CreateRecord{
+
input: %Com.Atproto.Repo.CreateRecord.Input{
+
repo: client.did,
+
collection: "app.bsky.feed.post",
+
rkey: Atex.TID.now() |> to_string(),
+
record: %App.Bsky.Feed.Post{
+
text: "Hello world from atex!",
+
createdAt: NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())
+
}
+
}
+
}) do
+
IO.inspect(response, label: "output")
+
+
conn
+
|> XRPC.OAuthClient.update_plug(client)
+
|> send_resp(200, response.uri)
+
else
+
:error ->
+
send_resp(conn, 401, "Unauthorized")
+
+
err ->
+
IO.inspect(err, label: "xrpc failed")
+
send_resp(conn, 500, "xrpc failed")
+
end
+
end
+
+
match _ do
+
send_resp(conn, 404, "oops")
+
end
+
+
def put_secret_key_base(conn, _) do
+
put_in(
+
conn.secret_key_base,
+
# Don't use this in production
+
"5ef1078e1617463a3eb3feb9b152e76587a75a6809e0485a125b6bb7ae468f086680771f700d77ff61dfdc8d8ee8a5c7848024a41cf5ad4b6eb3115f74ce6e46"
+
)
+
end
+
end
+142 -13
lib/atex/xrpc.ex
···
{:ok, client} = Atex.XRPC.LoginClient.login("https://bsky.social", "user.bsky.social", "password")
{:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
-
# OAuth-based client (coming next)
-
oauth_client = Atex.XRPC.OAuthClient.new_from_oauth_tokens(endpoint, access_token, refresh_token, dpop_key)
{:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
## Unauthenticated requests
···
{:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."])
"""
@doc """
Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
Accepts any client that implements `Atex.XRPC.Client` and returns
both the response and the (potentially updated) client.
"""
-
@spec get(Atex.XRPC.Client.client(), String.t(), keyword()) ::
-
{:ok, Req.Response.t(), Atex.XRPC.Client.client()}
-
| {:error, any(), Atex.XRPC.Client.client()}
-
def get(client, name, opts \\ []) do
client.__struct__.get(client, name, opts)
end
@doc """
-
Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
-
Accepts any client that implements `Atex.XRPC.Client` and returns
-
both the response and the (potentially updated) client.
"""
-
@spec post(Atex.XRPC.Client.client(), String.t(), keyword()) ::
-
{:ok, Req.Response.t(), Atex.XRPC.Client.client()}
-
| {:error, any(), Atex.XRPC.Client.client()}
-
def post(client, name, opts \\ []) do
client.__struct__.post(client, name, opts)
end
@doc """
···
{:ok, client} = Atex.XRPC.LoginClient.login("https://bsky.social", "user.bsky.social", "password")
{:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
+
# OAuth-based client
+
{:ok, oauth_client} = Atex.XRPC.OAuthClient.from_conn(conn)
{:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
## Unauthenticated requests
···
{:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."])
"""
+
alias Atex.XRPC.Client
+
@doc """
Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
Accepts any client that implements `Atex.XRPC.Client` and returns
both the response and the (potentially updated) client.
+
+
Can be called either with the XRPC operation name as a string, or with a lexicon
+
struct (generated via `deflexicon`) for type safety and automatic parameter/response handling.
+
+
When using a lexicon struct, the response body will be automatically converted to the
+
corresponding type if an Output struct exists for the lexicon.
+
+
## Examples
+
+
# Using string XRPC name
+
{:ok, response, client} =
+
Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "ovyerus.com"])
+
+
# Using lexicon struct with typed construction
+
{:ok, response, client} =
+
Atex.XRPC.get(client, %App.Bsky.Actor.GetProfile{
+
params: %App.Bsky.Actor.GetProfile.Params{actor: "ovyerus.com"}
+
})
"""
+
@spec get(Client.client(), String.t() | struct(), keyword()) ::
+
{:ok, Req.Response.t(), Client.client()}
+
| {:error, any(), Client.client()}
+
def get(client, name, opts \\ [])
+
+
def get(client, name, opts) when is_binary(name) do
client.__struct__.get(client, name, opts)
end
+
def get(client, %{__struct__: module} = query, opts) do
+
opts = if Map.get(query, :params), do: Keyword.put(opts, :params, query.params), else: opts
+
output_struct = Module.concat(module, Output)
+
output_exists = Code.ensure_loaded?(output_struct)
+
+
case client.__struct__.get(client, module.id(), opts) do
+
{:ok, %{status: 200} = response, client} ->
+
if output_exists do
+
case output_struct.from_json(response.body) do
+
{:ok, output} ->
+
{:ok, %{response | body: output}, client}
+
+
err ->
+
err
+
end
+
else
+
{:ok, response, client}
+
end
+
+
{:ok, _, _} = ok ->
+
ok
+
+
err ->
+
err
+
end
+
end
+
@doc """
+
Perform a HTTP POST on a XRPC resource. Called a "procedure" in lexicons.
+
+
Accepts any client that implements `Atex.XRPC.Client` and returns both the
+
response and the (potentially updated) client.
+
+
Can be called either with the XRPC operation name as a string, or with a
+
lexicon struct (generated via `deflexicon`) for type safety and automatic
+
input/parameter mapping.
+
+
When using a lexicon struct, the response body will be automatically converted
+
to the corresponding type if an Output struct exists for the lexicon.
+
+
## Examples
+
+
# Using string XRPC name
+
{:ok, response, client} =
+
Atex.XRPC.post(
+
client,
+
"com.atproto.repo.createRecord",
+
json: %{
+
repo: "did:plc:...",
+
collection: "app.bsky.feed.post",
+
rkey: Atex.TID.now() |> to_string(),
+
record: %{
+
text: "Hello World",
+
createdAt: NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())
+
}
+
}
+
)
+
# Using lexicon struct with typed construction
+
{:ok, response, client} =
+
Atex.XRPC.post(client, %Com.Atproto.Repo.CreateRecord{
+
input: %Com.Atproto.Repo.CreateRecord.Input{
+
repo: "did:plc:...",
+
collection: "app.bsky.feed.post",
+
rkey: Atex.TID.now() |> to_string(),
+
record: %App.Bsky.Feed.Post{
+
text: "Hello World!",
+
createdAt: NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())
+
}
+
}
+
})
"""
+
@spec post(Client.client(), String.t() | struct(), keyword()) ::
+
{:ok, Req.Response.t(), Client.client()}
+
| {:error, any(), Client.client()}
+
def post(client, name, opts \\ [])
+
+
def post(client, name, opts) when is_binary(name) do
client.__struct__.post(client, name, opts)
+
end
+
+
def post(client, %{__struct__: module} = procedure, opts) do
+
opts =
+
opts
+
|> then(
+
&if Map.get(procedure, :params), do: Keyword.put(&1, :params, procedure.params), else: &1
+
)
+
|> then(
+
&cond do
+
Map.get(procedure, :input) -> Keyword.put(&1, :json, procedure.input)
+
Map.get(procedure, :raw_input) -> Keyword.put(&1, :body, procedure.raw_input)
+
true -> &1
+
end
+
)
+
+
output_struct = Module.concat(module, Output)
+
output_exists = Code.ensure_loaded?(output_struct)
+
+
case client.__struct__.post(client, module.id(), opts) do
+
{:ok, %{status: 200} = response, client} ->
+
if output_exists do
+
case output_struct.from_json(response.body) do
+
{:ok, output} ->
+
{:ok, %{response | body: output}, client}
+
+
err ->
+
err
+
end
+
else
+
{:ok, response, client}
+
end
+
+
{:ok, _, _} = ok ->
+
ok
+
+
err ->
+
err
+
end
end
@doc """