defmodule CMS do @moduledoc """ TODO ## Examples ### Lookup a page by path defmodule MyApp.Page do use CMS, lookup_keys: [:path] # This is an example of what a document from Sanity CMS might look like. @dummy_result %{ _id: "page-1", display_order: 2, path: %{ current: "/" } } @impl true def fetch_by([{:path, path}]) do # Make an API call to the headless CMS and return document... case path do "/" -> {:ok, @dummy_result} _ -> {:error, :not_found} end end @impl true def list do # Make an API call to the headless CMS and return documents... [ @dummy_result # ... ] end @impl true def lookup_key(:path, item), do: item.path.current @impl true def primary_key(item), do: item._id end To look up a page by path: iex> CMS.get_by!(MyApp.Page, path: "/") %{_id: "page-1", display_order: 2, path: %{current: "/"}} ### List blog posts defmodule MyApp.Post do use CMS, list_keys: [:display_order] @impl true def list do # Make an API call to the headless CMS and return document... [ %{ _id: "post-1", display_order: 2 }, %{ _id: "post-2", display_order: 1 } ] end @impl true def order_by(:display_order, items), do: Enum.sort_by(items, & &1.display_order) @impl true def primary_key(item), do: item._id end To list pages: iex> CMS.list_by(MyApp.Post, :display_order) [%{_id: "post-2", display_order: 1}, %{_id: "post-1", display_order: 2}] """ @doc """ Fetches a single CMS document given one or more keys. Generally implementations of this callback will make an API call to the headless CMS. Returns `{:ok, doc}` or `{:error, :not_found}`. This callback is optional and is only needed if you intend to call `CMS.get_by/2` or `CMS.get_by!/2` without having initialized the cache. """ @callback fetch_by(Keyword.t()) :: {:ok, map()} | {:error, :not_found} @doc """ Returns a list of all CMS documents. Generally implementations of this callback will make an API call to the headless CMS. """ @callback list() :: [map()] @doc """ Returns the lookup key for a document given a key name and a CMS document. Only required if you will be looking up documents by key. See `CMS.get_by/2` and `CMS.get_by!/2`. """ @callback lookup_key(atom(), map()) :: any() @doc """ Orders documents by list key. Only required if you will be listing documents using `CMS.list_by/3`. """ @callback order_by(atom(), [map()]) :: [any()] @doc """ Returns the primary key of the given CMS document. """ @callback primary_key(map()) :: atom() @optional_callbacks fetch_by: 1, lookup_key: 2, order_by: 2 alias CMS.{CacheServer, NotFoundError} require Logger @using_opts_validation [ list_keys: [ type: {:list, :atom}, default: [] ], lookup_keys: [ type: {:list, :atom}, default: [] ] ] defmacro __using__(opts) do quote do opts = NimbleOptions.validate!(unquote(opts), unquote(@using_opts_validation)) @behaviour CMS @cms_list_keys Keyword.fetch!(opts, :list_keys) @cms_lookup_keys Keyword.fetch!(opts, :lookup_keys) def child_spec(opts \\ []) do opts = Keyword.put(opts, :module, __MODULE__) %{id: __MODULE__, start: {CMS.Updater, :start_link, [opts]}} end @doc false def __cms_list_keys__, do: @cms_list_keys @doc false def __cms_lookup_keys__, do: @cms_lookup_keys end end @doc """ TODO """ def get_by(mod, [{name, value}]) do case CacheServer.fetch(lookup_table(mod, name), value) do {:ok, primary_key} -> {:ok, item} = CacheServer.fetch(mod, primary_key) {:ok, item} {:error, :not_found} -> {:error, :not_found} {:error, :no_table} -> mod.fetch_by([{name, value}]) end end @doc """ TODO """ def get_by!(mod, pair) do case get_by(mod, pair) do {:ok, value} -> value {:error, :not_found} -> raise NotFoundError, "could not find result for #{inspect(pair)}" end end @list_by_opts_validation [ range: [ type: {:custom, __MODULE__, :validate_range, []} ] ] @doc """ TODO docs ## Options #{NimbleOptions.docs(@list_by_opts_validation)} """ def list_by(mod, name, opts \\ []) do opts = NimbleOptions.validate!(opts, @list_by_opts_validation) list_table = list_table(mod, name) case CacheServer.fetch(list_table, 0) do {:ok, _} -> Keyword.get(opts, :range, Stream.unfold(0, &{&1, &1 + 1})) |> Stream.map(fn i -> CacheServer.fetch(list_table, i) end) |> Enum.take_while(fn {:ok, _} -> true {:error, :not_found} -> false end) |> Enum.map(fn {:ok, primary_key} -> {:ok, item} = CacheServer.fetch(mod, primary_key) item end) {:error, :not_found} -> [] {:error, :no_table} -> # Results not cached so we need to make a request to the CMS to fetch them. The efficient # way to do this would be to add a mod.list_by callback that queries the CMS such that the # CMS will return the results sorted and paginated. I'm not worried about optimizing this # since all results will come from the cache in production. Instead, fetch all results and # paginate them manually in this function. all_items = mod.order_by(name, mod.list()) case Keyword.fetch(opts, :range) do {:ok, range} -> Enum.slice(all_items, range) :error -> all_items end end end @doc false def validate_range(%Range{} = range), do: {:ok, range} def validate_range(value), do: {:error, "not a range: #{inspect(value)}"} @update_opts_validation [ update_all_nodes: [ type: :boolean, default: false, doc: "If `true` then update will be sent to all Erlang nodes in cluster." ] ] @doc """ TODO docs ## Options #{NimbleOptions.docs(@update_opts_validation)} """ def update(mod, opts \\ []) do opts = NimbleOptions.validate!(opts, @update_opts_validation) items = mod.list() pairs = Enum.map(items, fn item -> {mod.primary_key(item), item} end) lookup_tables = Enum.map(mod.__cms_lookup_keys__(), fn name -> lookup_pairs = Enum.map(pairs, fn {primary_key, item} -> {mod.lookup_key(name, item), primary_key} end) {lookup_table(mod, name), lookup_pairs} end) list_tables = Enum.map(mod.__cms_list_keys__(), fn name -> list_pairs = mod.order_by(name, items) |> Enum.with_index() |> Enum.map(fn {item, index} -> {index, mod.primary_key(item)} end) {list_table(mod, name), list_pairs} end) tables = [{mod, pairs}] ++ list_tables ++ lookup_tables if Keyword.fetch!(opts, :update_all_nodes) do Logger.info("updating CMS content on all nodes") case CacheServer.put_tables_on_all_nodes(tables) do {_, []} -> :ok {_, bad_nodes} -> Logger.error("failed to update nodes: #{inspect(bad_nodes)}") :error end else Logger.info("updating CMS content on current node") CacheServer.put_tables(tables) :ok end end defp list_table(mod, name) do unless name in mod.__cms_list_keys__() do raise ArgumentError, "invalid list key #{inspect(name)}; allowed values are #{inspect(mod.__cms_list_keys__())}" end :"#{mod}.ListBy#{name |> Atom.to_string() |> Macro.camelize()}" end defp lookup_table(mod, name) do unless name in mod.__cms_lookup_keys__() do raise ArgumentError, "invalid lookup key #{inspect(name)}; allowed values are #{inspect(mod.__cms_lookup_keys__())}" end :"#{mod}.By#{name |> Atom.to_string() |> Macro.camelize()}" end end