lib/oauth2/client.ex

defmodule OAuth2.Client do
  @moduledoc ~S"""
  This module defines the `OAuth2.Client` struct and is responsible for building
  and establishing a request for an access token.

  ### Notes

  * If a full url is given (e.g. "http://www.example.com/api/resource") then it
  will use that otherwise you can specify an endpoint (e.g. "/api/resource") and
  it will append it to the `Client.site`.

  * The headers from the `Client.headers` are appended to the request headers.

  ### Examples

      client = OAuth2.Client.new(token: "abc123")

      case OAuth2.Client.get(client, "/some/resource") do
        {:ok, %OAuth2.Response{body: body}} ->
          "Yay!!"
        {:error, %OAuth2.Response{body: body}} ->
          "Something bad happen: #{inspect body}"
        {:error, %OAuth2.Error{reason: reason}} ->
          reason
      end

      response = OAuth2.Client.get!(client, "/some/resource")

      response = OAuth2.Client.post!(client, "/some/other/resources", %{foo: "bar"})
  """

  alias OAuth2.{AccessToken, Client, Error, Request, Response}

  @type authorize_url :: binary
  @type body :: any
  @type client_id :: binary
  @type client_secret :: binary
  @type headers :: [{binary, binary}]
  @type param :: binary | %{binary => param} | [param]
  @type params :: %{binary => param} | Keyword.t() | %{}
  @type redirect_uri :: binary
  @type ref :: reference | nil
  @type request_opts :: Keyword.t()
  @type serializers :: %{binary => module}
  @type site :: binary
  @type strategy :: module
  @type token :: AccessToken.t() | nil
  @type token_method :: :post | :get | atom
  @type token_url :: binary

  @type t :: %Client{
          authorize_url: authorize_url,
          client_id: client_id,
          client_secret: client_secret,
          headers: headers,
          params: params,
          redirect_uri: redirect_uri,
          ref: ref,
          request_opts: request_opts,
          serializers: serializers,
          site: site,
          strategy: strategy,
          token: token,
          token_method: token_method,
          token_url: token_url
        }

  defstruct authorize_url: "/oauth/authorize",
            client_id: "",
            client_secret: "",
            headers: [],
            params: %{},
            redirect_uri: "",
            ref: nil,
            request_opts: [],
            serializers: %{},
            site: "",
            strategy: OAuth2.Strategy.AuthCode,
            token: nil,
            token_method: :post,
            token_url: "/oauth/token"

  @doc """
  Builds a new `OAuth2.Client` struct using the `opts` provided.

  ## Client struct fields

  * `authorize_url` - absolute or relative URL path to the authorization
    endpoint. Defaults to `"/oauth/authorize"`
  * `client_id` - the client_id for the OAuth2 provider
  * `client_secret` - the client_secret for the OAuth2 provider
  * `headers` - a list of request headers
  * `params` - a map of request parameters
  * `redirect_uri` - the URI the provider should redirect to after authorization
     or token requests
  * `request_opts` - a keyword list of request options that will be sent to the
    `hackney` client. See the [hackney documentation] for a list of available
    options.
  * `site` - the OAuth2 provider site host
  * `strategy` - a module that implements the appropriate OAuth2 strategy,
    default `OAuth2.Strategy.AuthCode`
  * `token` - `%OAuth2.AccessToken{}` struct holding the token for requests.
  * `token_method` - HTTP method to use to request token (`:get` or `:post`).
    Defaults to `:post`
  * `token_url` - absolute or relative URL path to the token endpoint.
    Defaults to `"/oauth/token"`

  ## Example

      iex> OAuth2.Client.new(token: "123")
      %OAuth2.Client{authorize_url: "/oauth/authorize", client_id: "",
      client_secret: "", headers: [], params: %{}, redirect_uri: "", site: "",
      strategy: OAuth2.Strategy.AuthCode,
      token: %OAuth2.AccessToken{access_token: "123", expires_at: nil,
      other_params: %{}, refresh_token: nil, token_type: "Bearer"},
      token_method: :post, token_url: "/oauth/token"}

      iex> token = OAuth2.AccessToken.new("123")
      iex> OAuth2.Client.new(token: token)
      %OAuth2.Client{authorize_url: "/oauth/authorize", client_id: "",
      client_secret: "", headers: [], params: %{}, redirect_uri: "", site: "",
      strategy: OAuth2.Strategy.AuthCode,
      token: %OAuth2.AccessToken{access_token: "123", expires_at: nil,
      other_params: %{}, refresh_token: nil, token_type: "Bearer"},
      token_method: :post, token_url: "/oauth/token"}

  [hackney documentation]: https://github.com/benoitc/hackney/blob/master/doc/hackney.md#request5
  """
  @spec new(t, Keyword.t()) :: t
  def new(client \\ %Client{}, opts) do
    {token, opts} = Keyword.pop(opts, :token)
    {req_opts, opts} = Keyword.pop(opts, :request_opts, [])

    opts =
      opts
      |> Keyword.put(:token, process_token(token))
      |> Keyword.put(:request_opts, Keyword.merge(client.request_opts, req_opts))

    struct(client, opts)
  end

  defp process_token(nil), do: nil
  defp process_token(val) when is_binary(val), do: AccessToken.new(val)
  defp process_token(%AccessToken{} = token), do: token

  @doc """
  Puts the specified `value` in the params for the given `key`.

  The key can be a `string` or an `atom`. Atoms are automatically
  convert to strings.
  """
  @spec put_param(t, String.t() | atom, any) :: t
  def put_param(%Client{params: params} = client, key, value) do
    %{client | params: Map.put(params, "#{key}", value)}
  end

  @doc """
  Set multiple params in the client in one call.
  """
  @spec merge_params(t, params) :: t
  def merge_params(client, params) do
    params =
      Enum.reduce(params, %{}, fn {k, v}, acc ->
        Map.put(acc, "#{k}", v)
      end)

    %{client | params: Map.merge(client.params, params)}
  end

  @doc """
  Adds a new header `key` if not present, otherwise replaces the
  previous value of that header with `value`.
  """
  @spec put_header(t, binary, binary) :: t
  def put_header(%Client{headers: headers} = client, key, value)
      when is_binary(key) and is_binary(value) do
    key = String.downcase(key)
    %{client | headers: List.keystore(headers, key, 0, {key, value})}
  end

  @doc """
  Set multiple headers in the client in one call.
  """
  @spec put_headers(t, list) :: t
  def put_headers(%Client{} = client, []), do: client

  def put_headers(%Client{} = client, [{k, v} | rest]) do
    client
    |> put_header(k, v)
    |> put_headers(rest)
  end

  @doc false
  @spec authorize_url(t, list) :: {t, binary}
  def authorize_url(%Client{} = client, params \\ []) do
    client.strategy.authorize_url(client, params) |> to_url(:authorize_url)
  end

  @doc """
  Returns the authorize url based on the client configuration.

  ## Example

      iex> OAuth2.Client.authorize_url!(%OAuth2.Client{})
      "/oauth/authorize?client_id=&redirect_uri=&response_type=code"
  """
  @spec authorize_url!(t, list) :: binary
  def authorize_url!(%Client{} = client, params \\ []) do
    {_, url} = authorize_url(client, params)
    url
  end

  @doc """
  Register a serialization module for a given mime type.

  ## Example

      iex> client = OAuth2.Client.put_serializer(%OAuth2.Client{}, "application/json", Jason)
      %OAuth2.Client{serializers: %{"application/json" => Jason}}
      iex> OAuth2.Client.get_serializer(client, "application/json")
      Jason
  """
  @spec put_serializer(t, binary, atom) :: t
  def put_serializer(%Client{serializers: serializers} = client, mime, module)
      when is_binary(mime) and is_atom(module) do
    %Client{client | serializers: Map.put(serializers, mime, module)}
  end

  @doc """
  Un-register a serialization module for a given mime type.

  ## Example

      iex> client = OAuth2.Client.delete_serializer(%OAuth2.Client{}, "application/json")
      %OAuth2.Client{}
      iex> OAuth2.Client.get_serializer(client, "application/json")
      nil
  """
  @spec delete_serializer(t, binary) :: t
  def delete_serializer(%Client{serializers: serializers} = client, mime) do
    %Client{client | serializers: Map.delete(serializers, mime)}
  end

  @doc false
  @spec get_serializer(t, binary) :: atom
  def get_serializer(%Client{serializers: serializers}, mime) do
    Map.get(serializers, mime)
  end

  @doc """
  Fetches an `OAuth2.AccessToken` struct by making a request to the token endpoint.

  Returns the `OAuth2.Client` struct loaded with the access token which can then
  be used to make authenticated requests to an OAuth2 provider's API.

  ## Arguments

  * `client` - a `OAuth2.Client` struct with the strategy to use, defaults to
    `OAuth2.Strategy.AuthCode`
  * `params` - a keyword list of request parameters which will be encoded into
    a query string or request body depending on the selected strategy
  * `headers` - a list of request headers
  * `opts` - a Keyword list of request options which will be merged with
    `OAuth2.Client.request_opts`

  ## Options

  * `:recv_timeout` - the timeout (in milliseconds) of the request
  * `:proxy` - a proxy to be used for the request; it can be a regular url or a
    `{host, proxy}` tuple
  """
  @spec get_token(t, params, headers, Keyword.t()) ::
          {:ok, Client.t()} | {:error, Response.t()} | {:error, Error.t()}
  def get_token(%{token_method: method} = client, params \\ [], headers \\ [], opts \\ []) do
    {client, url} = token_url(client, params, headers)

    case Request.request(method, client, url, client.params, client.headers, opts) do
      {:ok, response} ->
        token = AccessToken.new(response.body)
        {:ok, %{client | headers: [], params: %{}, token: token}}

      {:error, error} ->
        {:error, error}
    end
  end

  @doc """
  Same as `get_token/4` but raises `OAuth2.Error` if an error occurs during the
  request.
  """
  @spec get_token!(t, params, headers, Keyword.t()) :: Client.t() | Error.t()
  def get_token!(client, params \\ [], headers \\ [], opts \\ []) do
    case get_token(client, params, headers, opts) do
      {:ok, client} ->
        client

      {:error, %Response{status_code: code, headers: headers, body: body}} ->
        raise %Error{
          reason: """
          Server responded with status: #{code}

          Headers:

          #{Enum.reduce(headers, "", fn {k, v}, acc -> acc <> "#{k}: #{v}\n" end)}
          Body:

          #{inspect(body)}
          """
        }

      {:error, error} ->
        raise error
    end
  end

  @doc """
  Refreshes an existing access token using a refresh token.
  """
  @spec refresh_token(t, params, headers, Keyword.t()) ::
          {:ok, Client.t()} | {:error, Response.t()} | {:error, Error.t()}
  def refresh_token(token, params \\ [], headers \\ [], opts \\ [])

  def refresh_token(%Client{token: %{refresh_token: nil}}, _params, _headers, _opts) do
    {:error, %Error{reason: "Refresh token not available."}}
  end

  def refresh_token(
        %Client{token: %{refresh_token: refresh_token}} = client,
        params,
        headers,
        opts
      ) do
    refresh_client =
      %{client | strategy: OAuth2.Strategy.Refresh, token: nil}
      |> Client.put_param(:refresh_token, refresh_token)

    case Client.get_token(refresh_client, params, headers, opts) do
      {:ok, %Client{} = client} ->
        if client.token.refresh_token do
          {:ok, client}
        else
          {:ok, put_in(client.token.refresh_token, refresh_token)}
        end

      {:error, error} ->
        {:error, error}
    end
  end

  @doc """
  Calls `refresh_token/4` but raises `Error` if there an error occurs.
  """
  @spec refresh_token!(t, params, headers, Keyword.t()) :: Client.t() | Error.t()
  def refresh_token!(%Client{} = client, params \\ [], headers \\ [], opts \\ []) do
    case refresh_token(client, params, headers, opts) do
      {:ok, %Client{} = client} -> client
      {:error, error} -> raise error
    end
  end

  @doc """
  Adds `authorization` header for basic auth.
  """
  @spec basic_auth(t) :: t
  def basic_auth(%OAuth2.Client{client_id: id, client_secret: secret} = client) do
    put_header(client, "authorization", "Basic " <> Base.encode64(id <> ":" <> secret))
  end

  @doc """
  Makes a `GET` request to the given `url` using the `OAuth2.AccessToken`
  struct.
  """
  @spec get(t, binary, headers, Keyword.t()) ::
          {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()}
  def get(%Client{} = client, url, headers \\ [], opts \\ []),
    do: Request.request(:get, client, url, "", headers, opts)

  @doc """
  Same as `get/4` but returns a `OAuth2.Response` or `OAuth2.Error` exception if
  the request results in an error.
  """
  @spec get!(t, binary, headers, Keyword.t()) :: Response.t() | Error.t()
  def get!(%Client{} = client, url, headers \\ [], opts \\ []),
    do: Request.request!(:get, client, url, "", headers, opts)

  @doc """
  Makes a `PUT` request to the given `url` using the `OAuth2.AccessToken`
  struct.
  """
  @spec put(t, binary, body, headers, Keyword.t()) ::
          {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()}
  def put(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
    do: Request.request(:put, client, url, body, headers, opts)

  @doc """
  Same as `put/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception if
  the request results in an error.

  An `OAuth2.Error` exception is raised if the request results in an
  error tuple (`{:error, reason}`).
  """
  @spec put!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t()
  def put!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
    do: Request.request!(:put, client, url, body, headers, opts)

  @doc """
  Makes a `PATCH` request to the given `url` using the `OAuth2.AccessToken`
  struct.
  """
  @spec patch(t, binary, body, headers, Keyword.t()) ::
          {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()}
  def patch(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
    do: Request.request(:patch, client, url, body, headers, opts)

  @doc """
  Same as `patch/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception if
  the request results in an error.

  An `OAuth2.Error` exception is raised if the request results in an
  error tuple (`{:error, reason}`).
  """
  @spec patch!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t()
  def patch!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
    do: Request.request!(:patch, client, url, body, headers, opts)

  @doc """
  Makes a `POST` request to the given URL using the `OAuth2.AccessToken`.
  """
  @spec post(t, binary, body, headers, Keyword.t()) ::
          {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()}
  def post(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
    do: Request.request(:post, client, url, body, headers, opts)

  @doc """
  Same as `post/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception
  if the request results in an error.

  An `OAuth2.Error` exception is raised if the request results in an
  error tuple (`{:error, reason}`).
  """
  @spec post!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t()
  def post!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
    do: Request.request!(:post, client, url, body, headers, opts)

  @doc """
  Makes a `DELETE` request to the given URL using the `OAuth2.AccessToken`.
  """
  @spec delete(t, binary, body, headers, Keyword.t()) ::
          {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()}
  def delete(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
    do: Request.request(:delete, client, url, body, headers, opts)

  @doc """
  Same as `delete/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception
  if the request results in an error.

  An `OAuth2.Error` exception is raised if the request results in an
  error tuple (`{:error, reason}`).
  """
  @spec delete!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t()
  def delete!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
    do: Request.request!(:delete, client, url, body, headers, opts)

  defp to_url(%Client{token_method: :post} = client, :token_url) do
    {client, endpoint(client, client.token_url)}
  end

  defp to_url(client, endpoint) do
    endpoint = Map.get(client, endpoint)
    url = endpoint(client, endpoint) <> "?" <> URI.encode_query(client.params)
    {client, url}
  end

  defp token_url(client, params, headers) do
    client
    |> token_post_header()
    |> client.strategy.get_token(params, headers)
    |> to_url(:token_url)
  end

  defp token_post_header(%Client{token_method: :post} = client) do
    client
    |> put_header("content-type", "application/x-www-form-urlencoded")
    |> put_header("accept", "application/json")
  end

  defp token_post_header(%Client{} = client), do: client

  defp endpoint(client, <<"/"::utf8, _::binary>> = endpoint),
    do: client.site <> endpoint

  defp endpoint(_client, endpoint), do: endpoint
end