lib/ex_aws/config.ex

defmodule ExAws.Config do
  @moduledoc """
  Generates the configuration for a service.

  It starts with the defaults for a given environment and then merges in the
  common config from the ex_aws config root, and then finally any config
  specified for the particular service.

  ## Refreshable fields

  Some fields are marked as refreshable. These fields will be fetched through
  the auth cache even if they are passed in as overrides. This is so stale
  credentials aren't used, for example, with long running streams.

  This behaviour must be explicitly enabled by passing `refreshable: true` as an option
  to Config.new/2
  """

  # TODO: Add proper documentation?

  @common_config [
    :http_client,
    :http_opts,
    :json_codec,
    :access_key_id,
    :secret_access_key,
    :debug_requests,
    :region,
    :security_token,
    :retries,
    :normalize_path,
    :telemetry_event,
    :telemetry_options
  ]

  @instance_role_config [
    :access_key_id,
    :secret_access_key,
    :security_token
  ]

  @awscli_config [
    :source_profile,
    :role_arn,
    :access_key_id,
    :secret_access_key,
    :region,
    :security_token,
    :role_session_name,
    :external_id
  ]

  @type t :: %{} | Keyword.t()

  @doc """
  Builds a complete set of config for an operation.

    1. Defaults are pulled from `ExAws.Config.Defaults`
    2. Common values set via e.g `config :ex_aws` are merged in.
    3. Keys set on the individual service e.g `config :ex_aws, :s3` are merged in
    4. Finally, any configuration overrides are merged in

  """
  @spec new(atom, keyword) :: map()
  def new(service, opts \\ []) do
    overrides = Map.new(opts)

    service
    |> build_base(overrides)
    |> retrieve_runtime_config()
    |> parse_host_for_region()
  end

  @doc """
  Builds a minimal HTTP configuration.
  """
  def http_config(service, opts \\ []) do
    overrides = Map.new(opts)

    build_base(service, overrides)
    |> Map.take([:http_client, :http_opts, :json_codec])
    |> retrieve_runtime_config
  end

  def build_base(service, overrides \\ %{}) do
    common_config = Application.get_all_env(:ex_aws) |> Map.new() |> Map.take(@common_config)
    service_config = Application.get_env(:ex_aws, service, []) |> Map.new()

    region =
      (Map.get(overrides, :region) ||
         Map.get(service_config, :region) ||
         Map.get(common_config, :region) ||
         "us-east-1")
      |> retrieve_runtime_value(%{})

    defaults = ExAws.Config.Defaults.get(service, region)

    config =
      defaults
      |> Map.merge(common_config)
      |> Map.merge(service_config)
      |> add_refreshable_metadata(overrides)

    # (Maybe) do not allow overrides for refreshable config.
    overrides =
      if refreshable = config[:refreshable] do
        Enum.reduce(refreshable, overrides, fn
          :awscli, overrides -> Map.drop(overrides, @awscli_config)
          :instance_role, overrides -> Map.drop(overrides, @instance_role_config)
        end)
      else
        overrides
      end

    Map.merge(config, overrides)
  end

  # :awscli and :instance_role both read creds from ExAws.Config.AuthCache which
  # is "refreshable". This is useful for long running streams where the creds can
  # change while the stream is still running.
  defp add_refreshable_metadata(config, %{refreshable: true}) do
    refreshable =
      Enum.flat_map(config, fn {_k, v} -> List.wrap(v) end)
      |> Enum.reduce([], fn
        {:awscli, _, _}, acc -> [:awscli | acc]
        :instance_role, acc -> [:instance_role | acc]
        _, acc -> acc
      end)
      |> Enum.uniq()

    if refreshable != [] do
      Map.put(config, :refreshable, refreshable)
    else
      config
    end
  end

  defp add_refreshable_metadata(config, _overrides) do
    config
  end

  def retrieve_runtime_config(config) do
    Enum.reduce(config, config, fn
      {:host, host}, config ->
        Map.put(config, :host, retrieve_runtime_value(host, config))

      {:retries, retries}, config ->
        Map.put(config, :retries, retries)

      {:http_opts, http_opts}, config ->
        Map.put(config, :http_opts, http_opts)

      {:telemetry_event, telemetry_event}, config ->
        Map.put(config, :telemetry_event, telemetry_event)

      {:telemetry_options, telemetry_options}, config ->
        Map.put(config, :telemetry_options, telemetry_options)

      {:headers, headers}, config ->
        Map.put(config, :headers, headers)

      {:refreshable, refreshable}, config ->
        Map.put(config, :refreshable, refreshable)

      {k, v}, config ->
        case retrieve_runtime_value(v, config) do
          %{} = result -> Map.merge(config, result)
          value -> Map.put(config, k, value)
        end
    end)
  end

  def retrieve_runtime_value({:system, env_key}, _) do
    System.get_env(env_key)
  end

  def retrieve_runtime_value(:instance_role, config) do
    config
    |> ExAws.Config.AuthCache.get()
    |> Map.take(@instance_role_config)
    |> valid_map_or_nil
  end

  def retrieve_runtime_value({:awscli, profile, expiration}, _) do
    ExAws.Config.AuthCache.get(profile, expiration * 1000)
    |> Map.take(@awscli_config)
    |> valid_map_or_nil
  end

  def retrieve_runtime_value(values, config) when is_list(values) do
    values
    |> Stream.map(&retrieve_runtime_value(&1, config))
    |> Enum.find(& &1)
  end

  def retrieve_runtime_value(value, _), do: value

  def parse_host_for_region(%{host: {stub, host}, region: region} = config) do
    Map.put(config, :host, String.replace(host, stub, region))
  end

  def parse_host_for_region(%{host: map, region: region} = config) when is_map(map) do
    case Map.fetch(map, region) do
      {:ok, host} -> Map.put(config, :host, host)
      :error -> "A host for region #{region} was not found in host map #{inspect(map)}"
    end
  end

  def parse_host_for_region(config), do: config

  def awscli_auth_adapter, do: Application.get_env(:ex_aws, :awscli_auth_adapter, nil)

  def awscli_auth_credentials(profile, credentials_ini_provider \\ ExAws.CredentialsIni.File) do
    case Application.get_env(:ex_aws, :awscli_credentials, nil) do
      nil ->
        case credentials_ini_provider.security_credentials(profile) do
          {:ok, creds} -> creds
          {:error, err} -> raise "Recieved error while retrieving security credentials: #{err}"
        end

      %{^profile => profile_credentials} ->
        profile_credentials

      _otherwise ->
        raise("Missing #{profile} in provided credentials.")
    end
  end

  defp valid_map_or_nil(map) when map == %{}, do: nil
  defp valid_map_or_nil(map), do: map
end