lib/kungfuig.ex

defmodule Kungfuig do
  @moduledoc """
  The behaviour defining the dynamic config provider.

  `Kungfuig` provides a plugagble drop-in support for live configurations.

  ### Examples

      Kungfuig.Supervisor.start_link()
      Kungfuig.config()
      #⇒ %{env: %{kungfuig: []}, system: %{}}

      Kungfuig.config(:env)
      #⇒ %{kungfuig: []}

      Application.put_env(:kungfuig, :foo, 42)
      Kungfuig.config(:env)
      #⇒ %{kungfuig: [foo: 42]}

  The configuration is frequently updated.
  """

  @typedoc "The config map to be updated and exposed through callback"
  @type config :: %{required(atom()) => term()}

  @typedoc """
  The callback to be used for subscibing to config updates.

  Might be an anonymous function, an `{m, f}` tuple accepting a single argument,
  or a process identifier accepting `call`, `cast` or simple message (`:info`.)
  """
  @type callback ::
          {module(), atom()}
          | (config() -> :ok)
          | {GenServer.name() | pid(), {:call | :cast | :info, atom()}}

  @typedoc "The option that can be passed to `start_link/1` function"
  @type option ::
          {:callback, callback()}
          | {:interval, non_neg_integer()}
          | {:anonymous, boolean()}
          | {:start_options, [GenServer.option()]}
          | {atom(), term()}

  @typedoc "The `start_link/1` function wrapping the `GenServer.start_link/3`"
  @callback start_link(opts :: [option()]) :: GenServer.on_start()

  @doc "The actual implementation to update the config"
  @callback update_config(state :: t()) :: config()

  @doc "The config that is manages by this behaviour is a simple map"
  @type t :: %{
          __struct__: Kungfuig,
          __meta__: [option()],
          __previous__: config(),
          state: config()
        }

  defstruct __meta__: [], __previous__: %{}, state: %{}

  @doc false
  @spec __using__(opts :: [option()]) :: tuple()
  defmacro __using__(opts) do
    quote location: :keep, generated: true, bind_quoted: [opts: opts] do
      {anonymous, opts} = Keyword.pop(opts, :anonymous, false)

      use GenServer
      @behaviour Kungfuig

      @impl Kungfuig
      def start_link(opts) do
        opts = Keyword.merge(unquote(opts), opts)

        {start_options, opts} = Keyword.pop(opts, :start_options, [])

        opts =
          opts
          |> Keyword.put_new(:imminent, false)
          |> Keyword.put_new(:interval, 1_000)
          |> Keyword.put_new(:validator, Kungfuig.Validators.Void)

        case Keyword.get_values(opts, :callback) do
          [{target, {type, name}} | _] when type in [:call, :cast, :info] and is_atom(name) -> :ok
          [{m, f} | _] when is_atom(m) and is_atom(f) -> :ok
          [f | _] when is_function(f, 1) -> :ok
          [] -> :ok
          other -> raise "Expected callable, got: " <> inspect(other)
        end

        start_options =
          if unquote(anonymous),
            do: Keyword.delete(start_options, :name),
            else: Keyword.put_new(start_options, :name, __MODULE__)

        GenServer.start_link(__MODULE__, %Kungfuig{__meta__: opts}, start_options)
      end

      @impl Kungfuig
      def update_config(%Kungfuig{state: state}), do: state

      defoverridable Kungfuig

      @impl GenServer
      def init(%Kungfuig{__meta__: meta} = state) do
        if meta[:imminent] == true do
          {:noreply, state} = handle_continue(:update, state)
          {:ok, state}
        else
          {:ok, state, {:continue, :update}}
        end
      end

      @impl GenServer
      def handle_info(:update, %Kungfuig{} = state),
        do: {:noreply, state, {:continue, :update}}

      @impl GenServer
      def handle_continue(
            :update,
            %Kungfuig{__meta__: opts, __previous__: previous, state: state} = config
          ) do
        state =
          with new_state <- update_config(config),
               {:ok, new_state} <- smart_validate(opts[:validator], new_state) do
            if previous != new_state,
              do: opts |> Keyword.get_values(:callback) |> send_callback(new_state)

            new_state
          else
            {:error, error} ->
              report_error(error)
              state
          end

        Process.send_after(self(), :update, opts[:interval])
        {:noreply, %Kungfuig{config | __previous__: state, state: state}}
      end

      @impl GenServer
      def handle_call(:state, _from, %Kungfuig{} = state),
        do: {:reply, state, state}

      defp send_callback(nil, _state), do: :ok

      defp send_callback(many, state) when is_list(many),
        do: Enum.each(many, &send_callback(&1, state))

      defp send_callback({target, {:info, m}}, state),
        do: send(target, {m, state})

      defp send_callback({target, {type, m}}, state),
        do: apply(GenServer, type, [target, {m, state}])

      defp send_callback({m, f}, state), do: apply(m, f, [state])
      defp send_callback(f, state) when is_function(f, 1), do: f.(state)

      @spec smart_validate(validator :: nil | module(), options :: map() | keyword()) ::
              {:ok, keyword()} | {:error, any()}
      defp smart_validate(nil, options), do: {:ok, options}
      defp smart_validate(Kungfuig.Validators.Void, options), do: {:ok, options}
      defp smart_validate(validator, options), do: validator.validate(options)

      @spec report_error(error :: any()) :: :ok
      defp report_error(_error), do: :ok

      defoverridable report_error: 1, smart_validate: 2
    end
  end

  @spec config(which :: atom() | [atom()], supervisor :: Supervisor.supervisor()) ::
          Kungfuig.t()
  def config(which \\ :!, supervisor \\ Kungfuig.Supervisor) do
    result =
      supervisor
      |> Supervisor.which_children()
      |> Enum.find(&match?({_, _, :worker, _}, &1))
      |> case do
        {_blender, pid, :worker, _} when is_pid(pid) ->
          GenServer.call(pid, :state)

        other ->
          raise inspect(other, label: "No Blender configured: ")
      end

    case which do
      nil -> result.state
      :! -> result.state
      which when is_atom(which) -> Map.get(result.state, which, %{})
      which when is_list(which) -> Map.take(result.state, which)
    end
  end
end