lib/snap/multi/multi.ex

defmodule Snap.Multi do
  @moduledoc """
  Provides a high level abstraction over the [Multi Search
  API](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-multi-search.html)
  which allows the user to perform multiple searches in a single request.

  Example usage:

      Multi.new()
      |> Multi.add(query_1, id: "query-1")
      |> Multi.add(query_2, id: "query-2")
      |> Multi.run(Cluster, index)

  This returns a `Snap.Multi.Response`, with a `searches` field containing a
  list of responses.

      {:ok, %Snap.Multi.Response{searches: [
        {"query-1", %Snap.SearchResponse{...}},
        {"query-2", %Snap.SearchResponse{...}}
      ]}}

  Each query can be named, using any value you like, by passing an `id: "foo"`
  into `Multi.add`. The list in `Snap.Multi.Response` contains tuple pairs where
  the first value is the ID and the second is the result of the query.

  If you choose not to name a query the ID in the tuple will be `nil`.

  If your query IDs are unique you can convert this to a `Map` for easy lookup
  later using `Enum.into(response.searches, %{})`.
  """

  defstruct searches: []

  @type t :: %__MODULE__{searches: list()}

  alias Snap.Cluster.Namespace
  alias Snap.Multi.Response
  alias Snap.Multi.Search

  @doc """
  Build a `Snap.Multi` request.
  """
  @spec new() :: t()
  def new() do
    %__MODULE__{}
  end

  @doc """
  Append to a `Snap.Multi` request. The `body` is required. If you pass an `id`
  into the headers, this will be used to name the query in the responses list
  and won't be passed through as a header in the request.
  """
  @spec add(t(), map(), Keyword.t()) :: t()
  def add(%__MODULE__{} = multi, body, headers \\ []) do
    {id, headers} = Keyword.pop(headers, :id)
    search = %Search{id: id, body: body, headers: headers}

    %__MODULE__{multi | searches: multi.searches ++ [search]}
  end

  @doc """
  Perform the `Snap.Multi` request. This returns `{:ok, Snap.Multi.Response}` or
  an error.
  """
  @spec run(t(), atom(), String.t(), Keyword.t(), Keyword.t(), Keyword.t()) ::
          {:ok, Snap.Multi.Response.t()} | {:error, Snap.Cluster.error()}
  def run(%__MODULE__{} = multi, cluster, index_or_alias, params \\ [], headers \\ [], opts \\ []) do
    ids = build_ids(multi.searches)
    body = encode(multi)
    headers = headers ++ [{"content-type", "application/x-ndjson"}]
    namespaced_index = Namespace.add_namespace_to_index(index_or_alias, cluster)

    case cluster.post("/#{namespaced_index}/_msearch", body, params, headers, opts) do
      {:ok, response} -> {:ok, Response.new(response, ids)}
      err -> err
    end
  end

  defp encode(%__MODULE__{} = multi) do
    multi.searches
    |> Enum.flat_map(&encode_search/1)
  end

  defp encode_search(%Search{headers: headers, body: body}) do
    [encode_headers(headers), "\n", encode_body(body), "\n"]
  end

  defp encode_headers(headers) do
    headers
    |> Enum.into(%{})
    |> Jason.encode!(pretty: false)
  end

  defp encode_body(body) do
    Jason.encode!(body, pretty: false)
  end

  defp build_ids(searches) do
    Enum.map(searches, & &1.id)
  end
end