lib/tesla/builder.ex

defmodule Tesla.Builder do
  @http_verbs ~w(head get delete trace options post put patch)a
  @body ~w(post put patch)a

  defmacro __using__(opts \\ []) do
    opts = Macro.prewalk(opts, &Macro.expand(&1, __CALLER__))
    docs = Keyword.get(opts, :docs, true)

    quote do
      Module.register_attribute(__MODULE__, :__middleware__, accumulate: true)
      Module.register_attribute(__MODULE__, :__adapter__, [])

      if unquote(docs) do
        @typedoc "Options that may be passed to a request function. See `request/2` for detailed descriptions."
      else
        @typedoc false
      end

      @type option ::
              {:method, Tesla.Env.method()}
              | {:url, Tesla.Env.url()}
              | {:query, Tesla.Env.query()}
              | {:headers, Tesla.Env.headers()}
              | {:body, Tesla.Env.body()}
              | {:opts, Tesla.Env.opts()}

      if unquote(docs) do
        @doc """
        Perform a request.

        ## Options

        - `:method` - the request method, one of [`:head`, `:get`, `:delete`, `:trace`, `:options`, `:post`, `:put`, `:patch`]
        - `:url` - either full url e.g. "http://example.com/some/path" or just "/some/path" if using `Tesla.Middleware.BaseUrl`
        - `:query` - a keyword list of query params, e.g. `[page: 1, per_page: 100]`
        - `:headers` - a keyworld list of headers, e.g. `[{"content-type", "text/plain"}]`
        - `:body` - depends on used middleware:
            - by default it can be a binary
            - if using e.g. JSON encoding middleware it can be a nested map
            - if adapter supports it it can be a Stream with any of the above
        - `:opts` - custom, per-request middleware or adapter options

        ## Examples

            ExampleApi.request(method: :get, url: "/users/path")

            # use shortcut methods
            ExampleApi.get("/users/1")
            ExampleApi.post(client, "/users", %{name: "Jon"})
        """
      else
        @doc false
      end

      @spec request(Tesla.Env.client(), [option]) :: Tesla.Env.result()
      def request(%Tesla.Client{} = client \\ %Tesla.Client{}, options) do
        Tesla.execute(__MODULE__, client, options)
      end

      if unquote(docs) do
        @doc """
        Perform request and raise in case of error.

        This is similar to `request/2` behaviour from Tesla 0.x

        See `request/2` for list of available options.
        """
      else
        @doc false
      end

      @spec request!(Tesla.Env.client(), [option]) :: Tesla.Env.t() | no_return
      def request!(%Tesla.Client{} = client \\ %Tesla.Client{}, options) do
        Tesla.execute!(__MODULE__, client, options)
      end

      unquote(generate_http_verbs(opts))

      import Tesla.Builder, only: [plug: 1, plug: 2, adapter: 1, adapter: 2]
      @before_compile Tesla.Builder
    end
  end

  @doc """
  Attach middleware to your API client.

  ```
  defmodule ExampleApi do
    use Tesla

    # plug middleware module with options
    plug Tesla.Middleware.BaseUrl, "http://api.example.com"

    # or without options
    plug Tesla.Middleware.JSON

    # or a custom middleware
    plug MyProject.CustomMiddleware
  end
  """

  defmacro plug(middleware, opts) do
    quote do
      @__middleware__ {
        {unquote(Macro.escape(middleware)), unquote(Macro.escape(opts))},
        {:middleware, unquote(Macro.escape(__CALLER__))}
      }
    end
  end

  defmacro plug(middleware) do
    quote do
      @__middleware__ {
        unquote(Macro.escape(middleware)),
        {:middleware, unquote(Macro.escape(__CALLER__))}
      }
    end
  end

  @doc """
  Choose adapter for your API client.

  ```
  defmodule ExampleApi do
    use Tesla

    # set adapter as module
    adapter Tesla.Adapter.Hackney

    # set adapter as anonymous function
    adapter fn env ->
      ...
      env
    end
  end
  """
  defmacro adapter(name, opts) do
    quote do
      @__adapter__ {
        {unquote(Macro.escape(name)), unquote(Macro.escape(opts))},
        {:adapter, unquote(Macro.escape(__CALLER__))}
      }
    end
  end

  defmacro adapter(name) do
    quote do
      @__adapter__ {
        unquote(Macro.escape(name)),
        {:adapter, unquote(Macro.escape(__CALLER__))}
      }
    end
  end

  defmacro __before_compile__(env) do
    adapter =
      env.module
      |> Module.get_attribute(:__adapter__)
      |> compile()

    middleware =
      env.module
      |> Module.get_attribute(:__middleware__)
      |> Enum.reverse()
      |> compile()

    quote location: :keep do
      def __middleware__, do: unquote(middleware)
      def __adapter__, do: unquote(adapter)
    end
  end

  def client(pre, post, adapter \\ nil)

  def client(pre, post, nil) do
    %Tesla.Client{pre: runtime(pre), post: runtime(post)}
  end

  def client(pre, post, adapter) do
    %Tesla.Client{pre: runtime(pre), post: runtime(post), adapter: runtime(adapter)}
  end

  @default_opts []

  defp compile(nil), do: nil
  defp compile(list) when is_list(list), do: Enum.map(list, &compile/1)

  # {Tesla.Middleware.Something, opts}
  defp compile({{{:__aliases__, _, _} = ast_mod, ast_opts}, {_kind, _caller}}) do
    quote do: {unquote(ast_mod), :call, [unquote(ast_opts)]}
  end

  # Tesla.Middleware.Something
  defp compile({{:__aliases__, _, _} = ast_mod, {_kind, _caller}}) do
    quote do: {unquote(ast_mod), :call, [unquote(@default_opts)]}
  end

  # fn env -> ... end
  defp compile({{:fn, _, _} = ast_fun, {_kind, _caller}}) do
    quote do: {:fn, unquote(ast_fun)}
  end

  defp runtime(list) when is_list(list), do: Enum.map(list, &runtime/1)
  defp runtime({module, opts}) when is_atom(module), do: {module, :call, [opts]}
  defp runtime(fun) when is_function(fun), do: {:fn, fun}
  defp runtime(module) when is_atom(module), do: {module, :call, [@default_opts]}

  defp generate_http_verbs(opts) do
    only = Keyword.get(opts, :only, @http_verbs)
    except = Keyword.get(opts, :except, [])
    docs = Keyword.get(opts, :docs, true)

    for method <- @http_verbs do
      for bang <- [:safe, :bang],
          client <- [:client, :noclient],
          opts <- [:opts, :noopts],
          method in only && method not in except do
        gen(method, bang, client, opts, docs)
      end
    end
  end

  defp gen(method, safe, client, opts, docs) do
    quote location: :keep do
      unquote(gen_doc(method, safe, client, opts, docs))
      unquote(gen_spec(method, safe, client, opts))
      unquote(gen_fun(method, safe, client, opts))
    end
  end

  defp gen_doc(method, safe, :client, :opts, true) do
    request = to_string(req(safe))
    name = name(method, safe)

    {body, body_line} =
      if method in @body do
        {~s|, %{name: "Jon"}|, ""}
      else
        {"", ~s|#{name}(client, "/users", body: %{name: "Jon"})|}
      end

    quote location: :keep do
      @doc """
      Perform a #{unquote(method |> to_string |> String.upcase())} request.

      See `#{unquote(request)}/1` or `#{unquote(request)}/2` for options definition.

          #{unquote(name)}("/users"#{unquote(body)})
          #{unquote(name)}("/users"#{unquote(body)}, query: [scope: "admin"])
          #{unquote(name)}(client, "/users"#{unquote(body)})
          #{unquote(name)}(client, "/users"#{unquote(body)}, query: [scope: "admin"])
          #{unquote(body_line)}
      """
    end
  end

  defp gen_doc(_method, _bang, _client, _opts, _) do
    quote location: :keep do
      @doc false
    end
  end

  defp gen_spec(method, safe, client, opts) do
    quote location: :keep do
      @spec unquote(name(method, safe))(unquote_splicing(types(method, client, opts))) ::
              unquote(type(safe))
    end
  end

  defp gen_fun(method, safe, client, opts) do
    quote location: :keep do
      def unquote(name(method, safe))(unquote_splicing(inputs(method, client, opts))) do
        unquote(req(safe))(unquote_splicing(outputs(method, client, opts)))
      end
    end
    |> gen_guards(opts)
  end

  defp gen_guards({:def, _, [head, [do: body]]}, :opts) do
    quote do
      def unquote(head) when is_list(opts), do: unquote(body)
    end
  end

  defp gen_guards(def, _opts), do: def

  defp name(method, :safe), do: method
  defp name(method, :bang), do: String.to_atom("#{method}!")

  defp req(:safe), do: :request
  defp req(:bang), do: :request!

  defp types(method, client, opts), do: type(client) ++ type(:url) ++ type(method) ++ type(opts)

  defp type(:safe), do: quote(do: Tesla.Env.result())
  defp type(:bang), do: quote(do: Tesla.Env.t() | no_return)

  defp type(:client), do: [quote(do: Tesla.Env.client())]
  defp type(:noclient), do: []
  defp type(:opts), do: [quote(do: [option])]
  defp type(:noopts), do: []
  defp type(:url), do: [quote(do: Tesla.Env.url())]
  defp type(method) when method in @body, do: [quote(do: Tesla.Env.body())]
  defp type(_method), do: []

  defp inputs(method, client, opts),
    do: input(client) ++ input(:url) ++ input(method) ++ input(opts)

  defp input(:client), do: [quote(do: %Tesla.Client{} = client)]
  defp input(:noclient), do: []
  defp input(:opts), do: [quote(do: opts)]
  defp input(:noopts), do: []
  defp input(:url), do: [quote(do: url)]
  defp input(method) when method in @body, do: [quote(do: body)]
  defp input(_method), do: []

  defp outputs(method, client, opts), do: output(client) ++ [output(output(method), opts)]
  defp output(:client), do: [quote(do: client)]
  defp output(:noclient), do: []
  defp output(m) when m in @body, do: quote(do: [method: unquote(m), url: url, body: body])
  defp output(m), do: quote(do: [method: unquote(m), url: url])
  defp output(prev, :opts), do: quote(do: unquote(prev) ++ opts)
  defp output(prev, :noopts), do: prev
end