lib/aws/client.ex

defmodule AWS.Client do
  @moduledoc """
  Provides credentials and connection details for making requests to AWS services.

  You can configure `access_key_id` and `secret_access_key` which are the credentials
  needed by [IAM](https://aws.amazon.com/iam), and also the `region` for your services.
  The list of regions can be found in the [AWS service endpoints](https://docs.aws.amazon.com/general/latest/gr/rande.html)
  documentation. You can also use "local" to make requests to your localhost.

  ## Custom HTTP client

  The option `http_client` accepts a tuple with a module and a list of options.
  The module must implement the callback `c:AWS.HTTPClient.request/5`.

  ## Custom JSON or XML parsers

  You can configure a custom JSON parser by using the option `json_module`. This
  option accepts a tuple with a module and options. The given module must
  implement the callbacks from `AWS.JSON`.

  Similarly, there is a `xml_module` option that configures the XML parser. The
  XML module must implement the callbacks from `AWS.XML`.

  ## Additional options

  * `:session_token` - an option to set the `X-Amz-Security-Token` when performing
    the requests.
  * `:port` - is the port to use when making requests. Defaults to `443`.
  * `:proto` - is the protocol to use. It can be "http" or "https". Defaults to `"https"`.
  * `:endpoint` - the AWS endpoint. Defaults to `"amazonaws.com"`. You can configure this
  using `put_endpoint/2` for AWS compatible APIs.

  The `service` option is overwritten by each service with its signing name from metadata.
  """

  @derive {Inspect, except: [:access_key_id, :secret_access_key, :session_token]}
  defstruct access_key_id: nil,
            secret_access_key: nil,
            session_token: nil,
            region: nil,
            service: nil,
            endpoint: nil,
            proto: "https",
            port: 443,
            http_client: {AWS.HTTPClient, []},
            json_module: {AWS.JSON, []},
            xml_module: {AWS.XML, []}

  @typedoc """
  The endpoint configuration.

  Check `put_endpoint/2` for more details.
  """
  @type endpoint_config :: binary() | {:keep_prefixes, binary()} | (map() -> binary()) | nil

  @type t :: %__MODULE__{
          access_key_id: binary() | nil,
          secret_access_key: binary() | nil,
          session_token: binary() | nil,
          region: binary() | nil,
          service: binary() | nil,
          endpoint: endpoint_config(),
          proto: binary(),
          port: non_neg_integer(),
          http_client: {module(), keyword()},
          json_module: {module(), keyword()},
          xml_module: {module(), keyword()}
        }

  @aws_default_endpoint "amazonaws.com"

  @aws_access_key_id "AWS_ACCESS_KEY_ID"
  @aws_secret_access_key "AWS_SECRET_ACCESS_KEY"
  @aws_session_token "AWS_SESSION_TOKEN"
  @aws_default_region "AWS_DEFAULT_REGION"

  @doc """
  The default endpoint.

  Check `put_endpoint/2` for more details on how to configure
  a custom endpoint.
  """
  def default_endpoint, do: @aws_default_endpoint

  def create() do
    case System.get_env(@aws_default_region) do
      nil -> raise RuntimeError, "missing default region"
      region -> create(region)
    end
  end

  def create(region) do
    case {System.get_env(@aws_access_key_id), System.get_env(@aws_secret_access_key),
          System.get_env(@aws_session_token)} do
      {nil, _, _} ->
        raise RuntimeError, "missing access key id"

      {_, nil, _} ->
        raise RuntimeError, "missing secret access key"

      {access_key_id, secret_access_key, nil} ->
        create(access_key_id, secret_access_key, region)

      {access_key_id, secret_access_key, token} ->
        create(access_key_id, secret_access_key, token, region)
    end
  end

  def create(access_key_id, secret_access_key, region) do
    %AWS.Client{
      access_key_id: access_key_id,
      secret_access_key: secret_access_key,
      region: region
    }
  end

  def create(access_key_id, secret_access_key, token, region) do
    %AWS.Client{
      access_key_id: access_key_id,
      secret_access_key: secret_access_key,
      session_token: token,
      region: region
    }
  end

  @doc """
  Configures the endpoint to a custom one.

  This is useful to set a custom endpoint when working with AWS
  compatible APIs.
  The following configuration is valid:

  * `"example.com"`: this will be used as it is, without considering
  regions or endpoint prefixes or account id.

  * `{:keep_prefixes, "example.com"}`: it will keep the same rules from
  AWS to build the prefixes.
  For example, if region is "us-east-1" and the service prefix is "s3",
  then the final endpoint will be `"s3.us-east-1.example.com"`

  * `fn options -> "example.com" end`: a function that will be invoked
  with options for that request. `options` is a map with the following
  shape:

      ```
      %{
        endpoint: endpoint_config(),
        region: binary() | nil,
        service: binary(),
        global?: boolean(),
        endpoint_prefix: binary(),
        account_id: binary() | nil
      }
      ```

  ## Examples

      iex> put_endpoint(%Client{}, "example.com")
      %Client{endpoint: "example.com}

      iex> put_endpoint(%Client{}, {:keep_prefixes, "example.com"})
      %Client{endpoint: {:keep_prefixes, "example.com}}

      iex> put_endpoint(%Client{}, fn opts -> Enum.join(["baz", opts.region, "foo.com"], ".") end)
      %Client{endpoint: #Function<>}

  """
  def put_endpoint(%__MODULE__{} = client, endpoint_config)
      when is_binary(endpoint_config) or is_function(endpoint_config, 1) do
    %{client | endpoint: endpoint_config}
  end

  def put_endpoint(%__MODULE__{} = client, {:keep_prefixes, endpoint} = endpoint_config)
      when is_binary(endpoint) do
    %{client | endpoint: endpoint_config}
  end

  def request(client, method, url, body, headers, _opts \\ []) do
    {mod, options} = Map.fetch!(client, :http_client)
    apply(mod, :request, [method, url, body, headers, options])
  end

  def encode!(_client, payload, :query), do: AWS.Util.encode_query(payload)

  def encode!(client, payload, format) do
    {mod, opts} = Map.fetch!(client, String.to_existing_atom("#{format}_module"))
    apply(mod, :encode_to_iodata!, [payload, opts])
  end

  def decode!(client, payload, format) do
    {mod, opts} = Map.fetch!(client, String.to_existing_atom("#{format}_module"))
    apply(mod, :decode!, [payload, opts])
  end
end