Skip to content

Commit 4afb623

Browse files
committed
feat: cache for identity resolver
1 parent d7380b3 commit 4afb623

File tree

7 files changed

+155
-10
lines changed

7 files changed

+155
-10
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ and this project adheres to
1818
- `Atex.HTTP.Response` struct to be returned by `Atex.HTTP.Adapter`.
1919
- `Atex.IdentityResolver` module for resolving and validating an identity,
2020
either by DID or a handle.
21+
- Also has a pluggable cache (with a default ETS implementation) for keeping
22+
some data locally.
2123

2224
## [0.2.0] - 2025-06-09
2325

lib/atex/application.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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

lib/atex/identity_resolver.ex

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,45 @@
11
defmodule Atex.IdentityResolver do
2-
alias Atex.IdentityResolver.{DID, DIDDocument, Handle}
2+
alias Atex.IdentityResolver.{Cache, DID, DIDDocument, Handle, Identity}
33

44
@handle_strategy Application.compile_env(:atex, :handle_resolver_strategy, :dns_first)
55

66
# TODO: simplify errors
77

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()}
1119
| {:error, :handle_mismatch}
1220
| {:error, any()}
13-
def resolve("did:" <> _ = did) do
21+
defp do_resolve("did:" <> _ = did) do
1422
with {:ok, document} <- DID.resolve(did),
1523
:ok <- DIDDocument.validate_for_atproto(document, did) do
1624
with handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
1725
{:ok, handle_did} <- Handle.resolve(handle, @handle_strategy),
1826
true <- handle_did == did do
19-
{:ok, document, did, handle}
27+
{:ok, Identity.new(did, handle, document)}
2028
else
2129
# Not having a handle, while a little un-ergonomic, is totally valid.
22-
nil -> {:ok, document}
30+
nil -> {:ok, Identity.new(did, nil, document)}
2331
false -> {:error, :handle_mismatch}
2432
e -> e
2533
end
2634
end
2735
end
2836

29-
def resolve(handle) do
37+
defp do_resolve(handle) do
3038
with {:ok, did} <- Handle.resolve(handle, @handle_strategy),
3139
{:ok, document} <- DID.resolve(did),
3240
did_handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
3341
true <- did_handle == handle do
34-
{:ok, document, did, handle}
42+
{:ok, Identity.new(did, handle, document)}
3543
else
3644
nil -> {:error, :handle_mismatch}
3745
false -> {:error, :handle_mismatch}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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

mix.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ defmodule Atex.MixProject do
2020

2121
def application do
2222
[
23-
extra_applications: [:logger]
23+
extra_applications: [:logger],
24+
mod: {Atex.Application, []}
2425
]
2526
end
2627

0 commit comments

Comments
 (0)