Current section
Files
Jump to
Current section
Files
lib/ex_dav/storage/memory.ex
defmodule ExDav.Storage.Memory do
@moduledoc """
In-memory implementation of `ExDav.Storage`. Backed by a single Agent
registered under the module name, for use in dev demos and tests.
Add `ExDav.Storage.Memory` to your supervision tree (it has no init
args), or call `start_link/1` from a test setup. Use `reset/0` between
tests to clear state.
children = [ExDav.Storage.Memory]
Supervisor.start_link(children, strategy: :one_for_one)
ExDav.Storage.Memory.upsert_user("alice", "secret")
{:ok, _} = ExDav.Storage.Memory.create_collection("alice", :calendar, "default",
displayname: "Default")
Plug wiring:
plug ExDav.CalDav.Plug,
storage: ExDav.Storage.Memory,
authenticator: {ExDav.Authenticator.Basic,
verify: {ExDav.Storage.Memory, :authenticate}}
"""
@behaviour ExDav.Storage
use Agent
defp blank do
%{
# username => %{password, display_name}
users: %{},
# {username, kind} => %{col_name => %{displayname, description, properties, ctag}}
collections: %{},
# {username, kind} => %{col_name => %{res_name => %{body, content_type, etag, uid, component, version}}}
resources: %{},
# {username, kind} => %{col_name => %{res_name => version}}
tombstones: %{}
}
end
def start_link(_opts \\ []) do
Agent.start_link(fn -> blank() end, name: __MODULE__)
end
@doc "Reset all state. Intended for tests."
def reset, do: Agent.update(__MODULE__, fn _ -> blank() end)
# ---- admin / auth (not part of the Storage behaviour) --------------------
def upsert_user(username, password, opts \\ []) do
display_name = Keyword.get(opts, :display_name, username)
Agent.update(__MODULE__, fn s ->
put_in(s, [:users, username], %{password: password, display_name: display_name})
end)
:ok
end
def authenticate(username, password) do
Agent.get(__MODULE__, fn s ->
case Map.get(s.users, username) do
%{password: ^password} -> true
_ -> false
end
end)
end
# ---- behaviour callbacks --------------------------------------------------
@impl true
def user_exists?(username) do
Agent.get(__MODULE__, fn s -> Map.has_key?(s.users, username) end)
end
@impl true
def list_collections(username, kind) do
Agent.get(__MODULE__, fn s ->
s.collections
|> Map.get({username, kind}, %{})
|> Enum.sort_by(fn {name, _} -> name end)
|> Enum.map(fn {name, c} -> to_collection_map(name, kind, c, %{}) end)
end)
end
@impl true
def get_collection(username, kind, name) do
Agent.get(__MODULE__, fn s ->
case get_in(s.collections, [{username, kind}, name]) do
nil -> nil
c -> to_collection_map(name, kind, c, %{})
end
end)
end
@impl true
def get_collection_with_resources(username, kind, name) do
Agent.get(__MODULE__, fn s ->
case get_in(s.collections, [{username, kind}, name]) do
nil ->
nil
c ->
res = get_in(s.resources, [{username, kind}, name]) || %{}
to_collection_map(name, kind, c, res)
end
end)
end
@impl true
def create_collection(username, kind, name, opts) do
Agent.get_and_update(__MODULE__, fn s ->
cond do
not Map.has_key?(s.users, username) ->
{{:error, :no_user}, s}
match?(%{}, get_in(s.collections, [{username, kind}, name])) ->
{{:error, :already_exists}, s}
true ->
col = %{
displayname: Keyword.get(opts, :displayname, name),
description: Keyword.get(opts, :description),
properties: Keyword.get(opts, :properties, default_properties(kind)),
ctag: 0
}
s = put_in_path(s, [:collections, {username, kind}, name], col)
{{:ok, to_collection_map(name, kind, col, %{})}, s}
end
end)
end
@impl true
def update_collection(username, kind, name, props) do
Agent.get_and_update(__MODULE__, fn s ->
case get_in(s.collections, [{username, kind}, name]) do
nil ->
{{:error, :not_found}, s}
col ->
updated =
Enum.reduce(props, col, fn
{:displayname, v}, acc -> Map.put(acc, :displayname, v)
{:description, v}, acc -> Map.put(acc, :description, v)
_, acc -> acc
end)
|> Map.update!(:ctag, &(&1 + 1))
s = put_in_path(s, [:collections, {username, kind}, name], updated)
{{:ok, to_collection_map(name, kind, updated, %{})}, s}
end
end)
end
@impl true
def delete_collection(username, kind, name) do
Agent.get_and_update(__MODULE__, fn s ->
if get_in(s.collections, [{username, kind}, name]) do
s =
s
|> update_in([:collections, {username, kind}], &Map.delete(&1, name))
|> drop_path([:resources, {username, kind}, name])
|> drop_path([:tombstones, {username, kind}, name])
{:ok, s}
else
{{:error, :not_found}, s}
end
end)
end
@impl true
def get_resource(username, kind, col_name, res_name) do
Agent.get(__MODULE__, fn s ->
case get_in(s.resources, [{username, kind}, col_name, res_name]) do
nil -> nil
r -> to_resource_map(kind, res_name, r)
end
end)
end
@impl true
def put_resource(username, kind, col_name, res_name, body) do
{uid, component} = summarize_body(kind, body)
etag = ExDav.ICal.etag(body)
Agent.get_and_update(__MODULE__, fn s ->
case get_in(s.collections, [{username, kind}, col_name]) do
nil ->
{{:error, :not_found}, s}
col ->
new_version = col.ctag + 1
res = %{
body: body,
content_type: content_type_for(kind),
etag: etag,
uid: uid,
component: component,
version: new_version
}
s =
s
|> put_in_path([:resources, {username, kind}, col_name, res_name], res)
|> put_in_path([:collections, {username, kind}, col_name, :ctag], new_version)
|> drop_path([:tombstones, {username, kind}, col_name, res_name])
{{:ok, to_resource_map(kind, res_name, res)}, s}
end
end)
end
@impl true
def delete_resource(username, kind, col_name, res_name) do
Agent.get_and_update(__MODULE__, fn s ->
with col when is_map(col) <- get_in(s.collections, [{username, kind}, col_name]),
res when is_map(res) <- get_in(s.resources, [{username, kind}, col_name, res_name]) do
new_version = col.ctag + 1
s =
s
|> update_in([:resources, {username, kind}, col_name], &Map.delete(&1, res_name))
|> put_in_path([:collections, {username, kind}, col_name, :ctag], new_version)
|> put_in_path([:tombstones, {username, kind}, col_name, res_name], new_version)
{:ok, s}
else
_ -> {{:error, :not_found}, s}
end
end)
end
@impl true
def sync_changes(username, kind, col_name, since) do
Agent.get(__MODULE__, fn s ->
case get_in(s.collections, [{username, kind}, col_name]) do
nil ->
{:error, :not_found}
col ->
res = get_in(s.resources, [{username, kind}, col_name]) || %{}
tombs = get_in(s.tombstones, [{username, kind}, col_name]) || %{}
changed =
res
|> Enum.filter(fn {_, r} -> since == nil or r.version > since end)
|> Enum.map(fn {name, r} -> to_resource_map(kind, name, r) end)
deleted =
case since do
nil ->
[]
v when is_integer(v) ->
tombs
|> Enum.filter(fn {_, ver} -> ver > v end)
|> Enum.map(fn {name, _} -> name end)
end
{changed, deleted, col.ctag}
end
end)
end
# ---- internals ------------------------------------------------------------
defp to_collection_map(name, kind, col, res) do
%{
name: name,
displayname: col.displayname || name,
description: col.description,
kind: kind,
properties: col.properties || default_properties(kind),
ctag: col.ctag,
resources: for({n, r} <- res, into: %{}, do: {n, to_resource_map(kind, n, r)})
}
end
defp to_resource_map(kind, name, r) do
%{
name: name,
body: r.body,
content_type: r.content_type || content_type_for(kind),
etag: r.etag,
uid: r.uid,
component: Map.get(r, :component)
}
end
defp default_properties(:calendar), do: %{"components" => ["VEVENT", "VTODO"]}
defp default_properties(:addressbook), do: %{}
defp content_type_for(:calendar), do: "text/calendar; charset=utf-8"
defp content_type_for(:addressbook), do: "text/vcard; charset=utf-8"
defp summarize_body(:calendar, body), do: ExDav.ICal.summarize(body)
defp summarize_body(:addressbook, body), do: {ExDav.VCard.extract_uid(body), nil}
defp put_in_path(map, [k], v), do: Map.put(map, k, v)
defp put_in_path(map, [k | rest], v) do
Map.update(map, k, put_in_path(%{}, rest, v), &put_in_path(&1, rest, v))
end
defp drop_path(map, [k]), do: Map.delete(map, k)
defp drop_path(map, [k | rest]) do
case Map.fetch(map, k) do
:error -> map
{:ok, sub} -> Map.put(map, k, drop_path(sub, rest))
end
end
end