lib/doex/api.ex

defmodule Doex.Api do
  use FnExpr

  @moduledoc """
  Make almost direct calls to the Digital Ocean API.

  Assuming your configs are properly setup, then you only need to provide
  the endpoint after the `/v2` in the DO API, so to access your account
  you would run

      Doex.Api.get("/account")

  OR, you encode the method, and make the same call like

      Doex.Api.call(:get, %{source: "/account"})

  If your configurations are message up (or other errors occur), it will look
  similar to

      {:error,
       "Expected a 200, received 401",
       %{"id" => "unauthorized", "message" => "Unable to authenticate you."}}

  If things are working as expected, a success message looks like

      {:ok,
       %{"account" => %{"droplet_limit" => 99, "email" => "me@example.com",
           "email_verified" => true, "floating_ip_limit" => 5, "status" => "active",
           "status_message" => "",
           "uuid" => "abcdefghabcdefghabcdefghabcdefghabcdefgh"}}}

  To send a POST command, for example creating a new droplet, you can run

      Doex.Api.post(
         "/droplets",
         %{name: "dplet001",
           region: "tor1",
           size: "s-1vcpu-1gb",
           image: "ubuntu-18-04-x64",
           ssh_keys: [12345],
           backups: false,
           ipv6: true,
           user_data: nil,
           private_networking: nil,
           volumes: nil,
           tags: ["dplet001"]})

  OR, you encode the method, and make the same call like

      Doex.Api.call(
       :post,
       %{source: "/droplets",
         body: %{name: "dplet001",
                 region: "tor1",
                 size: "s-1vcpu-1gb",
                 image: "ubuntu-18-04-x64",
                 ssh_keys: [12345],
                 backups: false,
                 ipv6: true,
                 user_data: nil,
                 private_networking: nil,
                 volumes: nil,
                 tags: ["dplet001"]}})

  You could also provide additional headers within the call, but the defaults of

        %{bearer: <your configured token>,
          content_type: "application/json"}

  Should be sufficient.  But if you needed to make the calls directly without
  any loaded config, then you could.  For example,

        Doex.Api.get(
         "/account",
         %{bearer: "ABC123",
           content_type: "application/json"})

  OR,

        Doex.Api.call(
         :get,
         %{source: "/account",
           header: %{bearer: "ABC123",
                     content_type: "application/json"}})

  A similar approach can be used for sending the headers via a POST call.
  """

  @doc """
  Retrieve data from the API using any method (:get, :post, :put, :delete, etc) available
  """
  def call(method, %{source: source, body: body, headers: headers}),
    do: request(method, source, body, headers)

  def call(method, %{source: source, body: body}), do: request(method, source, body, %{})
  def call(method, %{source: source, headers: headers}), do: request(method, source, headers)
  def call(method, %{source: source}), do: request(method, source, %{})

  @doc """
  Make an API call using GET.  Optionally provide any required headers
  """
  def get(source, headers \\ %{}), do: request(:get, source, headers)

  @doc """
  Make an API call using POST.  Optionally provide any required data and headers
  """
  def post(source, body \\ %{}, headers \\ %{}), do: request(:post, source, body, headers)

  @doc """
  Make an API call using PUT.  Optionally provide any required data and headers
  """
  def put(source, body \\ %{}, headers \\ %{}), do: request(:put, source, body, headers)

  @doc """
  Make an API call using PUT.  Optionally provide any required data and headers
  """
  def delete(source, body \\ %{}, headers \\ %{}), do: request(:delete, source, body, headers)

  @doc """
  Encode the provided hash map for the URL.

  ## Examples

      iex> Doex.Api.encode_body(%{a: "one", b: "two"})
      "{\\"a\\":\\"one\\",\\"b\\":\\"two\\"}"

      iex> Doex.Api.encode_body(%{a: "o ne"})
      "{\\"a\\":\\"o ne\\"}"

      iex> Doex.Api.encode_body(nil, %{a: "o ne"})
      "{\\"a\\":\\"o ne\\"}"

      iex> Doex.Api.encode_body("application/x-www-form-urlencoded", %{a: "o ne"})
      "a=o+ne"

      iex> Doex.Api.encode_body("application/json", %{a: "b"})
      "{\\"a\\":\\"b\\"}"

  """
  def encode_body(map), do: encode_body(nil, map)
  def encode_body(nil, map), do: encode_body("application/json", map)
  def encode_body("application/x-www-form-urlencoded", map), do: URI.encode_query(map)
  def encode_body("application/json", map), do: Jason.encode!(map)
  def encode_body(_, map), do: encode_body(nil, map)

  @doc """
  Build the headers for your API

  ## Examples

      iex> Doex.Api.encode_headers(%{content_type: "application/json", bearer: "abc123"})
      [{"Authorization", "Bearer abc123"}, {"Content-Type", "application/json"}]

      iex> Doex.Api.encode_headers(%{bearer: "abc123"})
      [{"Authorization", "Bearer abc123"}, {"Content-Type", "application/json"}]

      iex> Doex.Api.encode_headers(%{})
      [{"Authorization", "Bearer FILL_ME_IN"}, {"Content-Type", "application/json"}]

      iex> Doex.Api.encode_headers()
      [{"Authorization", "Bearer FILL_ME_IN"}, {"Content-Type", "application/json"}]

      iex> Doex.Api.encode_headers(nil)
      [{"Authorization", "Bearer FILL_ME_IN"}, {"Content-Type", "application/json"}]

  """
  def encode_headers(), do: encode_headers(%{})
  def encode_headers(nil), do: encode_headers(%{})

  def encode_headers(data) do
    data
    |> reject_nil
    |> invoke(fn clean_data -> Map.merge(default_headers(), clean_data) end)
    |> Enum.map(&header/1)
    |> Enum.reject(&is_nil/1)
  end

  defp header({:bearer, bearer}), do: {"Authorization", "Bearer #{bearer}"}
  defp header({:content_type, content_type}), do: {"Content-Type", content_type}
  defp header({:body_type, _}), do: nil

  defp parse({:ok, %HTTPoison.Response{status_code: 200} = resp}), do: parse(resp)
  defp parse({:ok, %HTTPoison.Response{status_code: 201} = resp}), do: parse(resp)
  defp parse({:ok, %HTTPoison.Response{status_code: 202} = resp}), do: parse(resp)
  defp parse({:ok, %HTTPoison.Response{status_code: 204} = resp}), do: parse(resp)

  defp parse({:ok, %HTTPoison.Response{status_code: code, body: body}}) do
    body
    |> String.replace("\\\"", "\"")
    |> Jason.decode()
    |> case do
      {:ok, message} -> {:error, "Expected a 200, received #{code}", message}
      {:error, _} -> {:error, "Expected a 200, received #{code}", body}
    end
  end

  defp parse({:error, %HTTPoison.Error{reason: reason}}) do
    {:error, reason, nil}
  end

  defp parse(%HTTPoison.Response{body: nil}), do: {:ok, nil}
  defp parse(%HTTPoison.Response{body: ""}), do: {:ok, nil}
  defp parse(%HTTPoison.Response{body: body}), do: {:ok, body |> Jason.decode!()}

  defp reject_nil(map) do
    map
    |> Enum.reject(fn {_k, v} -> v == nil end)
    |> Enum.into(%{})
  end

  @doc """
  Provided the relative path of the Digital Ocean API, resolve the
  full URL

  ## Examples

      iex> Doex.Api.resolve_url("/accounts")
      "https://api.digitalocean.com/v2/accounts"

  """
  def resolve_url(source), do: "#{Doex.config()[:url]}#{source}"

  defp request(method, source, headers) do
    source
    |> resolve_url
    |> invoke(HTTPoison.request(method, &1, "", encode_headers(headers)))
    |> parse
  end

  defp request(method, source, body, headers) do
    source
    |> resolve_url
    |> invoke(
      HTTPoison.request(
        method,
        &1,
        encode_body(headers[:body_type] || headers[:content_type], body),
        encode_headers(headers)
      )
    )
    |> parse
  end

  defp default_headers do
    %{bearer: Doex.config()[:token], content_type: "application/json"}
  end
end