lib/money/input/visualizer/standalone.ex

# `Money.Input.Visualizer.Standalone` runs the visualizer as a
# self-contained Bandit web server. It is only defined when
# `:bandit` is available (which transitively pulls in `:plug`),
# so consumer apps that don't need a standalone dev server
# don't get compiler warnings about missing modules. When
# `:bandit` isn't installed, this module simply doesn't exist
# and any call to it raises the standard
# `UndefinedFunctionError`.
if Code.ensure_loaded?(Bandit) do
  defmodule Money.Input.Visualizer.Standalone do
    @moduledoc """
    A tiny helper that runs `Money.Input.Visualizer` as a
    standalone web server for local development.

    Requires `:bandit` in your project's deps. This module is
    only compiled when `:bandit` is present — without it,
    `Money.Input.Visualizer.Standalone` doesn't exist and
    calling its functions raises `UndefinedFunctionError`.

        Money.Input.Visualizer.Standalone.start(port: 4002)
        # Visit http://localhost:4002

    To stop the server, call
    `Money.Input.Visualizer.Standalone.stop/1` with the PID
    returned from `start/1`.

    ## Enable flag

    For safety, this helper refuses to start unless the
    visualizer has been enabled — either via config:

        config :ex_money_input, visualizer: true

    …or by passing `enabled: true` to `start/1` explicitly. The
    intent is to make accidental deployment of a developer tool
    to production loud rather than silent. Mounting
    `Money.Input.Visualizer` directly under `forward/2` in a host
    Phoenix router bypasses this — that path is up to the host
    to gate.

    """

    @doc """
    Starts the visualizer on the given port.

    ### Options

    * `:port` — TCP port to listen on. Default `4002`.

    * `:ip` — IP address to bind to. Default `:loopback` (only
      accessible from localhost). Pass `:any` to bind on all
      interfaces.

    * `:enabled` — when `true`, override the
      `:ex_money_input, :visualizer` config check. Default `false`.

    ### Returns

    * `{:ok, pid}` on success.

    * `{:error, %Money.Input.VisualizerDisabledError{}}` when
      the visualizer has not been enabled. See the module doc.

    * `{:error, reason}` on other failures (e.g. port-in-use).

    ### Examples

        iex> Application.put_env(:ex_money_input, :visualizer, true)
        iex> {:ok, pid} = Money.Input.Visualizer.Standalone.start(port: 0)
        iex> :ok = Money.Input.Visualizer.Standalone.stop(pid)

    """
    @spec start(keyword()) :: {:ok, pid()} | {:error, term()}
    def start(options \\ []) do
      if enabled?(options) do
        port = Keyword.get(options, :port, 4002)
        ip = Keyword.get(options, :ip, :loopback)

        Bandit.start_link(
          plug: Money.Input.Visualizer,
          port: port,
          ip: ip_tuple(ip)
        )
      else
        {:error, Money.Input.VisualizerDisabledError.exception([])}
      end
    end

    @doc """
    Returns a child spec suitable for embedding under a
    supervision tree.

    Honours the same enable-flag rules as `start/1`. When the
    flag is off, the child spec is a no-op task that exits
    `:normal`, so a supervisor with a permanent restart strategy
    won't keep retrying.

    ### Options

    See `start/1`.

    ### Returns

    * A child specification map.

    """
    @spec child_spec(keyword()) :: Supervisor.child_spec()
    def child_spec(options \\ []) do
      if enabled?(options) do
        port = Keyword.get(options, :port, 4002)
        ip = Keyword.get(options, :ip, :loopback)

        %{
          id: __MODULE__,
          start:
            {Bandit, :start_link, [[plug: Money.Input.Visualizer, port: port, ip: ip_tuple(ip)]]},
          type: :supervisor
        }
      else
        %{
          id: __MODULE__,
          start: {Task, :start_link, [fn -> :ok end]},
          restart: :temporary,
          type: :worker
        }
      end
    end

    @doc """
    Stops a standalone server started by `start/1`.

    ### Arguments

    * `pid` — the process identifier returned by `start/1`.

    ### Returns

    * `:ok`.

    """
    @spec stop(pid()) :: :ok
    def stop(pid) when is_pid(pid) do
      _ = Supervisor.stop(pid)
      :ok
    end

    @doc """
    Returns `true` when the visualizer is enabled via config or
    via the `:enabled` option.
    """
    @spec enabled?(keyword()) :: boolean()
    def enabled?(options \\ []) do
      cond do
        Keyword.get(options, :enabled) == true -> true
        Application.get_env(:ex_money_input, :visualizer) == true -> true
        true -> false
      end
    end

    # ---- helpers ------------------------------------------------------------

    defp ip_tuple(:loopback), do: {127, 0, 0, 1}
    defp ip_tuple(:any), do: {0, 0, 0, 0}
    defp ip_tuple({_, _, _, _} = tuple), do: tuple
  end
end