Latch: an idiomatic Elixir OAuth atproto library
Or how I stopped worrying and learned to love the ETS
I've previously written about Latch and I wanted to give a little update. This side quest ended up taking quite a lot of time to complete, but now I'm finally seeing the light at the end of the tunnel.
The biggest thing was ergonomics of having to implement a Store, with six callbacks and some unfortunately annoying logic. Maybe it's just me, but implementing callbacks always feels awkward. Can't quite keep in my head who's calling who... So I added a built-in Store implementation using ETS. The implementation ended up not being very bad at all, if you ignore the scary but necessary __using__ defmacro. Implementation here.
The core of the implementation is this, all six callbacks:
def put_request(ref, state, request, ttl) do
sweep_after_timestamp = System.monotonic_time(:second) + ttl
:ets.insert(ref, {state, request, sweep_after_timestamp})
:ok
end
def take_request(ref, state) do
case :ets.take(ref, state) do
[] ->
{:error, :not_found}
[{^state, request, sweep_after_timestamp} | _] ->
if sweep_after_timestamp > System.monotonic_time(:second) do
{:ok, request}
else
{:error, :not_found}
end
end
end
def fetch_session(ref, did) do
case :ets.lookup(ref, did) do
[] -> {:error, :not_found}
[{^did, session} | _] -> {:ok, session}
end
end
def put_session(ref, did, session) do
:ets.insert(ref, {did, session})
:ok
end
def delete_session(ref, did) do
:ets.delete(ref, did)
:ok
end
def update_session(ref, did, fun) do
with {:ok, session} <- fetch_session(ref, did) do
with {:ok, session} <- fun.(session) do
:ok = put_session(ref, did, session)
{:ok, session}
end
end
rescue
error ->
Logger.warning("Latch.Store.ETS implementation raised with: #{inspect(error)}")
{:error, :backend_error}
endAnd the rest of it is just plumbing for the GenServer that owns it. Got some pretty serious integration tests in place too https://tangled.org/jola.dev/latch/blob/edf5badb2f6c54408538566ef217176433524ade/test/latch/store/ets_test.exs
A sample:
test "lifecycle" do
pid = start_latch()
# AUTHORIZE SECTION
identity = %Identity{did: @did, handle: @handle, pds_endpoint: @pds}
server = server()
request_uri = "urn:ietf:params:oauth:request_uri:request"
expect(Identity, :resolve_handle, fn _handle -> {:ok, identity} end)
expect(Discovery, :discover, fn _pds, _opts -> {:ok, server} end)
expect(Flow, :par, fn _config, _server, opts ->
send(self(), {:state, opts[:state]})
{:ok, request_uri}
end)
{:ok, _redirect_url} = Latch.authorize(pid, @handle)
assert_receive {:state, state}
# CALLBACK SECTION
session = %Session{
did: @did,
access_token: "access-token",
refresh_token: "refresh-token",
dpop_key: @dpop_key,
scope: "atproto",
issuer: @issuer,
pds_endpoint: @pds,
expires_at: DateTime.add(DateTime.utc_now(), 5, :minute)
}
expect(Flow, :exchange_code, fn _config, _opts ->
{:ok, session}
end)
assert {:ok, %{did: @did, handle: @handle}} =
Latch.callback(
pid,
%{
"state" => state,
"iss" => @issuer,
"code" => "auth-code"
}
)
# CLIENT SECTION
expect(XRPC, :query, fn _config, _session, _method, _opts ->
{:ok, %{"did" => @did}}
end)
assert {:ok, %{"did" => @did}} =
Latch.query(pid, @did, "app.bsky.actor.getProfile", actor: @did)
assert :ok = Latch.delete_session(pid, @did)
reject(&XRPC.query/4)
assert {:error, %Latch.Error.NoSession{}} =
Latch.query(pid, @did, "app.bsky.actor.getProfile", actor: @did)
endReaping the benefits of using the "Finch pattern" for library design here, we can easily mock and integration test even with async: true.
The one less ideal quirk is the part where you have to define your own ETS store module with use.
defmodule MyApp.LatchStore do
use Latch.Store.ETS
endThe practical reason why it's designed this way is that the implementor's store needs to be bound to their "instance", and use can neatly do that. The downside is that now there can only be a single instance of MyApp.LatchStore, we're not passing a dynamic name. So we can't have two instances running concurrently. I don't expect that really matters much to the user of the library, and it's not really a problem for the library either. Yet. I might revisit this later but for now, this is fine.
Next up is writing some better documentation and an integration guide.
Log in to leave a note.