Current section

Files

Jump to
algolia lib algolia.ex
Raw

lib/algolia.ex

defmodule Algolia do
@moduledoc """
Elixir implementation of Algolia search API, using Hackney for http requests
"""
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 \\ [strategy: :none]) do
strategy = opts[:strategy]
params =
case strategy do
:none -> "?strategy=none"
:stop_if_enough_matches -> "?strategy=stopIfEnoughMatches"
_ -> ""
end
path = "*/queries" <> params
body = queries |> format_multi() |> Jason.encode!()
send_request(:read, :post, path, body)
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
opts =
opts
|> Keyword.put(:query, query)
|> Enum.map(fn {k, v} ->
v = if is_list(v), do: Enum.join(v, ","), else: v
{k, v}
end)
path = index <> "?" <> URI.encode_query(opts)
send_request(:read, :get, path)
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" => "Pteri<em>dophyta</em>",
"value" => "Pteridophyta"
},
%{
"count" => 7000,
"highlighted" => "Rho<em>dophyta</em>",
"value" => "Rhodophyta"
},
%{
"count" => 150,
"highlighted" => "Cyca<em>dophyta</em>",
"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 = index <> "/facets/" <> URI.encode(facet) <> "/query"
body =
query
|> Map.put("facetQuery", text)
|> Jason.encode!()
send_request(:read, :post, path, body)
end
defp send_request(read_or_write, method, path) do
send_request(read_or_write, method, path, "", 0)
end
defp send_request(read_or_write, method, path, body) do
send_request(read_or_write, method, path, body, 0)
end
defp send_request(_, _, _, _, 4) do
{:error, "Unable to connect to Algolia"}
end
defp send_request(read_or_write, method, path, body, curr_retry) do
url =
"https://"
|> Path.join(host(read_or_write, curr_retry))
|> Path.join("/1/indexes")
|> Path.join(path)
headers = [
"X-Algolia-API-Key": api_key(),
"X-Algolia-Application-Id": application_id()
]
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, body} when code in 200..299 ->
{:ok, Jason.decode!(body)}
{:ok, code, _, body} ->
{:error, code, body}
_ ->
send_request(read_or_write, method, path, body, curr_retry + 1)
end
end
@doc """
Get an object in an index by objectID
"""
def get_object(index, object_id) do
path = "#{index}/#{object_id}"
:read
|> send_request(:get, path)
|> inject_index_into_response(index)
end
@doc """
Add an Object
"""
def add_object(index, object) do
body = Jason.encode!(object)
path = "#{index}"
:write
|> send_request(:post, path, body)
|> inject_index_into_response(index)
end
@doc """
Add an object with an attribute as the objectID
"""
def add_object(index, object, id_attribute: id_attribute) do
save_object(index, object, id_attribute: id_attribute)
end
@doc """
Add multiple objects
"""
def add_objects(index, objects) do
objects
|> build_batch_request("addObject")
|> send_batch_request(index)
end
@doc """
Add multiple objects, with an attribute as objectID
"""
def add_objects(index, objects, id_attribute: id_attribute) do
save_objects(index, objects, id_attribute: id_attribute)
end
@doc """
Save a single object, without objectID specified, must have objectID as
a field
"""
def save_object(index, object, id_attribute: id_attribute) do
object_id = object[id_attribute] || object[to_string(id_attribute)]
if !object_id do
raise ArgumentError,
message: "Your object #{object} does not have a attribute #{id_attribute}"
end
save_object(index, object, object_id)
end
def save_object(index, object, object_id) when is_map(object) do
body = Jason.encode!(object)
path = "#{index}/#{object_id}"
:write
|> send_request(:put, path, body)
|> inject_index_into_response(index)
end
def save_object(index, object) when is_map(object) do
object_id = object["objectID"] || object[:objectID]
if !object_id do
raise ArgumentError,
message: "Your object must have an objectID to be saved using save_object"
end
body = Jason.encode!(object)
path = "#{index}/#{object_id}"
:write
|> send_request(:put, path, body)
|> inject_index_into_response(index)
end
@doc """
Save multiple objects
"""
def save_objects(index, objects, id_attribute: id_attribute) when is_list(objects) do
objects
|> add_object_ids(id_attribute: id_attribute)
|> build_batch_request("updateObject")
|> send_batch_request(index)
end
def save_objects(index, objects) when is_list(objects) do
objects
|> build_batch_request("updateObject")
|> send_batch_request(index)
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)
params =
if opts[:upsert?] do
""
else
"?createIfNotExists=false"
end
path = "#{index}/#{object_id}/partial" <> URI.encode(params)
:write
|> send_request(:post, path, body)
|> 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)
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) do
path = "/#{index}/batch"
body = Jason.encode!(requests)
:write
|> send_request(:post, path, body)
|> 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, "") do
{:error, %InvalidObjectIDError{}}
end
def delete_object(index, object_id) do
path = "#{index}/#{object_id}"
:write
|> send_request(:delete, path)
|> inject_index_into_response(index)
end
@doc """
Delete multiple objects
"""
def delete_objects(index, object_ids) do
object_ids
|> Enum.map(fn id ->
%{objectID: id}
end)
|> build_batch_request("deleteObject")
|> send_batch_request(index)
end
@doc """
List all indexes
"""
def list_indexes do
send_request(:read, :get, "")
end
@doc """
Deletes the index
"""
def delete_index(index) do
path = "#{index}"
:write
|> send_request(:delete, path)
|> inject_index_into_response(index)
end
@doc """
Clears all content of an index
"""
def clear_index(index) do
path = "#{index}/clear"
:write
|> send_request(:post, path)
|> inject_index_into_response(index)
end
@doc """
Set the settings of a index
"""
def set_settings(index, settings) do
body = Jason.encode!(settings)
:write
|> send_request(:put, "/#{index}/settings", body)
|> inject_index_into_response(index)
end
@doc """
Get the settings of a index
"""
def get_settings(index) do
:read
|> send_request(:get, "/#{index}/settings")
|> 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(:post, "/#{src_index}/operation", 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(:post, "/#{src_index}/operation", 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 """
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, :get, "#{index}/task/#{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