lib/config_cat/hooks.ex

defmodule ConfigCat.Hooks do
  @moduledoc """
  Subscribe to events fired by the SDK.

  Hooks are callback functions that are called by the SDK when certain events
  happen. Client applications can register more than one callback for each hook.

  Callbacks are called within the same process that generated the event. Any
  exceptions that are raised by a callback are rescued, logged, and reported to
  any registered `on_error` callbacks.

  The following callbacks are available:
  - `on_client_ready`: This event is sent when the SDK reaches the ready state.
    If the SDK is set up with lazy load or manual polling it's considered ready
    right after instantiation. If it's using auto polling, the ready state is
    reached when the SDK has a valid config JSON loaded into memory either from
    cache or from HTTP.
  - `on_config_changed(config: map())`: This event is sent when the SDK loads a
    valid config JSON into memory from cache, and each subsequent time when the
    loaded config JSON changes via HTTP.
  - `on_flag_evaluated(evaluation_details: EvaluationDetails.t())`: This event
    is sent each time when the SDK evaluates a feature flag or setting. The
    event sends the same evaluation details that you would get from
    get_value_details.
  - on_error(error: String.t()): This event is sent when an error occurs within the
    ConfigCat SDK.
  """
  use GenServer

  alias ConfigCat.Config
  alias ConfigCat.EvaluationDetails
  alias ConfigCat.Hooks.Impl

  defmodule State do
    @moduledoc false
    use TypedStruct

    typedstruct do
      field :impl, Impl.t()
      field :instance_id, ConfigCat.instance_id(), enforce: true
    end

    @spec new(keyword()) :: t()
    def new(options \\ []) do
      hooks = Keyword.get(options, :hooks, [])

      struct!(__MODULE__, impl: Impl.new(hooks), instance_id: options[:instance_id])
    end

    @spec with_impl(t(), Impl.t()) :: t()
    def with_impl(%__MODULE__{} = state, %Impl{} = impl) do
      %{state | impl: impl}
    end
  end

  @typedoc """
  A hook callback is either an anonymous function or a module/function name/extra_arguments tuple.

  Each callback is passed specific arguments. These specific arguments are
  prepended to the extra arguments provided in the tuple (if any).

  For example, you might want to define a callback that sends a message to
  another process which the config changes. You can pass the pid of that process
  as an extra argument:

  ```elixir
  def MyModule do
    def subscribe_to_config_changes(subscriber_pid) do
      ConfigCat.hooks()
      |> ConfigCat.Hooks.add_on_config_changed({__MODULE__, :on_config_changed, [subscriber_pid]})
    end

    def on_config_changed(config, pid) do
      send pid, {:config_changed, config}
    end
  end
  ```
  """
  @type named_callback :: {module(), atom(), list()}
  @type on_client_ready_callback :: (-> any()) | named_callback()
  @type on_config_changed_callback :: (Config.settings() -> any()) | named_callback()
  @type on_error_callback :: (String.t() -> any()) | named_callback()
  @type on_flag_evaluated_callback :: (EvaluationDetails.t() -> any()) | named_callback()
  @type option ::
          {:on_client_ready, on_client_ready_callback()}
          | {:on_config_changed, on_config_changed_callback()}
          | {:on_error, on_error_callback()}
          | {:on_flag_evaluated, on_flag_evaluated_callback()}
  @type start_option :: {:hooks, t()} | {:instance_id, ConfigCat.instance_id()}
  @opaque t :: ConfigCat.instance_id()

  @doc false
  @spec start_link([start_option()]) :: GenServer.on_start()
  def start_link(options) do
    instance_id = Keyword.fetch!(options, :instance_id)

    GenServer.start_link(__MODULE__, State.new(options), name: via_tuple(instance_id))
  end

  @doc """
  Add an `on_client_ready` callback.
  """
  @spec add_on_client_ready(t(), on_client_ready_callback()) :: t()
  def add_on_client_ready(instance_id, callback) do
    instance_id
    |> via_tuple()
    |> GenServer.call({:add_hook, :on_client_ready, callback})

    instance_id
  end

  @doc """
  Add an `on_config_changed` callback.
  """
  @spec add_on_config_changed(t(), on_config_changed_callback()) :: t()
  def add_on_config_changed(instance_id, callback) do
    instance_id
    |> via_tuple()
    |> GenServer.call({:add_hook, :on_config_changed, callback})

    instance_id
  end

  @doc """
  Add an `on_error` callback.
  """
  @spec add_on_error(t(), on_error_callback()) :: t()
  def add_on_error(instance_id, callback) do
    instance_id
    |> via_tuple()
    |> GenServer.call({:add_hook, :on_error, callback})

    instance_id
  end

  @doc """
  Add an `on_flag_evaluated` callback.
  """
  @spec add_on_flag_evaluated(t(), on_flag_evaluated_callback()) :: t()
  def add_on_flag_evaluated(instance_id, callback) do
    instance_id
    |> via_tuple()
    |> GenServer.call({:add_hook, :on_flag_evaluated, callback})

    instance_id
  end

  @doc false
  @spec invoke_on_client_ready(t()) :: :ok
  def invoke_on_client_ready(instance_id) do
    instance_id
    |> hooks()
    |> Impl.invoke_hook(:on_client_ready, [])
  end

  @doc false
  @spec invoke_on_config_changed(t(), Config.settings()) :: :ok
  def invoke_on_config_changed(instance_id, settings) do
    instance_id
    |> hooks()
    |> Impl.invoke_hook(:on_config_changed, [settings])
  end

  @doc false
  @spec invoke_on_error(t(), String.t()) :: :ok
  def invoke_on_error(instance_id, message) do
    instance_id
    |> hooks()
    |> Impl.invoke_hook(:on_error, [message])
  end

  @doc false
  @spec invoke_on_flag_evaluated(t(), EvaluationDetails.t()) :: :ok
  def invoke_on_flag_evaluated(instance_id, %EvaluationDetails{} = details) do
    instance_id
    |> hooks()
    |> Impl.invoke_hook(:on_flag_evaluated, [details])
  end

  defp hooks(instance_id) do
    instance_id
    |> via_tuple()
    |> GenServer.call(:hooks)
  end

  defp via_tuple(instance_id) do
    {:via, Registry, {ConfigCat.Registry, {__MODULE__, instance_id}}}
  end

  @impl GenServer
  def init(%State{} = state) do
    Logger.metadata(instance_id: state.instance_id)
    {:ok, state}
  end

  @impl GenServer
  def handle_call({:add_hook, hook, callback}, _from, %State{} = state) do
    new_impl = Impl.add_hook(state.impl, hook, callback)
    {:reply, :ok, State.with_impl(state, new_impl)}
  end

  @impl GenServer
  def handle_call(:hooks, _from, %State{} = state) do
    {:reply, state.impl, state}
  end
end