lib/ex_typesense/document.ex

defmodule ExTypesense.Document do
  @moduledoc since: "0.1.0"
  @moduledoc """
  Module for CRUD operations for documents. Refer to this [doc guide](https://typesense.org/docs/latest/api/documents.html).
  """

  alias ExTypesense.HttpClient
  import Ecto.Query, warn: false

  @collections_path "collections"
  @documents_path "documents"
  @search_path "search"
  @import_path "import"
  @api_header_name 'X-TYPESENSE-API-KEY'
  @type response :: {:ok, map()} | {:error, map()}

  @doc """
  Get a document from a collection by its document `id`.
  """
  @doc since: "0.1.0"
  @spec get_document(String.t()) :: response()
  def get_document(document_id) do
    path = Path.join([@collections_path, document_id])

    HttpClient.run(:get, path)
  end

  @doc """
  Search from a document.

  ## Examples
      iex> Document.search(Something, "umbrella", "title,description")
      {:ok,
       %{
        "facet_counts" => [],
        "found" => 20,
        "hits" => [...],
        "out_of" => 111,
        "page" => 1,
        "request_params" => %{
          "collection_name" => "something",
          "per_page" => 10,
          "q" => "umbrella"
        },
        "search_cutoff" => false,
        "search_time_ms" => 5
       }
      }

  """
  @doc since: "0.1.0"
  @spec search(module() | String.t(), String.t(), String.t()) :: response()
  def search(collection_name, search_term, query_by) do
    collection_name =
      if is_atom(collection_name) do
        collection_name.__schema__(:source)
      else
        collection_name
      end

    query = %{
      q: search_term,
      query_by: query_by
    }

    path =
      Path.join([
        @collections_path,
        collection_name,
        @documents_path,
        @search_path
      ])

    HttpClient.run(:get, path, nil, query)
  end

  @doc """
  Search from a document. Returns an Ecto query.

  ## Examples
      iex> Document.search(Something, "umbrella", "title,description")
      #Ecto.Query<...>

  """
  @doc since: "0.1.0"
  @spec ecto_search(module(), String.t(), String.t(), String.t()) :: Ecto.Query.t()
  def ecto_search(module_name, search_term, search_field, query_by) do
    query = %{
      q: search_term,
      query_by: query_by
    }

    path =
      Path.join([
        @collections_path,
        module_name.__schema__(:source),
        @documents_path,
        @search_path
      ])

    {:ok, result} = HttpClient.run(:get, path, nil, query)

    case Enum.empty?(result["hits"]) do
      true ->
        module_name
        |> where([i], field(i, ^search_field) in [])

      false ->
        values =
          Enum.map(result["hits"], fn %{"document" => document} ->
            get_in(document, ["slug"])
          end)

        search_field = String.to_existing_atom(search_field)

        module_name
        |> where([i], field(i, ^search_field) in ^values)
    end
  end

  @doc """
  Indexes multiple documents.

  ## Examples
      iex> posts = Posts |> Repo.all()

      iex> Document.index_multiple_documents(posts)
      [
        %{"success" => true},
        %{"success" => true},
        ...
      ]
  """
  @doc since: "0.1.0"
  @spec index_multiple_documents(list(struct())) :: list(map())
  def index_multiple_documents(list_of_structs) do
    payload =
      list_of_structs
      |> Stream.map(&Jason.encode!/1)
      |> Enum.join("\n")

    collection_name = hd(list_of_structs).__struct__.__schema__(:source)

    path =
      [
        @collections_path,
        collection_name,
        @documents_path,
        @import_path
      ]
      |> Path.join()

    url = %URI{
      host: HttpClient.get_host(),
      port: HttpClient.get_port(),
      scheme: HttpClient.get_scheme(),
      path: path,
      query: "action=create"
    }

    api_key = String.to_charlist(HttpClient.api_key())

    headers = [{@api_header_name, api_key}]
    content_type = 'text/plain;'

    request = {
      URI.to_string(url),
      headers,
      content_type,
      payload
    }

    http_opts = [
      ssl: [
        {:versions, [:"tlsv1.2"]},
        verify: :verify_peer,
        cacerts: :public_key.cacerts_get(),
        customize_hostname_check: [
          match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
        ]
      ],
      timeout: 5_000,
      recv_timeout: 5_000
    ]

    case :httpc.request(:post, request, http_opts, []) do
      {:ok, {_status_code, _headers, message}} ->
        message
        |> to_string()
        |> String.split("\n")
        |> Stream.map(&Jason.decode!/1)
        |> Enum.to_list()

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