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

feat: cache for identity resolver

ovyerus.com 4afb6234 d7380b38

verified
+155 -10
+2
CHANGELOG.md
··· 18 18 - `Atex.HTTP.Response` struct to be returned by `Atex.HTTP.Adapter`. 19 19 - `Atex.IdentityResolver` module for resolving and validating an identity, 20 20 either by DID or a handle. 21 + - Also has a pluggable cache (with a default ETS implementation) for keeping 22 + some data locally. 21 23 22 24 ## [0.2.0] - 2025-06-09 23 25
+10
lib/atex/application.ex
··· 1 + defmodule Atex.Application do 2 + @moduledoc false 3 + 4 + use Application 5 + 6 + def start(_type, _args) do 7 + children = [Atex.IdentityResolver.Cache] 8 + Supervisor.start_link(children, strategy: :one_for_one) 9 + end 10 + end
+17 -9
lib/atex/identity_resolver.ex
··· 1 1 defmodule Atex.IdentityResolver do 2 - alias Atex.IdentityResolver.{DID, DIDDocument, Handle} 2 + alias Atex.IdentityResolver.{Cache, DID, DIDDocument, Handle, Identity} 3 3 4 4 @handle_strategy Application.compile_env(:atex, :handle_resolver_strategy, :dns_first) 5 5 6 6 # TODO: simplify errors 7 7 8 - @spec resolve(identity :: String.t()) :: 9 - {:ok, document :: DIDDocument.t(), did :: String.t(), handle :: String.t()} 10 - | {:ok, DIDDocument.t()} 8 + def resolve(identifier) do 9 + # If cache fetch succeeds, then the ok tuple will be retuned by the default `with` behaviour 10 + with {:error, :not_found} <- Cache.get(identifier), 11 + {:ok, identity} <- do_resolve(identifier), 12 + identity <- Cache.insert(identity) do 13 + {:ok, identity} 14 + end 15 + end 16 + 17 + @spec do_resolve(identity :: String.t()) :: 18 + {:ok, Identity.t()} 11 19 | {:error, :handle_mismatch} 12 20 | {:error, any()} 13 - def resolve("did:" <> _ = did) do 21 + defp do_resolve("did:" <> _ = did) do 14 22 with {:ok, document} <- DID.resolve(did), 15 23 :ok <- DIDDocument.validate_for_atproto(document, did) do 16 24 with handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document), 17 25 {:ok, handle_did} <- Handle.resolve(handle, @handle_strategy), 18 26 true <- handle_did == did do 19 - {:ok, document, did, handle} 27 + {:ok, Identity.new(did, handle, document)} 20 28 else 21 29 # Not having a handle, while a little un-ergonomic, is totally valid. 22 - nil -> {:ok, document} 30 + nil -> {:ok, Identity.new(did, nil, document)} 23 31 false -> {:error, :handle_mismatch} 24 32 e -> e 25 33 end 26 34 end 27 35 end 28 36 29 - def resolve(handle) do 37 + defp do_resolve(handle) do 30 38 with {:ok, did} <- Handle.resolve(handle, @handle_strategy), 31 39 {:ok, document} <- DID.resolve(did), 32 40 did_handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document), 33 41 true <- did_handle == handle do 34 - {:ok, document, did, handle} 42 + {:ok, Identity.new(did, handle, document)} 35 43 else 36 44 nil -> {:error, :handle_mismatch} 37 45 false -> {:error, :handle_mismatch}
+42
lib/atex/identity_resolver/cache.ex
··· 1 + defmodule Atex.IdentityResolver.Cache do 2 + # TODO: need the following: 3 + # did -> handle mapping 4 + # handle -> did mapping 5 + # did -> document mapping? 6 + # User should be able to call a single function to fetch all info for either did and handle, including the link between them. 7 + # Need some sort of TTL so that we can refresh as necessary 8 + alias Atex.IdentityResolver.Identity 9 + 10 + @cache Application.compile_env(:atex, :identity_cache, Atex.IdentityResolver.Cache.ETS) 11 + 12 + @doc """ 13 + Add a new identity to the cache. Can also be used to update an identity that may already exist. 14 + 15 + Returns the input `t:Atex.IdentityResolver.Identity.t/0`. 16 + """ 17 + @callback insert(identity :: Identity.t()) :: Identity.t() 18 + 19 + @doc """ 20 + Retrieve an identity from the cache by DID *or* handle. 21 + """ 22 + @callback get(String.t()) :: {:ok, Identity.t()} | {:error, atom()} 23 + 24 + @doc """ 25 + Delete an identity in the cache. 26 + """ 27 + @callback delete(String.t()) :: :noop | Identity.t() 28 + 29 + @doc """ 30 + Get the child specification for starting the cache in a supervision tree. 31 + """ 32 + @callback child_spec(any()) :: Supervisor.child_spec() 33 + 34 + defdelegate get(identifier), to: @cache 35 + 36 + @doc false 37 + defdelegate insert(payload), to: @cache 38 + @doc false 39 + defdelegate delete(snowflake), to: @cache 40 + @doc false 41 + defdelegate child_spec(opts), to: @cache 42 + end
+57
lib/atex/identity_resolver/cache/ets.ex
··· 1 + defmodule Atex.IdentityResolver.Cache.ETS do 2 + alias Atex.IdentityResolver.Identity 3 + @behaviour Atex.IdentityResolver.Cache 4 + use Supervisor 5 + 6 + @table :atex_identities 7 + 8 + def start_link(opts) do 9 + Supervisor.start_link(__MODULE__, opts) 10 + end 11 + 12 + @impl Supervisor 13 + def init(_opts) do 14 + :ets.new(@table, [:set, :public, :named_table]) 15 + Supervisor.init([], strategy: :one_for_one) 16 + end 17 + 18 + @impl Atex.IdentityResolver.Cache 19 + @spec insert(Identity.t()) :: Identity.t() 20 + def insert(identity) do 21 + # TODO: benchmark lookups vs match performance, is it better to use a "composite" key or two inserts? 22 + :ets.insert(@table, {{identity.did, identity.handle}, identity}) 23 + identity 24 + end 25 + 26 + @impl Atex.IdentityResolver.Cache 27 + @spec get(String.t()) :: {:ok, Identity.t()} | {:error, atom()} 28 + def get(identifier) do 29 + lookup(identifier) 30 + end 31 + 32 + @impl Atex.IdentityResolver.Cache 33 + @spec delete(String.t()) :: :noop | Identity.t() 34 + def delete(identifier) do 35 + case lookup(identifier) do 36 + {:ok, identity} -> 37 + :ets.delete(@table, {identity.did, identity.handle}) 38 + identity 39 + 40 + _ -> 41 + :noop 42 + end 43 + end 44 + 45 + defp lookup(identifier) do 46 + case :ets.match(@table, {{identifier, :_}, :"$1"}) do 47 + [] -> 48 + case :ets.match(@table, {{:_, identifier}, :"$1"}) do 49 + [] -> {:error, :not_found} 50 + [[identity]] -> {:ok, identity} 51 + end 52 + 53 + [[identity]] -> 54 + {:ok, identity} 55 + end 56 + end 57 + end
+25
lib/atex/identity_resolver/identity.ex
··· 1 + defmodule Atex.IdentityResolver.Identity do 2 + use TypedStruct 3 + 4 + @typedoc """ 5 + The controlling DID for an identity. 6 + """ 7 + @type did() :: String.t() 8 + @typedoc """ 9 + The human-readable handle for an identity. Can be missing. 10 + """ 11 + @type handle() :: String.t() | nil 12 + @typedoc """ 13 + The resolved DID document for an identity. 14 + """ 15 + @type document() :: Atex.IdentityResolver.DIDDocument.t() 16 + 17 + typedstruct do 18 + field :did, did(), enforce: true 19 + field :handle, handle() 20 + field :document, document(), enforce: true 21 + end 22 + 23 + @spec new(did(), handle(), document()) :: t() 24 + def new(did, handle, document), do: %__MODULE__{did: did, handle: handle, document: document} 25 + end
+2 -1
mix.exs
··· 20 20 21 21 def application do 22 22 [ 23 - extra_applications: [:logger] 23 + extra_applications: [:logger], 24 + mod: {Atex.Application, []} 24 25 ] 25 26 end 26 27