236 lines
		
	
	
		
			6.4 KiB
		
	
	
	
		
			Elixir
		
	
	
	
	
	
			
		
		
	
	
			236 lines
		
	
	
		
			6.4 KiB
		
	
	
	
		
			Elixir
		
	
	
	
	
	
defmodule Dotenv do
 | 
						|
  @moduledoc """
 | 
						|
  This module implements both an OTP application API and a "serverless" API.
 | 
						|
 | 
						|
  Server API
 | 
						|
  ==========
 | 
						|
 | 
						|
  Start the application with `start/2` On starting, it will automatically export
 | 
						|
  the environment variables in the default path (`.env`).
 | 
						|
 | 
						|
  The environment can then be reloaded with `reload!/0` or a specific path
 | 
						|
  or list of paths can be provided to `reload!/1`.
 | 
						|
 | 
						|
  Serverless API
 | 
						|
  ==============
 | 
						|
 | 
						|
  To use the serverless API, you can either load the environment variables with
 | 
						|
  `load!` (again, optionally passing in a path or list of paths), or you
 | 
						|
  can retrieve the variables without exporting them using `load`.
 | 
						|
  """
 | 
						|
 | 
						|
  use Application
 | 
						|
  alias Dotenv.Env
 | 
						|
 | 
						|
  def start(_type, env_path \\ :automatic) do
 | 
						|
    Dotenv.Supervisor.start_link(env_path)
 | 
						|
  end
 | 
						|
 | 
						|
  @quotes_pattern ~r/^(['"])(.*)\1$/
 | 
						|
  @pattern ~r/
 | 
						|
    \A
 | 
						|
    (?:export\s+)?    # optional export
 | 
						|
    ([\w\.]+)         # key
 | 
						|
    (?:\s*=\s*|:\s+?) # separator
 | 
						|
    (                 # optional value begin
 | 
						|
      '(?:\'|[^'])*?' #   single quoted value
 | 
						|
      |               #   or
 | 
						|
      "(?:\"|[^"])*?" #   double quoted value
 | 
						|
      |               #   or
 | 
						|
      [^#\n]+?        #   unquoted value
 | 
						|
    )?                # value end
 | 
						|
    (?:\s*\#.*)?      # optional comment
 | 
						|
    \z
 | 
						|
    /x
 | 
						|
 | 
						|
  # https://regex101.com/r/XrvCwE/1
 | 
						|
  @env_expand_pattern ~r/
 | 
						|
    (?:^|[^\\])                           # prevent to expand \\$
 | 
						|
    (                                     # get variable key pattern
 | 
						|
      \$                                  #
 | 
						|
      (?:                                 #
 | 
						|
        ([A-Z0-9_]*[A-Z_]+[A-Z0-9_]*)     # get variable key
 | 
						|
        |                                 #
 | 
						|
        (?:                               #
 | 
						|
          {([A-Z0-9_]*[A-Z_]+[A-Z0-9_]*)} # get variable key between {}
 | 
						|
        )                                 #
 | 
						|
      )                                   #
 | 
						|
    )                                     #
 | 
						|
    /x
 | 
						|
 | 
						|
  ##############################################################################
 | 
						|
  # Server API
 | 
						|
  ##############################################################################
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Calls the server to reload the values in the `.env` file into the
 | 
						|
  system environment.
 | 
						|
 | 
						|
  This call is asynchronous (`cast`).
 | 
						|
  """
 | 
						|
  @spec reload!() :: :ok
 | 
						|
  def reload! do
 | 
						|
    :gen_server.cast(:dotenv, :reload!)
 | 
						|
  end
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Calls the server to reload the values in the file located at `env_path` into
 | 
						|
  the system environment.
 | 
						|
 | 
						|
  This call is asynchronous (`cast`).
 | 
						|
  """
 | 
						|
  @spec reload!(any) :: :ok
 | 
						|
  def reload!(env_path) do
 | 
						|
    :gen_server.cast(:dotenv, {:reload!, env_path})
 | 
						|
  end
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Returns the current state of the server as a `Dotenv.Env` struct.
 | 
						|
  """
 | 
						|
  @spec env() :: Env.t()
 | 
						|
  def env do
 | 
						|
    :gen_server.call(:dotenv, :env)
 | 
						|
  end
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Retrieves the value of the given `key` from the server, or `fallback` if the
 | 
						|
  value is not found.
 | 
						|
  """
 | 
						|
  @spec get(String.t(), String.t() | nil) :: String.t()
 | 
						|
  def get(key, fallback \\ nil) do
 | 
						|
    :gen_server.call(:dotenv, {:get, key, fallback})
 | 
						|
  end
 | 
						|
 | 
						|
  ##############################################################################
 | 
						|
  # Serverless API
 | 
						|
  ##############################################################################
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Reads the env files at the provided `env_path` path(s), exports the values into
 | 
						|
  the system environment, and returns them in a `Dotenv.Env` struct.
 | 
						|
  """
 | 
						|
  def load!(env_path \\ :automatic) do
 | 
						|
    env = load(env_path)
 | 
						|
    System.put_env(env.values)
 | 
						|
    env
 | 
						|
  end
 | 
						|
 | 
						|
  @doc """
 | 
						|
  Reads the env files at the provided `env_path` path(s) and returns the values in a `Dotenv.Env` struct.
 | 
						|
  """
 | 
						|
  @spec load(String.t() | :automatic | [String.t()]) :: Env.t()
 | 
						|
  def load(env_path \\ :automatic)
 | 
						|
 | 
						|
  def load([env_path | env_paths]) do
 | 
						|
    first_env = load(env_path)
 | 
						|
    rest_env = load(env_paths)
 | 
						|
 | 
						|
    %Env{paths: [env_path | rest_env.paths], values: Map.merge(first_env.values, rest_env.values)}
 | 
						|
  end
 | 
						|
 | 
						|
  def load([]) do
 | 
						|
    %Env{paths: [], values: Map.new()}
 | 
						|
  end
 | 
						|
 | 
						|
  def load(env_path) do
 | 
						|
    {env_path, contents} = read_env_file(env_path)
 | 
						|
    values = contents |> parse_contents()
 | 
						|
    %Env{paths: [env_path], values: values}
 | 
						|
  end
 | 
						|
 | 
						|
  def parse_contents(contents) do
 | 
						|
    values = String.split(contents, "\n")
 | 
						|
 | 
						|
    values
 | 
						|
    |> Enum.flat_map(&Regex.scan(@pattern, &1))
 | 
						|
    |> trim_quotes_from_values
 | 
						|
    |> Enum.reduce([], &expand_env/2)
 | 
						|
    |> Enum.reduce(Map.new(), &collect_into_map/2)
 | 
						|
  end
 | 
						|
 | 
						|
  defp collect_into_map([_whole, k, v], env), do: Map.put(env, k, v)
 | 
						|
  defp collect_into_map([_whole, _k], env), do: env
 | 
						|
 | 
						|
  defp trim_quotes_from_values(values) do
 | 
						|
    values
 | 
						|
    |> Enum.map(fn values ->
 | 
						|
      Enum.map(values, &trim_quotes/1)
 | 
						|
    end)
 | 
						|
  end
 | 
						|
 | 
						|
  defp trim_quotes(value) do
 | 
						|
    String.replace(value, @quotes_pattern, "\\2")
 | 
						|
  end
 | 
						|
 | 
						|
  # without value
 | 
						|
  defp expand_env([_whole, _k], acc), do: acc
 | 
						|
 | 
						|
  defp expand_env([whole, k, v], acc) do
 | 
						|
    matchs = Regex.scan(@env_expand_pattern, v)
 | 
						|
 | 
						|
    new_value =
 | 
						|
      case Enum.empty?(matchs) do
 | 
						|
        true ->
 | 
						|
          v
 | 
						|
 | 
						|
        false ->
 | 
						|
          matchs
 | 
						|
          |> Enum.reduce(v, fn [_whole, pattern | keys], v ->
 | 
						|
            v |> replace_env(pattern, keys, acc)
 | 
						|
          end)
 | 
						|
      end
 | 
						|
 | 
						|
    acc ++ [[whole, k, new_value]]
 | 
						|
  end
 | 
						|
 | 
						|
  defp replace_env(value, pattern, ["" | keys], env), do: replace_env(value, pattern, keys, env)
 | 
						|
  defp replace_env(value, pattern, [key | _], env), do: replace_env(value, pattern, key, env)
 | 
						|
 | 
						|
  defp replace_env(value, pattern, key, %Env{} = env) do
 | 
						|
    new_value = env |> Env.get(key) || ""
 | 
						|
 | 
						|
    pattern
 | 
						|
    |> Regex.escape()
 | 
						|
    |> Regex.compile!()
 | 
						|
    |> Regex.replace(value, new_value)
 | 
						|
  end
 | 
						|
 | 
						|
  defp replace_env(value, pattern, key, acc) when is_list(acc) do
 | 
						|
    values = acc |> Enum.reduce(Map.new(), &collect_into_map/2)
 | 
						|
    replace_env(value, pattern, key, %Env{values: values})
 | 
						|
  end
 | 
						|
 | 
						|
  defp replace_env(value, pattern, key, %{} = values) do
 | 
						|
    replace_env(value, pattern, key, %Env{values: values})
 | 
						|
  end
 | 
						|
 | 
						|
  defp read_env_file(:automatic) do
 | 
						|
    case find_env_path() do
 | 
						|
      {:ok, env_path} -> {env_path, File.read!(env_path)}
 | 
						|
      {:error, _} -> {:none, ""}
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  defp read_env_file(:none) do
 | 
						|
    {:none, ""}
 | 
						|
  end
 | 
						|
 | 
						|
  defp read_env_file(env_path) do
 | 
						|
    {env_path, File.read!(env_path)}
 | 
						|
  end
 | 
						|
 | 
						|
  defp find_env_path do
 | 
						|
    find_env_path(File.cwd!())
 | 
						|
  end
 | 
						|
 | 
						|
  defp find_env_path(dir) do
 | 
						|
    candidate = Path.join(dir, ".env")
 | 
						|
 | 
						|
    cond do
 | 
						|
      File.exists?(candidate) -> {:ok, candidate}
 | 
						|
      dir == "/" -> {:error, "No .env found"}
 | 
						|
      true -> find_env_path(Path.dirname(dir))
 | 
						|
    end
 | 
						|
  end
 | 
						|
end
 |