lib/client.ex

defmodule ExCurl.Client do
  @moduledoc """
  A helper to create HTTP Clients based on ExCurl that can share common options.

  ## Example HTTP Client

  You can `use ExCurl.Client` on any module to provide HTTP Client functionality and share default options. Below is an example based on the great 
  [Tesla documentation](https://hexdocs.pm/tesla/readme.html):

  ```elixir
  defmodule GitHubClient do
    use ExCurl.Client, defaults: [base_url: "https://api.github.com", headers: %{"authentication" => "Bearer some-secret-token"}]

    def user_repos(username), do: get("/users/\#{username}/repos")
  end
  ```

  We can then use this client with the shared defaults and custom functions:

  ```elixir
  # A custom GET request using our default base_url and headers
  GitHubClient.get("/users/danielrudn")

  # Our custom function, requiring only a username
  GitHubClient.user_repos("danielrudn")
  ```

  ## Response Handling

  You may define an optional `handle_response/1` callback to transform a response after a successful request.
  A common example would be parsing a JSON string into a map. Below we have the same `GitHub` client as above
  but we also parse the JSON response if possible using the `handle_response/1` callback:

  ```elixir
  defmodule GitHubClient do
    use ExCurl.Client, defaults: [base_url: "https://api.github.com", headers: %{"authentication" => "Bearer some-secret-token"}]

    def handle_response(%ExCurl.Response{body: body}) do
      case Jason.decode(body) do
        {:ok, decoded_body} -> decoded_body
        _ -> body
      end
    end

    def user_repos(username), do: get("/users/\#{username}/repos")
  end
  ```

  ## Options
  All of the [Shared Options]() from ExCurl are available. There are also the follow additional options:

  * `base_url` - The base URL used for all requests by this client module
  """

  @doc """
  See [Response Handling](#module-response-handling) for usage and examples.
  """
  @callback handle_response(response :: %ExCurl.Response{}) :: any()
  @optional_callbacks handle_response: 1

  defmacro __using__(client_opts \\ []) do
    quote do
      def get!(url, opts \\ []), do: request!("GET", url, opts)
      def post!(url, opts \\ []), do: request!("POST", url, opts)
      def put!(url, opts \\ []), do: request!("PUT", url, opts)
      def patch!(url, opts \\ []), do: request!("PATCH", url, opts)
      def delete!(url, opts \\ []), do: request!("DELETE", url, opts)
      def get(url, opts \\ []), do: request("GET", url, opts)
      def post(url, opts \\ []), do: request("POST", url, opts)
      def put(url, opts \\ []), do: request("PUT", url, opts)
      def patch(url, opts \\ []), do: request("PATCH", url, opts)
      def delete(url, opts \\ []), do: request("DELETE", url, opts)

      def request(method, url, opts \\ []) do
        merged_opts = Keyword.merge(default_opts(), opts)

        with {:ok, %ExCurl.Response{} = resp} <-
               ExCurl.request(method, request_url(url, merged_opts), merged_opts) do
          if function_exported?(__MODULE__, :handle_response, 1) do
            {:ok, apply(__MODULE__, :handle_response, [resp])}
          else
            {:ok, resp}
          end
        end
      end

      def request!(method, url, opts \\ []) do
        merged_opts = Keyword.merge(default_opts(), opts)
        response = ExCurl.request!(method, request_url(url, merged_opts), merged_opts)

        if function_exported?(__MODULE__, :handle_response, 1) do
          apply(__MODULE__, :handle_response, [response])
        else
          response
        end
      end

      defp request_url(url_or_path, opts) do
        case Keyword.get(opts, :base_url, nil) do
          base_url when is_binary(base_url) -> base_url <> url_or_path
          _ -> url_or_path
        end
      end

      defp default_opts do
        case Keyword.get(unquote(client_opts), :defaults, []) do
          opts_list when is_list(opts_list) -> opts_list
          opts_atom when is_atom(opts_atom) -> apply(__MODULE__, opts_atom, [])
          opts_func when is_function(opts_func, 0) -> opts_func.()
        end
      end
    end
  end
end