lib/github/graphql.ex

defmodule GitHub.GraphQL do
  @doc """
  Query the GitHub GraphQL API

  Nothing fancy here like `GitHub.GraphQL.paginate`: the query is simply
  submitted and the response lightly parsed.

  The happy path is a response with a 200 status code and a body that contains
  a "data" field. If this is the case then `{:ok, data}` is returned.

  Otherwise various well known error conditions are recognized and will be
  returned as `{:error, some_error_value}`
  """
  def query(query, config, vars \\ %{first: 20}) do
    query
    |> call_graphql(config, vars)
    |> case do
      {:ok, %Neuron.Response{status_code: 200, body: %{"errors" => errors}}} ->
        {:error, errors}

      {:ok, %Neuron.Response{status_code: 200, body: %{"data" => data}}} ->
        {:ok, data}

      {:error, %Neuron.Response{body: body}} ->
        {:error, body}

      error ->
        {:error, error}
    end
  end

  @doc """
  Make a paginated query against the GitHub GraphQL API

  This requires that the query itself support pagination.

  1. The query must define `$first: Int` and `$after: String` variables
  2. The query must use the `$first` and `$after` variables
  3. The query must request `pageInfo { hasNextPage, endCursor }`

  ## Example query that supports automatic pagination

  ```graphql
  query($first: Int, $after: String) {
    viewer {
      followers(first: $first, after: $after) {
        pageInfo {
          endCursor
          hasNextPage
        }
        nodes {
          databaseId
        }
      }
    }
  }
  ```

  The `nodes` data will be collected into a list and returned.
  """
  def paginate(query, config, vars \\ %{first: 20}) do
    init = fn -> request(query, config, vars) end
    next = &next/1
    stop = &Function.identity/1

    Stream.resource(init, next, stop)
    |> Enum.into([])
    |> case do
      [] ->
        IO.puts("Empty results from pagination. Did your query have a `nodes` field?")
        pagination_usage()
        []
      nodes ->
        nodes
    end
  end

  defp request(query, config, vars) do
    query
    |> call_graphql(config, vars)
    |> case do
      {:ok, %Neuron.Response{status_code: 200, body: %{"errors" => errors}}} ->
        IO.inspect(errors)
        {[], nil, query, config, vars}

      {:ok, %Neuron.Response{status_code: 200, body: %{"data" => data}}} ->
        {GraphQL.find_nodes(data), GraphQL.find_pageinfo(data), query, config, vars}

      {:error, %Neuron.Response{body: body}} ->
        IO.inspect(body)
        {[], nil, query, config, vars}

      error ->
        IO.inspect(error)
        {[], nil, query, config, vars}
    end
  end

  defp next({nil, _query, _config, _vars} = acc), do: {:halt, acc}
  defp next({%{"hasNextPage" => false}, _query, _config, _vars} = acc), do: {:halt, acc}

  defp next({%{"endCursor" => endCursor}, query, config, vars} = acc) do
    results = request(query, config, Map.put(vars, :after, endCursor))

    case results do
      {_data, %{"endCursor" => ^endCursor}, _query, _config, _vars} ->
        IO.puts("endCursor is not advancing: halting queries")
        pagination_usage()
        {:halt, acc}

      _ ->
        next(results)
    end
  end

  defp next({data, page, query, config, args}) do
    {data, {page, query, config, args}}
  end

  defp call_graphql(query, config, vars) do
    Neuron.query(query, vars,
      url: config[:endpoint],
      headers: [authorization: "Bearer #{config[:token]}"]
    )
  end

  defp pagination_usage() do
    IO.puts("For pagination to work your query must meet the requirements")
    IO.puts("""
    query($first: Int, $after: String) {
      viewer {
        followers(first: $first, after: $after) {
          pageInfo {
            endCursor
            hasNextPage
          }
          nodes {
            databaseId
          }
        }
      }
    }
    """)
  end
end