lib/graphql/utils.ex

defmodule BridgeEx.Graphql.Utils do
  @moduledoc """
  Misc utils for handling Graphql requests/responses.
  """

  alias BridgeEx.Graphql.LanguageConventions
  require Logger

  @type client_response :: {:ok, any()} | {:error, any()}

  @type graphql_response ::
          {:error, String.t()}
          | {:ok, %{data: term()}}
          | {
              :ok,
              %{
                error: [
                  %{message: String.t(), locations: [%{line: integer(), column: integer()}]}
                ],
                data: term()
              }
            }

  @spec decode_http_response(
          {:ok, HTTPoison.Response.t() | HTTPoison.AsyncResponse.t()}
          | {:error, HTTPoison.Error.t()},
          String.t()
        ) :: client_response()
  def decode_http_response({:ok, %HTTPoison.Response{status_code: 200, body: body_string}}, _) do
    Jason.decode(body_string, keys: :atoms)
  end

  def decode_http_response(
        {:ok,
         %HTTPoison.Response{status_code: code, body: body_string, request_url: request_url}},
        query
      ) do
    Logger.error("GraphQL: Bad Response error",
      status_code: code,
      body_string: body_string,
      request_url: request_url,
      request_body: query
    )

    {:error, "BAD_RESPONSE"}
  end

  def decode_http_response({:error, %HTTPoison.Error{reason: reason}}, query) do
    Logger.error("GraphQL: HTTP error", reason: inspect(reason), request_body: query)

    {:error, "HTTP_ERROR"}
  end

  @spec parse_response(graphql_response()) ::
          client_response()
  def parse_response({:error, error}) when is_binary(error), do: {:error, error}

  def parse_response({:ok, %{errors: errors}}) do
    errors =
      errors
      |> Enum.map(& &1.message)
      |> Enum.join(", ")

    {:error, errors}
  end

  def parse_response({:ok, %{data: data}}), do: {:ok, data}

  @spec normalize_inner_fields(%{atom() => any()} | String.t()) :: %{atom() => any()} | String.t()
  def normalize_inner_fields(binary) when is_binary(binary), do: binary
  def normalize_inner_fields(map = %{}), do: Enum.reduce(map, %{}, &do_normalize_inner_fields/2)

  @spec retry(
          {:ok, String.t()} | {:error, Jason.EncodeError.t() | Exception.t()},
          (any() -> {:error, String.t()} | {:ok, any()}),
          integer()
        ) :: client_response()
  def retry({:error, %Jason.EncodeError{message: message}}, _fun, _attempt) do
    {:error, message}
  end

  def retry({:ok, arg}, fun, 1) do
    fun.(arg)
  end

  def retry({:ok, arg}, fun, n) do
    case fun.(arg) do
      {:error, _reason} ->
        Process.sleep(500)
        retry({:ok, arg}, fun, n - 1)

      val ->
        val
    end
  end

  @spec do_normalize_inner_fields({atom(), any()}, map()) :: %{atom() => any()}
  defp do_normalize_inner_fields({key, value}, acc) when is_map(value) do
    Map.merge(acc, %{to_snake_case(key) => normalize_inner_fields(value)})
  end

  defp do_normalize_inner_fields({key, value}, acc) when is_list(value) do
    Map.merge(acc, %{to_snake_case(key) => Enum.map(value, &normalize_inner_fields/1)})
  end

  defp do_normalize_inner_fields({key, value}, acc) do
    Map.merge(acc, %{to_snake_case(key) => value})
  end

  @spec to_snake_case(atom() | String.t()) :: atom() | String.t()
  defp to_snake_case(formattable) when is_binary(formattable),
    do: LanguageConventions.to_internal_name(formattable, :read)

  defp to_snake_case(formattable) when is_atom(formattable) do
    formattable
    |> Atom.to_string()
    |> LanguageConventions.to_internal_name(:read)
    |> String.to_atom()
  end
end