defmodule Skogsra do @moduledoc """ This library attempts to improve the use of OS environment variables and application configuration. You would create a settings module e.g: ```elixir defmodule Settings do use Skogsra # Rather equivalent to `System.get_env("POSTGRES_PORT") || 5432` (misses # the automatic casting to integer). Generates the function # `Settings.postgres_port/0`. @spec portgres_port() :: integer() system_env :postgres_port, default: 5432 # Equivalent to # ``` # System.get_env("POSTGRES_HOSTNAME") || # (Application.get_env(:my_app, MyApp.Repo, []) |> # Keyword.get(:hostname, "localhost")) # ``` # Generates the function Settings.postgres_hostname/0 @spec postgres_hostname() :: binary() app_env :postgres_hostname, :my_app, :hostname, domain: MyApp.Repo default: "localhost" end ``` It can be used in the configuration file as well e.g: ```elixir config :my_app, MyApp.Repo, adapter: Ecto.Adapters.Postgres, hostname: Skogsra.get_env("POSTGRES_HOSTNAME", "localhost"), port: Skogsra.get_env_as(:integer, "POSTGRES_PORT", "5432"), (...) ``` Or from a module: ```elixir defmodule MyApp do @port Skogsra.get_app_env :my_app, :port, domain: MyApp.Repo, default: 5432, name: "POSTGRES_PORT" @pool_size Skogsra.get_app_env :my_app, :pool_size, domain: [MyApp.Pool, :pool_options], default: 5 name: "POOL_SIZE" @spec get_port() :: integer() def get_port, do: @port @spec get_pool_size() :: integer() def get_pool_size, do: @pool_size end ``` """ require Logger @doc """ For now is just equivalent to use `import Skogsra`. """ defmacro __using__(_) do quote do import Skogsra end end @doc """ Macro that receives the `name` of the OS environment variable and some optional `Keyword` list with `options` and generates a function with arity 0 with the same name of the OS environment variable, but in lower case e.g. `"FOO"` would generate the function `foo/0`. The available options are: - `:static` - Whether the computation of the OS environment variable is done on compiling time or not. By default its value is `false`. - `:default` - Default value in case the OS environment variable doesn't exist. By default is `nil`. - `:type` - The type of the OS environment variable. By default is the type of the default value. If there is no default value, the default type is `binary`. The available types are `:binary`, `:integer`, `:float`, `:boolean` and `:atom`. e.g ```elixir defmodule Settings do use Skogsra system_env :foo, default: 42, type: :integer end ``` This would generate the function `Settings.foo/0` that would search for the OS environment variable `"FOO"` and cast it to integer on runtime and defaults to `42`. """ defmacro system_env(name, opts \\ []) do if Keyword.get(opts, :static, false) do Skogsra.static_system_env(name, opts) else Skogsra.runtime_system_env(name, opts) end end @doc """ Macro that receives the `name` of the OS environment variable, the name of the `app`, the name of the option `key` and some optional `Keyword` list with `options` and generates a function with arity 0 with the same name of the OS environment variable, but in lower case e.g. `"FOO"` would generate the function `foo/0`. The available options are: - `:static` - Whether the computation of the OS environment variable or application configuration option is done on compiling time or not. By default its value is `false`. - `:default` - Default value in case the OS environment variable and the application configuration option don't exist. By default is `nil`. - `:type` - The type of the OS environment variable. By default is the type of the default value. If there is no default value, the default type is `binary`. The available types are `:binary`, `:integer`, `:float`, `:boolean` and `:atom`. - `:domain` - The `key` to search in the configuration file e.g in: ```elixir config :my_app, MyApp.Repo, adapter: Ecto.Adapters.Postgres, (...) ``` the domain would be `MyApp.Repo`. By default, there is no domain. e.g ```elixir defmodule Settings do use Skogsra app_env :foo, :my_app, :foo, default: 42, type: :integer, domain: MyApp.Domain end ``` This would generate the function `Settings.foo/0` that would search for the OS environment variable `"FOO"` and cast it to integer on runtime. If the OS environment variable is not found, attempts to search for the `:foo` configuration option for the application `:my_app` and the domain `MyApp.Domain`. If nothing is found either, it defaults to `42`. Calling `Settings.foo/0` without a domain set is equivalent to: ```elixir with value when not is_nil(value) <- System.get_env("FOO"), {number, _} <- Integer.parse(value) do number else _ -> Application.get_env(:my_app, :foo, 42) end ``` Calling `Settings.foo/0` With a domain set is equivalent to: ```elixir with value when not is_nil(value) <- System.get_env("FOO"), {number, _} <- Integer.parse(value) do number else _ -> opts = Application.get_env(:my_app, MyApp.Domain, []) Keyword.get(opts, :foo, 42) end ``` """ defmacro app_env(name, app, key, opts \\ []) do if Keyword.get(opts, :static, false) do Skogsra.static_app_env(name, app, key, opts) else Skogsra.runtime_app_env(name, app, key, opts) end end @doc """ Gets the OS environment variable by its `name` and cast it the the type of the `default` value. If no `default` value is provided, returns a string. If the OS environment variable is not found, returns the `default` value. ```elixir config :my_app, MyApp.Repo, adapter: Ecto.Adapters.Postgres, hostname: Skogsra.get_env("POSTGRES_HOSTNAME", "localhost"), (...) ``` """ @spec get_env(binary) :: term() @spec get_env(binary, term) :: term() def get_env(name, default \\ nil) when is_binary(name) do default |> type?() |> get_env_as(name, default) end @doc """ Gets the OS environment variable by its `name` and casts it to the provided `type`. If the OS environment variable is not found, returns the `default` value. ```elixir config :my_app, MyApp.Repo, adapter: Ecto.Adapters.Postgres, hostname: Skogsra.get_env("POSTGRES_HOSTNAME", "localhost"), port: Skogsra.get_env_as(:integer, "POSTGRES_PORT", 5432), (...) ``` """ @spec get_env_as(atom(), binary()) :: term() @spec get_env_as(atom(), binary(), term()) :: term() def get_env_as(type, name, default \\ nil) when is_binary(name) do default = cast_default(default, type) with {:ok, value} <- do_get_env_as(type, name, default) do value else {:error, message} -> Logger.error(message) default end end @doc """ Gets the OS environment variable by its name if present in the option `:name`. If it's not found, attempts to get the application configuration option by the `app` name and the option `key`. Optionally receives a `Keyword` list of `options` i.e: - `:name` - Name of the OS environment variable. By default is `""`. - `:default` - Default value in case the OS environment variable and the application configuration option don't exist. By default is `nil`. - `:type` - The type of the OS environment variable. By default is the type of the default value. If there is no default value, the default type is `binary`. The available types are `:binary`, `:integer`, `:float`, `:boolean` and `:atom`. - `:domain` - The `key` to search in the configuration file e.g in: ```elixir config :my_app, MyApp.Repo, adapter: Ecto.Adapters.Postgres, hostname: "localhost", (...) ``` the domain would be `MyApp.Repo`. By default, there is no domain. If the domain is a list of atoms forces the algorithm to go recursively in the structure to find the key. e.g: ```elixir defmodule MyApp do def get_hostname do get_app_env(:my_app, :hostname, domain: MyApp.Repo, name: "POSTGRES_HOSTNAME" ) end end ``` """ @spec get_app_env(atom(), atom()) :: term() @spec get_app_env(atom(), atom(), list()) :: term() def get_app_env(app, key, options \\ []) do name = Keyword.get(options, :name, "") default = Keyword.get(options, :default, nil) type = Keyword.get(options, :type, type?(default)) with default = cast_default(default, type), value when is_nil(value) <- get_env_as(type, name), domain = Keyword.get(options, :domain), {:ok, value} <- do_get_app_env(type, app, domain, key, default) do value else {:error, _} -> default value -> value end end ######### # Helpers @doc false def runtime_system_env(name, opts) do default = Keyword.get(opts, :default) type = Keyword.get(opts, :type, type?(default)) env_name = name |> Atom.to_string() |> String.upcase() quote do def unquote(name)() do Skogsra.get_env_as(unquote(type), unquote(env_name), unquote(default)) end end end @doc false def static_system_env(name, opts) do default = Keyword.get(opts, :default) type = Keyword.get(opts, :type, type?(default)) env_name = name |> Atom.to_string() |> String.upcase() value = Skogsra.get_env_as(type, env_name, default) quote do def unquote(name)(), do: unquote(value) end end @doc false def runtime_app_env(name, app, key, opts) do env_name = name |> Atom.to_string() |> String.upcase() quote do def unquote(name)() do Skogsra.get_app_env( unquote(app), unquote(key), [{:name, unquote(env_name)} | unquote(opts)] ) end end end @doc false def static_app_env(name, app, key, opts) do env_name = name |> Atom.to_string() |> String.upcase() value = Skogsra.get_app_env(app, key, [{:name, env_name} | opts]) quote do def unquote(name)(), do: unquote(value) end end @doc false def do_get_app_env(type, app, nil, key, default) do value = Application.get_env(app, key, default) cast(value, type) end def do_get_app_env(type, app, domain, key, default) when is_atom(domain) do do_get_app_env(type, app, [domain], key, default) end def do_get_app_env(type, app, [domain | domains], key, default) do case Application.get_env(app, domain) do nil -> {:ok, default} opts -> value = do_get_app_env(opts, domains, key, default) cast(value, type) end end @doc false def do_get_app_env(opts, [], key, default) do Keyword.get(opts, key, default) end def do_get_app_env(opts, [domain | domains], key, default) do case Keyword.get(opts, domain) do nil -> default new_opts -> do_get_app_env(new_opts, domains, key, default) end end @doc false def do_get_env_as(type, name, default \\ nil) def do_get_env_as(_, "", default), do: {:ok, default} def do_get_env_as(type, name, default) do value = System.get_env(name) if is_nil(value), do: {:ok, default}, else: cast(value, type) end @doc false def type?(nil), do: :any def type?(value) when is_integer(value), do: :integer def type?(value) when is_float(value), do: :float def type?(value) when is_boolean(value), do: :boolean def type?(value) when is_atom(value), do: :atom def type?(_), do: :any @doc false def cast_default(default, type) do with {:ok, default} <- cast(default, type) do default else _ -> nil end end @doc false def cast(value, :integer) when is_binary(value) do value = String.trim(value) with {number, ""} <- Integer.parse(value) do {:ok, number} else {_, rest} -> {:error, "#{rest} from #{value} couldn't be casted to integer"} :error -> {:error, "Cannot cast #{inspect value} to integer"} end end def cast(value, :float) when is_binary(value) do value = String.trim(value) with {number, ""} <- Float.parse(value) do {:ok, number} else {_, rest} -> {:error, "#{rest} from #{value} couldn't be casted to float"} :error -> {:error, "Cannot cast #{inspect value} to float"} end end def cast(value, :boolean) when is_binary(value) do real_value = value |> String.downcase() |> String.trim() |> String.to_atom() if is_boolean(real_value) do {:ok, real_value} else {:error, "Cannot cast #{inspect value} to boolean"} end end def cast(value, :atom) when is_binary(value) do {:ok, String.to_atom(value)} end def cast(value, _), do: {:ok, value} end