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

feat: cache for identity resolver

ovyerus.com 4afb6234 d7380b38

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