lib/skogsra/provider/json.ex

if Code.ensure_loaded?(Config.Provider) and Code.ensure_loaded?(Jason) do
  defmodule Skogsra.Provider.Json do
    @moduledoc """
    This module defines a JSON config provider for Skogsra.

    > **Important**: You need to add `{:jason, "~> 1.1"}` as a dependency
    > along with `Skogsra` because the dependency is optional.

    The following is an example for `Ecto` configuration in a file called
    `/etc/my_app/config.yml`:

    ```json
    [
      {
        "app": "my_app",
        "module": "MyApp.Repo",
        "config": {
          "database": "my_app_db",
          "username": "postgres",
          "password": "postgres",
          "hostname": "localhost",
          "port": 5432
        }
      }
    ]
    ```

    Then in your release configuration you can add the following:

    ```elixir
    config_providers: [{Skogsra.Provider.Json, ["/etc/my_app/config.json"]}]
    ```

    Once the system boots, it'll parse and add the JSON configuration.
    """
    @behaviour Config.Provider

    require Logger

    ###########
    # Callbacks

    @impl Config.Provider
    def init(path) when is_binary(path) do
      path
    end

    @impl Config.Provider
    def load(config, path) do
      {:ok, _} = Application.ensure_all_started(:logger)
      {:ok, _} = Application.ensure_all_started(:jason)

      with {:ok, contents} <- File.read(path),
           {:ok, parsed} <- Jason.decode(contents),
           {:ok, new_config} <- load_config(parsed) do
        Config.Reader.merge(config, new_config)
      else
        {:error, reason} ->
          Logger.warning(
            "File #{path} cannot be read/loaded " <>
              "due to #{inspect(reason)}"
          )

          config
      end
    end

    #########
    # Helpers

    # Loads a JSON config from a list of maps.
    @spec load_config([map()]) :: {:ok, keyword()} | {:error, term()}
    @spec load_config([map()], list()) :: {:ok, keyword()} | {:error, term()}
    defp load_config(maps, acc \\ [])

    defp load_config([], acc) do
      config =
        acc
        |> Enum.reverse()
        |> List.flatten()

      {:ok, config}
    end

    defp load_config([json | rest], acc) do
      with {:ok, config} <- load_app_config(json) do
        load_config(rest, [config | acc])
      end
    end

    # Loads an app config from a YAML parsed document.
    @spec load_app_config(map()) :: {:ok, keyword()} | {:error, term()}
    defp load_app_config(config) do
      with {:ok, app} <- get_app(config),
           {:ok, namespace} <- get_namespace(config),
           {:ok, module} <- get_module(config),
           {:ok, app_config} <- get_config(config) do
        module = module || namespace

        if is_nil(module) do
          {:ok, [{app, app_config}]}
        else
          {:ok, [{app, [{module, app_config}]}]}
        end
      end
    end

    # Gets the name of an app.
    @spec get_app(map()) :: {:ok, atom()} | {:error, term()}
    defp get_app(config) when is_map(config) do
      value =
        config
        |> Map.get("app")
        |> String.to_atom()

      {:ok, value}
    rescue
      _ ->
        {:error, "Name of the app is invalid"}
    end

    # Gets namespace for an app.
    @spec get_namespace(map()) :: {:ok, module()} | {:error, term()}
    defp get_namespace(config) do
      case Map.get(config, "namespace") do
        nil ->
          {:ok, nil}

        value ->
          value =
            value
            |> String.split(~r/\./)
            |> Module.concat()

          {:ok, value}
      end
    rescue
      _ ->
        {:error, "Namespace is invalid"}
    end

    # Gets module to be configured.
    @spec get_module(map()) :: {:ok, module()} | {:error, term()}
    defp get_module(config) do
      case Map.get(config, "module") do
        nil ->
          {:ok, nil}

        value ->
          value =
            value
            |> String.split(~r/\./)
            |> Module.safe_concat()

          {:ok, value}
      end
    rescue
      _ ->
        {:error, "Module is invalid"}
    end

    # Gets config key for an app.
    @spec get_config(map()) :: {:ok, keyword()} | {:error, term()}
    defp get_config(config) do
      value =
        config
        |> Map.get("config")
        |> Enum.map(&expand_variable/1)
        |> List.flatten()

      {:ok, value}
    rescue
      _ ->
        {:error, "Config is invalid"}
    end

    # Expands a variable.
    @spec expand_variable({binary(), term()}) :: {atom(), term()}
    defp expand_variable({key, value}) do
      key = String.to_atom(key)
      value = expand_value(value)

      {key, value}
    end

    # Expands a value
    @spec expand_value(term()) :: term()
    defp expand_value(value)

    defp expand_value(values) when is_map(values) do
      values
      |> Enum.map(&expand_variable/1)
      |> List.flatten()
    end

    defp expand_value(value) do
      value
    end
  end
end