defmodule Algolia do
@moduledoc """
Elixir implementation of Algolia search API, using Hackney for http requests
"""
alias Algolia.Paths
defmodule MissingApplicationIDError do
defexception message: """
The `application_id` settings is required to use Algolia. Please include your
application_id in your application config file like so:
config :algolia, application_id: YOUR_APPLICATION_ID
Alternatively, you can also set the secret key as an environment variable:
ALGOLIA_APPLICATION_ID=YOUR_APP_ID
"""
end
defmodule MissingAPIKeyError do
defexception message: """
The `api_key` settings is required to use Algolia. Please include your
api key in your application config file like so:
config :algolia, api_key: YOUR_API_KEY
Alternatively, you can also set the secret key as an environment variable:
ALGOLIA_API_KEY=YOUR_SECRET_API_KEY
"""
end
defmodule InvalidObjectIDError do
defexception message: "The ObjectID cannot be an empty string"
end
def application_id do
System.get_env("ALGOLIA_APPLICATION_ID") || Application.get_env(:algolia, :application_id) ||
raise MissingApplicationIDError
end
def api_key do
System.get_env("ALGOLIA_API_KEY") || Application.get_env(:algolia, :api_key) ||
raise MissingAPIKeyError
end
defp host(:read, 0), do: "#{application_id()}-dsn.algolia.net"
defp host(:write, 0), do: "#{application_id()}.algolia.net"
defp host(_read_or_write, curr_retry) when curr_retry <= 3,
do: "#{application_id()}-#{curr_retry}.algolianet.com"
@doc """
Multiple queries
"""
def multi(queries, opts \\ []) do
path = Paths.multiple_queries(opts[:strategy])
body = queries |> format_multi() |> Jason.encode!()
send_request(:read, %{method: :post, path: path, body: body, options: opts[:request_options]})
end
defp format_multi(queries) do
requests =
Enum.map(queries, fn query ->
index_name = query[:index_name] || query["index_name"]
if !index_name,
do: raise(ArgumentError, message: "Missing index_name for one of the multiple queries")
params =
query
|> Map.delete(:index_name)
|> Map.delete("index_name")
|> URI.encode_query()
%{indexName: index_name, params: params}
end)
%{requests: requests}
end
@doc """
Search a single index
"""
def search(index, query, opts \\ []) do
{request_options, opts} = Keyword.pop(opts, :request_options)
path = Paths.search(index, query, opts)
send_request(:read, %{method: :get, path: path, options: request_options})
end
@doc """
Search for facet values
Enables you to search through the values of a facet attribute, selecting
only a **subset of those values that meet a given criteria**.
For a facet attribute to be searchable, it must have been declared in the
`attributesForFaceting` index setting with the `searchable` modifier.
Facet-searching only affects facet values. It does not impact the underlying
index search.
The results are **sorted by decreasing count**. This can be adjusted via
`sortFacetValuesBy`.
By default, maximum **10 results are returned**. This can be adjusted via
`maxFacetHits`.
## Examples
iex> Algolia.search_for_facet_values("species", "phylum", "dophyta")
{
:ok,
%{
"exhaustiveFacetsCount" => false,
"faceHits" => [
%{
"count" => 9000,
"highlighted" => "Pteridophyta",
"value" => "Pteridophyta"
},
%{
"count" => 7000,
"highlighted" => "Rhodophyta",
"value" => "Rhodophyta"
},
%{
"count" => 150,
"highlighted" => "Cycadophyta",
"value" => "Cycadophyta"
}
],
"processingTimeMS" => 42
}
}
"""
@spec search_for_facet_values(binary, binary, binary, map) ::
{:ok, map} | {:error, code :: integer, message :: binary}
def search_for_facet_values(index, facet, text, query \\ %{})
when is_binary(index) and is_binary(facet) and is_binary(text) do
path = Paths.search_facet(index, facet)
body =
query
|> Map.put("facetQuery", text)
|> Jason.encode!()
send_request(:read, %{method: :post, path: path, body: body})
end
defp send_request(read_or_write, request, curr_retry \\ 0)
defp send_request(_read_or_write, _request, 4) do
{:error, "Unable to connect to Algolia"}
end
defp send_request(read_or_write, request, curr_retry) do
url = request_url(read_or_write, curr_retry, request[:path])
headers = request_headers(request[:options] || [])
body = request[:body] || ""
request[:method]
|> :hackney.request(url, headers, body, [
:with_body,
path_encode_fun: &URI.encode/1,
connect_timeout: 3_000 * (curr_retry + 1),
recv_timeout: 30_000 * (curr_retry + 1),
ssl_options: [{:versions, [:"tlsv1.2"]}]
])
|> case do
{:ok, code, _headers, response} when code in 200..299 ->
{:ok, Jason.decode!(response)}
{:ok, code, _, response} ->
{:error, code, response}
_ ->
send_request(read_or_write, request, curr_retry + 1)
end
end
defp request_url(read_or_write, retry, path) do
"https://"
|> Path.join(host(read_or_write, retry))
|> Path.join(path)
end
defp request_headers(request_options) do
custom = request_options[:headers] || []
default = [
{"X-Algolia-API-Key", api_key()},
{"X-Algolia-Application-Id", application_id()}
]
custom ++ default
end
@doc """
Get an object in an index by objectID
"""
def get_object(index, object_id, opts \\ []) do
path = Paths.object(index, object_id)
:read
|> send_request(%{method: :get, path: path, options: opts[:request_options]})
|> inject_index_into_response(index)
end
@doc """
Add an Object
An attribute can be chosen as the objectID.
"""
def add_object(index, object, opts \\ []) do
if opts[:id_attribute] do
save_object(index, object, opts)
else
body = Jason.encode!(object)
path = Paths.index(index)
:write
|> send_request(%{method: :post, path: path, body: body, options: opts[:request_options]})
|> inject_index_into_response(index)
end
end
@doc """
Add multiple objects
An attribute can be chosen as the objectID.
"""
def add_objects(index, objects, opts \\ []) do
if opts[:id_attribute] do
save_objects(index, objects, opts)
else
objects
|> build_batch_request("addObject")
|> send_batch_request(index, opts[:request_options])
end
end
@doc """
Save a single object, without objectID specified, must have objectID as
a field
"""
def save_object(index, object, opts \\ [])
def save_object(index, object, id) when is_map(object) and not is_list(id) do
save_object(index, object, id, [])
end
def save_object(index, object, opts) when is_map(object) do
id = object_id_for_save!(object, opts)
save_object(index, object, id, opts[:request_options])
end
defp object_id_for_save!(object, opts) do
if id_attribute = opts[:id_attribute] do
object[id_attribute] || object[to_string(id_attribute)] ||
raise ArgumentError,
message: "Your object does not have a '#{id_attribute}' attribute"
else
object["objectID"] || object[:objectID] ||
raise ArgumentError,
message: "Your object must have an objectID to be saved using save_object"
end
end
defp save_object(index, object, object_id, request_options) do
body = Jason.encode!(object)
path = Paths.object(index, object_id)
:write
|> send_request(%{method: :put, path: path, body: body, options: request_options})
|> inject_index_into_response(index)
end
@doc """
Save multiple objects
"""
def save_objects(index, objects, opts \\ [id_attribute: :objectID]) when is_list(objects) do
id_attribute = opts[:id_attribute] || :objectID
objects
|> add_object_ids(id_attribute: id_attribute)
|> build_batch_request("updateObject")
|> send_batch_request(index, opts[:request_options])
end
@doc """
Partially updates an object, takes option upsert: true or false
"""
def partial_update_object(index, object, object_id, opts \\ [upsert?: true]) do
body = Jason.encode!(object)
path = Paths.partial_object(index, object_id, opts[:upsert?])
:write
|> send_request(%{method: :post, path: path, body: body, options: opts[:request_options]})
|> inject_index_into_response(index)
end
@doc """
Partially updates multiple objects
"""
def partial_update_objects(index, objects, opts \\ [upsert?: true, id_attribute: :objectID]) do
id_attribute = opts[:id_attribute] || :objectID
upsert =
case opts[:upsert?] do
false -> false
_ -> true
end
action = if upsert, do: "partialUpdateObject", else: "partialUpdateObjectNoCreate"
objects
|> add_object_ids(id_attribute: id_attribute)
|> build_batch_request(action)
|> send_batch_request(index, opts[:request_options])
end
# No need to add any objectID by default
defp add_object_ids(objects, id_attribute: :objectID), do: objects
defp add_object_ids(objects, id_attribute: "objectID"), do: objects
defp add_object_ids(objects, id_attribute: attribute) do
Enum.map(objects, fn object ->
object_id = object[attribute] || object[to_string(attribute)]
if !object_id do
raise ArgumentError, message: "id attribute `#{attribute}` doesn't exist"
end
add_object_id(object, object_id)
end)
end
defp add_object_id(object, object_id) do
Map.put(object, :objectID, object_id)
end
defp get_object_id(object) do
case object[:objectID] || object["objectID"] do
nil -> {:error, "Not objectID found"}
object_id -> {:ok, object_id}
end
end
defp send_batch_request(requests, index, request_options) do
path = Paths.batch(index)
body = Jason.encode!(requests)
:write
|> send_request(%{method: :post, path: path, body: body, options: request_options})
|> inject_index_into_response(index)
end
defp build_batch_request(objects, action) do
requests =
Enum.map(objects, fn object ->
case get_object_id(object) do
{:ok, object_id} -> %{action: action, body: object, objectID: object_id}
_ -> %{action: action, body: object}
end
end)
%{requests: requests}
end
@doc """
Delete a object by its objectID
"""
def delete_object(index, object_id, opts \\ [])
def delete_object(_index, "", _request_options) do
{:error, %InvalidObjectIDError{}}
end
def delete_object(index, object_id, opts) do
path = Paths.object(index, object_id)
:write
|> send_request(%{method: :delete, path: path, options: opts[:request_options]})
|> inject_index_into_response(index)
end
@doc """
Delete multiple objects
"""
def delete_objects(index, object_ids, opts \\ []) do
object_ids
|> Enum.map(fn id ->
%{objectID: id}
end)
|> build_batch_request("deleteObject")
|> send_batch_request(index, opts[:request_options])
end
@doc """
Remove all objects matching a filter (including geo filters).
Allowed filter parameters:
* `filters`
* `facetFilters`
* `numericFilters`
* `aroundLatLng` and `aroundRadius` (these two need to be used together)
* `insideBoundingBox`
* `insidePolygon`
## Examples
iex> Algolia.delete_by("index", filters: ["score < 30"])
{:ok, %{"indexName" => "index", "taskId" => 42, "deletedAt" => "2018-10-30T15:33:13.556Z"}}
"""
def delete_by(index, opts) when is_list(opts) do
{request_options, opts} = Keyword.pop(opts, :request_options)
path = Paths.delete_by(index)
body =
opts
|> sanitize_delete_by_opts()
|> validate_delete_by_opts!()
|> Map.new()
|> Jason.encode!()
:write
|> send_request(%{method: :post, path: path, body: body, options: request_options})
|> inject_index_into_response(index)
end
defp sanitize_delete_by_opts(opts) do
Keyword.drop(opts, [
:hitsPerPage,
:attributesToRetrieve,
"hitsPerPage",
"attributesToRetrieve"
])
end
defp validate_delete_by_opts!([]) do
raise ArgumentError, message: "opts are required, use `clear_index/1` to wipe the index."
end
defp validate_delete_by_opts!(opts), do: opts
@doc """
List all indexes
"""
def list_indexes do
send_request(:read, %{method: :get, path: Paths.indexes()})
end
@doc """
Deletes the index
"""
def delete_index(index) do
:write
|> send_request(%{method: :delete, path: Paths.index(index)})
|> inject_index_into_response(index)
end
@doc """
Clears all content of an index
"""
def clear_index(index) do
:write
|> send_request(%{method: :post, path: Paths.clear(index)})
|> inject_index_into_response(index)
end
@doc """
Set the settings of a index
"""
def set_settings(index, settings) do
body = Jason.encode!(settings)
path = Paths.settings(index)
:write
|> send_request(%{method: :put, path: path, body: body})
|> inject_index_into_response(index)
end
@doc """
Get the settings of a index
"""
def get_settings(index) do
:read
|> send_request(%{method: :get, path: Paths.settings(index)})
|> inject_index_into_response(index)
end
@doc """
Moves an index to new one
"""
def move_index(src_index, dst_index) do
body = Jason.encode!(%{operation: "move", destination: dst_index})
:write
|> send_request(%{method: :post, path: Paths.operation(src_index), body: body})
|> inject_index_into_response(src_index)
end
@doc """
Copies an index to a new one
"""
def copy_index(src_index, dst_index) do
body = Jason.encode!(%{operation: "copy", destination: dst_index})
:write
|> send_request(%{method: :post, path: Paths.operation(src_index), body: body})
|> inject_index_into_response(src_index)
end
## Helps piping a response into wait_task, as it requires the index
defp inject_index_into_response({:ok, body}, index) do
{:ok, Map.put(body, "indexName", index)}
end
defp inject_index_into_response(response, _index), do: response
@doc """
Get the logs of the latest search and indexing operations.
## Options
* `:indexName` - Index for which log entries should be retrieved. When omitted,
log entries are retrieved across all indices.
* `:length` - Maximum number of entries to retrieve. Maximum allowed value: 1000.
* `:offset` - First entry to retrieve (zero-based). Log entries are sorted by
decreasing date, therefore 0 designates the most recent log entry.
* `:type` - Type of log to retrieve: `all` (default), `query`, `build` or `error`.
"""
def get_logs(opts \\ []) do
send_request(:write, %{method: :get, path: Paths.logs(opts)})
end
@doc """
Wait for a task for an index to complete
returns :ok when it's done
"""
def wait_task(index, task_id, time_before_retry \\ 1000) do
case send_request(:write, %{method: :get, path: Paths.task(index, task_id)}) do
{:ok, %{"status" => "published"}} ->
:ok
{:ok, %{"status" => "notPublished"}} ->
:timer.sleep(time_before_retry)
wait_task(index, task_id, time_before_retry)
other ->
other
end
end
@doc """
Convinient version of wait_task/4, accepts a response to be waited on
directly. This enables piping a operation directly into wait_task
"""
def wait(response = {:ok, %{"indexName" => index, "taskID" => task_id}}, time_before_retry) do
with :ok <- wait_task(index, task_id, time_before_retry), do: response
end
def wait(response = {:ok, _}), do: wait(response, 1000)
def wait(response = {:error, _}), do: response
def wait(response), do: response
end