lib/request.ex

defmodule MicrosoftGraph.Request do
  @moduledoc """
  Lower-level API to create and execute Microsoft Graph API requests.
  """
  alias MicrosoftGraph.Request

  @type t() :: %Request{
          method: atom(),
          url: URI.t(),
          headers: [{binary(), binary()}],
          params: map(),
          options: [{:cast, atom()}, {:merge_query_params, boolean()}]
        }

  defstruct method: :get,
            url: URI.new(""),
            headers: [],
            params: %{},
            options: []

  @doc """
  Create a GET request.

  See `new/1` for options.
  """
  def get(path, options \\ []) do
    options |> put_path_in_options(path) |> new()
  end

  @doc """
  Create a POST request.

  See `new/1` for options.
  """
  def post(path, options \\ []) do
    options
    |> put_path_in_options(path)
    |> put_method_in_options(:post)
    |> new()
  end

  @doc """
  Create a PUT request.

  See `new/1` for options.
  """
  def put(path, options \\ []) do
    options
    |> put_path_in_options(path)
    |> put_method_in_options(:put)
    |> new()
  end

  @doc """
  Create a PATCH request.

  See `new/1` for options.
  """
  def patch(path, options \\ []) do
    options
    |> put_path_in_options(path)
    |> put_method_in_options(:patch)
    |> new()
  end

  @doc """
  Create a DELETE request.

  See `new/1` for options.
  """
  def delete(path, options \\ []) do
    options
    |> put_path_in_options(path)
    |> put_method_in_options(:delete)
    |> new()
  end

  @doc """
  Create a request. You generally should use the other request constructors like `get/2` and `post/2`.

  Basic request options:

  - `:method` - the request method, defaults to :get.
  - `:url` - the request URL.
  - `:headers` - the request headers.
    The headers are automatically encoded using these rules:
    - atom header names are turned into strings
    - string header names are left as is.
  - `:params` - the request params (querystring for get, post body for post,put,patch).
  - `:cast` - OData cast, defaults to nothing
    Options are:
    - `:user` for `microsoft.graph.user`
    - `:group` for `microsoft.graph.group`

  """
  def new(options \\ []) do
    {method, options} = Keyword.pop(options, :method, :get)
    {url, options} = Keyword.pop(options, :url, "")
    {params, options} = Keyword.pop(options, :params, %{})
    {headers, options} = Keyword.pop(options, :headers, [])

    struct!(Request, %{
      method: method,
      url: url,
      params: MicrosoftGraph.Utils.Map.keys_to_strings(params),
      headers: headers,
      options: options
    })
    |> handle_query_params()
    |> handle_cast_option()
  end

  @doc """
  Executes a given request.

  ## Example

      iex> MicrosoftGraph.Request.execute(%Request{}, %OAuth2.Client{})
      {:ok, response}

      # Bad request
      iex> MicrosoftGraph.Request.execute(%Request{}, %OAuth2.Client{})
      {:error, response}

      # Unsupported method
      iex> MicrosoftGraph.Request.execute(%Request{method: :grab}, %OAuth2.Client{})
      {:error, :unsupported_method}

  """
  def execute(%Request{} = request, client) do
    case request.method do
      :get ->
        OAuth2.Client.get(client, request.url.path, request.headers, params: request.params)

      :get_paginated ->
        get_paginated_results(request, client)

      :post ->
        OAuth2.Client.post(client, request.url.path, request.params, request.headers)

      :put ->
        OAuth2.Client.put(client, request.url.path, request.params, request.headers)

      :patch ->
        OAuth2.Client.patch(client, request.url.path, request.params, request.headers)

      :delete ->
        OAuth2.Client.delete(client, request.url.path, request.params, request.headers)

      _ ->
        {:error, :unsupported_method}
    end
    |> case do
      {:ok, response} ->
        {:ok, response}

      {:error, %{body: %{"error" => %{"code" => "InvalidAuthenticationToken"}}}} ->
        {:error, :invalid_authentication_token}

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

  @doc """
  Puts a header into the given request.

  ## Example

      iex> MicrosoftGraph.Request.put_header(%Request{}, "Content-Type", "application/json")
      %Request{}

  """
  def put_header(%Request{} = request, key, value) when is_binary(key) and is_binary(value) do
    %{request | headers: List.keystore(request.headers, key, 0, {key, value})}
  end

  @doc """
  Will make request a paginated request. `execute/2` will follow the `@odata.nextLink` in the response
  if it exists to fetch the next page. Method type will be `:get_paginated`.
  """
  def paginate_results(%Request{} = request) do
    Map.put(request, :method, :get_paginated)
  end

  @doc """
  Puts the advanced query params and headers required for some operations.

  https://docs.microsoft.com/en-us/graph/aad-advanced-queries?tabs=http
  """
  def put_advanced_query_params(%Request{} = request) do
    params = MicrosoftGraph.Utils.Map.deep_merge(%{"$count" => true}, request.params)

    headers =
      MicrosoftGraph.Utils.Keyword.deep_merge([ConsistencyLevel: "eventual"], request.headers)

    request
    |> Map.put(:params, params)
    |> Map.put(:headers, headers)
  end

  defp get_paginated_results(%Request{} = request, client, results \\ []) do
    case OAuth2.Client.get(client, request.url.path, request.headers, params: request.params) do
      {:ok, %{body: %{"@odata.nextLink" => next_link, "value" => page_results}}} ->
        # There is another page, collect users and load next page
        get(next_link, params: request.params, headers: request.headers)
        |> paginate_results()
        |> get_paginated_results(client, List.flatten(results, page_results))

      {:ok, %{body: %{"value" => page_results}}} ->
        # Last or only page
        {:ok, List.flatten(results, page_results)}

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

  defp handle_query_params(%Request{url: %{query: nil}} = request), do: request

  defp handle_query_params(%Request{url: %{query: query}} = request) do
    # Merge or replace the params with the querystring params from the provided path
    # Defaults to replace, can be merged with `merge_query_params: true` in request options
    if Keyword.get(request.options, :merge_query_params, false) do
      merged_params =
        request.params |> MicrosoftGraph.Utils.Map.deep_merge(URI.decode_query(query))

      request |> Map.put(:params, merged_params)
    else
      request |> Map.put(:params, URI.decode_query(query))
    end
  end

  defp handle_cast_option(%Request{} = request) do
    # If a cast option was provided, we need to add the case to the path
    path =
      case Keyword.get(request.options, :cast) do
        :user ->
          String.trim_trailing(request.url.path, "/") <> "/microsoft.graph.user"

        :group ->
          String.trim_trailing(request.url.path, "/") <> "/microsoft.graph.group"

        nil ->
          request.url.path
      end

    url = request.url |> Map.put(:path, path)
    request |> Map.put(:url, url)
  end

  defp put_path_in_options(options, path) do
    uri = URI.parse(path)
    List.keystore(options, :url, 0, {:url, uri})
  end

  defp put_method_in_options(options, method) do
    List.keystore(options, :method, 0, {:method, method})
  end
end