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_calendar("alice", "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 => %{cal_name => %{displayname, description, components, ctag}}
calendars: %{},
# username => %{cal_name => %{obj_name => %{ical, etag, uid, component, version}}}
objects: %{},
# username => %{cal_name => %{obj_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_calendars(username) do
Agent.get(__MODULE__, fn s ->
s.calendars
|> Map.get(username, %{})
|> Enum.sort_by(fn {name, _} -> name end)
|> Enum.map(fn {name, c} -> to_calendar_map(name, c, %{}) end)
end)
end
@impl true
def get_calendar(username, name) do
Agent.get(__MODULE__, fn s ->
case get_in(s.calendars, [username, name]) do
nil -> nil
c -> to_calendar_map(name, c, %{})
end
end)
end
@impl true
def get_calendar_with_objects(username, name) do
Agent.get(__MODULE__, fn s ->
case get_in(s.calendars, [username, name]) do
nil ->
nil
c ->
objs = get_in(s.objects, [username, name]) || %{}
to_calendar_map(name, c, objs)
end
end)
end
@impl true
def create_calendar(username, 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.calendars, [username, name])) ->
{{:error, :already_exists}, s}
true ->
cal = %{
displayname: Keyword.get(opts, :displayname, name),
description: Keyword.get(opts, :description),
components: Keyword.get(opts, :components, ["VEVENT", "VTODO"]),
ctag: 0
}
s = put_in_path(s, [:calendars, username, name], cal)
{{:ok, to_calendar_map(name, cal, %{})}, s}
end
end)
end
@impl true
def update_calendar(username, name, props) do
Agent.get_and_update(__MODULE__, fn s ->
case get_in(s.calendars, [username, name]) do
nil ->
{{:error, :not_found}, s}
cal ->
updated =
Enum.reduce(props, cal, 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, [:calendars, username, name], updated)
{{:ok, to_calendar_map(name, updated, %{})}, s}
end
end)
end
@impl true
def delete_calendar(username, name) do
Agent.get_and_update(__MODULE__, fn s ->
if get_in(s.calendars, [username, name]) do
s =
s
|> update_in([:calendars, username], &Map.delete(&1, name))
|> drop_path([:objects, username, name])
|> drop_path([:tombstones, username, name])
{:ok, s}
else
{{:error, :not_found}, s}
end
end)
end
@impl true
def get_object(username, cal_name, obj_name) do
Agent.get(__MODULE__, fn s ->
get_in(s.objects, [username, cal_name, obj_name])
|> case do
nil -> nil
o -> to_object_map(obj_name, o)
end
end)
end
@impl true
def put_object(username, cal_name, obj_name, ical) do
{uid, component} = ExDav.ICal.summarize(ical)
etag = ExDav.ICal.etag(ical)
Agent.get_and_update(__MODULE__, fn s ->
case get_in(s.calendars, [username, cal_name]) do
nil ->
{{:error, :not_found}, s}
cal ->
new_version = cal.ctag + 1
obj = %{
ical: ical,
etag: etag,
uid: uid,
component: component,
version: new_version
}
s =
s
|> put_in_path([:objects, username, cal_name, obj_name], obj)
|> put_in_path([:calendars, username, cal_name, :ctag], new_version)
|> drop_path([:tombstones, username, cal_name, obj_name])
{{:ok, to_object_map(obj_name, obj)}, s}
end
end)
end
@impl true
def delete_object(username, cal_name, obj_name) do
Agent.get_and_update(__MODULE__, fn s ->
with cal when is_map(cal) <- get_in(s.calendars, [username, cal_name]),
obj when is_map(obj) <- get_in(s.objects, [username, cal_name, obj_name]) do
new_version = cal.ctag + 1
s =
s
|> update_in([:objects, username, cal_name], &Map.delete(&1, obj_name))
|> put_in_path([:calendars, username, cal_name, :ctag], new_version)
|> put_in_path([:tombstones, username, cal_name, obj_name], new_version)
{:ok, s}
else
_ -> {{:error, :not_found}, s}
end
end)
end
@impl true
def sync_changes(username, cal_name, since) do
Agent.get(__MODULE__, fn s ->
case get_in(s.calendars, [username, cal_name]) do
nil ->
{:error, :not_found}
cal ->
objs = get_in(s.objects, [username, cal_name]) || %{}
tombs = get_in(s.tombstones, [username, cal_name]) || %{}
changed =
objs
|> Enum.filter(fn {_, o} -> since == nil or o.version > since end)
|> Enum.map(fn {name, o} -> to_object_map(name, o) 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, cal.ctag}
end
end)
end
# ---- internals ------------------------------------------------------------
defp to_calendar_map(name, cal, objs) do
%{
name: name,
displayname: cal.displayname || name,
description: cal.description,
components: cal.components || ["VEVENT", "VTODO"],
ctag: cal.ctag,
objects: for({n, o} <- objs, into: %{}, do: {n, to_object_map(n, o)})
}
end
defp to_object_map(name, o) do
%{name: name, ical: o.ical, etag: o.etag, uid: o.uid, component: o.component}
end
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