lib/dnsimple.ex

defmodule Dnsimple do
  require Logger

  def start, do: Application.ensure_all_started(:dnsimple)


  defmodule Error do
    def decode(body) do
      Poison.decode!(body)
    end
  end

  defmodule RequestError do
    @moduledoc """
    Error raised when an API request fails for an client, a server error or
    invalid request information.
    """

    defexception [:message, :http_response]

    def new(http_response) do
      message = Error.decode(http_response.body) |> Map.get("message")

      %__MODULE__{
        message: "HTTP #{http_response.status_code}: #{message}",
        http_response: http_response
      }
    end
  end

  defmodule NotFoundError do
    @moduledoc """
    Error raised when the target of an API request is not found.
    """

    alias Dnsimple.Error
    defexception [:message, :http_response]

    def new(http_response) do
      %__MODULE__{
        message: Error.decode(http_response.body) |> Map.get("message"),
        http_response: http_response
      }
    end
  end

  defmodule Client do
    @default_base_url Application.get_env(:dnsimple, :base_url, "https://api.dnsimple.com")
    @default_user_agent "dnsimple-elixir/#{Dnsimple.Mixfile.project[:version]}"

    @api_version "v2"

    defstruct access_token: nil, base_url: @default_base_url, user_agent: nil
    @type t :: %__MODULE__{access_token: String.t, base_url: String.t, user_agent: String.t}

    @type headers :: [{binary, binary}] | %{binary => binary}
    @type body :: binary | {:form, [{atom, any}]} | {:file, binary}


    @doc """
    Prepends the correct API version to path.

    ## Examples

        iex> Dnsimple.Client.versioned "/whoami"
        "/v2/whoami"

    """
    @spec versioned(String.t) :: String.t
    def versioned(path) do
      "/" <> @api_version <> path
    end

    @doc"""
    Returns the representation of an empty body in a request.

    ## Examples
       iex> Dnsimple.Client.empty_body()
       nil

    """
    @spec empty_body :: nil
    def empty_body, do: nil

    @doc """
    Issues a GET request to the given url.
    """
    @spec get(Client.t, binary, Keyword.t) :: {:ok|:error, HTTPoison.Response.t | HTTPoison.AsyncResponse.t}
    def get(client, url, options \\ []), do: execute(client, :get, url, empty_body(), options)

    @doc """
    Issues a POST request to the given url.
    """
    @spec post(Client.t, binary, body, Keyword.t) :: {:ok|:error, HTTPoison.Response.t | HTTPoison.AsyncResponse.t}
    def post(client, url, body, options \\ []), do: execute(client, :post, url, body, options)

    @doc """
    Issues a PUT request to the given url.
    """
    @spec put(Client.t, binary, body, Keyword.t) :: {:ok|:error, HTTPoison.Response.t | HTTPoison.AsyncResponse.t}
    def put(client, url, body, options \\ []), do: execute(client, :put, url, body, options)

    @doc """
    Issues a PATCH request to the given url.
    """
    @spec patch(Client.t, binary, body, Keyword.t) :: {:ok|:error, HTTPoison.Response.t | HTTPoison.AsyncResponse.t}
    def patch(client, url, body, options \\ []), do: execute(client, :patch, url, body, options)

    @doc """
    Issues a DELETE request to the given url.
    """
    @spec delete(Client.t, binary, Keyword.t) :: {:ok|:error, HTTPoison.Response.t | HTTPoison.AsyncResponse.t}
    def delete(client, url, options \\ []), do: execute(client, :delete, url, empty_body(), options)

    def execute(client, method, url, body \\ "", all_options \\ []) do
      {headers, options} = split_headers_options(client, all_options)
      {headers, body}    = process_request_body(headers, body)
      base_options       = [recv_timeout: 30000]

      Logger.debug("[dnsimple] #{format_http_method(method)} #{url(client, url)}")

      HTTPoison.request!(method, url(client, url), body, headers, Keyword.merge(base_options, options))
      |> check_response
    end

    defp split_headers_options(client, all_options) do
      default_headers = %{
        "Accept"        => "application/json",
        "User-Agent"    => format_user_agent(client.user_agent),
        "Authorization" => "Bearer #{client.access_token}",
      }

      {headers, options} = Keyword.pop(all_options, :headers)

      case headers do
        nil     -> {default_headers, options}
        headers -> {Enum.into(headers, default_headers), options}
      end
    end

    # Builds the final user agent to use for HTTP requests.
    #
    # If no custom user agent is provided, the default user agent is used.
    #
    #     dnsimple-elixir/1.0
    #
    # If a custom user agent is provided, the final user agent is the combination
    # of the custom user agent prepended by the default user agent.
    #
    #     dnsimple-elixir/1.0 customAgentFlag
    #
    defp format_user_agent(nil), do: @default_user_agent
    defp format_user_agent(custom_agent) do
      "#{custom_agent} #{@default_user_agent}"
    end

    # Extracts a specific {"Name", "Value"} header tuple.
    defp get_header(headers, name) do
      Enum.find(headers, fn({key, _}) -> key == name end)
    end

    defp process_request_body(headers, nil), do: {headers, []}
    defp process_request_body(headers, body) when is_binary(body), do: {headers, body}
    defp process_request_body(headers, body) do
      case get_header(headers, "Accept") do
        {_, "application/json"} -> {Map.put(headers, "Content-Type", "application/json"), Poison.encode!(body)}
        _                       -> {headers, body}
      end
    end

    defp url(%Client{base_url: base_url}, path) do
      base_url <> path
    end

    defp check_response(http_response) do
      case http_response.status_code  do
        i when i in 200..299 -> {:ok, http_response}
        404 -> {:error, NotFoundError.new(http_response)}
        _   -> {:error, RequestError.new(http_response)}
      end
    end

    defp format_http_method(method) when is_atom(method), do: format_http_method(Atom.to_string(method))
    defp format_http_method(method) when is_binary(method), do: String.upcase(method)
  end


  defmodule Listing do
    @doc """
    Issues a GET request to the given url processing the listing options first.
    """
    @spec get(Client.t, binary, Keyword.t) :: {:ok|:error, HTTPoison.Response.t | HTTPoison.AsyncResponse.t}
    def get(client, url, options \\ []), do: Client.get(client, url, format(options))


    @known_params ~w(filter sort page per_page)a

    @doc """
    Format request options for list endpoints into HTTP params.
    """
    def format(options) do
      {params, options} = Enum.reduce(@known_params, {[], options}, &extract_param/2)

      case params do
        [] -> options
        _  -> Keyword.put(options, :params, params)
      end
    end

    defp extract_param(:filter = option, {params, options}) do
      case Keyword.get_and_update(options, option, fn _ -> :pop end) do
        {nil, _} ->
          {params, options}
        {value, updated_options} ->
          updated_params = Keyword.merge(params, value)
          {updated_params, updated_options}
      end
    end
    defp extract_param(option, {params, options}) do
      case Keyword.get_and_update(options, option, fn _ -> :pop end) do
        {nil, _} ->
          {params, options}
        {value, updated_options} ->
          updated_params = Keyword.put(params, option, value)
          {updated_params, updated_options}
      end
    end


    @first_page 1
    @unkown_pages_left nil

    @doc """
    Iterates over all pages of a listing endpoint and returns the union of all
    the elements of all pages in the form of `{:ok, all_elements}`.  If an
    error occurs it will return the response to the request that failed in the
    form of `{:error, failed_response}`.

    Note that the `params` attribute must include the `options` parameter even
    if it's optional.

    ## Examples

      client     = %Dnsimple.Client{access_token: "a1b2c3d4"}
      account_id = 1010

      Listing.get_all(Dnsimple.Zones, :list_zones, [client, account_id, []])
      Listing.get_all(Dnsimple.Zones, :list_zones, [client, account_id, [sort: "name:desc"]])
      Listing.get_all(Dnsimple.Zones, :list_zone_records, [client, account_id, _zone_id = "example.com", []])

    """
    def get_all(module, function, params) do
      get_pages(module, function, params, _all = [], _page = @first_page, _pages_left = @unkown_pages_left)
    end

    defp get_pages(_module, _function, _params, all, _page, _pages_left = 0), do: {:ok, all}
    defp get_pages(module, function, params, all, page, _pages_left) do
      case apply(module, function, add_page_param(params, page)) do
        {:ok, response} ->
          all       = all ++ response.data
          next      = page + 1
          remaining = response.pagination.total_pages - page
          get_pages(module, function, params, all, next, remaining)
        {:error, response} -> {:error, response}
      end
    end

    defp add_page_param(params, page) do
      arity   = Enum.count(params)
      options = List.last(params) ++ [page: page]
      List.replace_at(params, arity - 1, options)
    end

  end
end