diff --git a/.gitignore b/.gitignore index cf5a733..84eaea9 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ config # Ignore qdrant_storage folder qdrant_storage/ + +# Ignore .env file +.env +.envrc \ No newline at end of file diff --git a/.tool-versions b/.tool-versions index e465254..9ace962 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -elixir 1.16.3-otp-26 -erlang 26.2.5.3 \ No newline at end of file +elixir 1.19.1-otp-28 +erlang 28.1.1 diff --git a/README.md b/README.md index 8ac0403..a9d69fe 100644 --- a/README.md +++ b/README.md @@ -16,53 +16,188 @@ def deps do [ {:qdrant, "~> 0.8.0"} # Or use the latest version from GitHub | Recommended during development phase - {:qdrant, git: "git@github.com:marinac-dev/qdrant.git"}, + {:qdrant, github: "marinac-dev/qdrant.git", branch: "master"}, ] end ``` -## Config +## Configuration + +Configure the Qdrant client in your `config/config.exs`: + +### Local/Docker Setup (Default) ```elixir config :qdrant, - port: 6333, interface: "rest", # gRPC not yet supported - database_url: System.get_env("QDRANT_DATABASE_URL"), - # If you are using cloud version of Qdrant, add API key - api_key: System.get_env("QDRANT_API_KEY") + # Option 1: Use full URL (recommended) + url: System.get_env("QDRANT_URL") || "http://localhost:6333", + # Option 2: Use separate URL and port (for backward compatibility) + # database_url: System.get_env("QDRANT_DATABASE_URL") || "http://localhost", + # port: 6333, + require_api_key: false # Default: false for local/docker instances +``` + +### Qdrant Cloud Setup + +```elixir +config :qdrant, + interface: "rest", + url: System.get_env("QDRANT_URL"), # e.g., "https://your-cluster.cloud.qdrant.io" + require_api_key: true, # Required for Qdrant Cloud (auto-detected if URL contains cloud.qdrant.io) + api_key: System.get_env("QDRANT_API_KEY") # Required when require_api_key is true ``` +**Note:** The client automatically detects Qdrant Cloud instances and requires an API key when: +- The URL contains `cloud.qdrant.io`, or +- The URL uses HTTPS and is not localhost + +You can also explicitly set `require_api_key: true` to force API key authentication. + +Alternatively, you can set these via environment variables: +- `QDRANT_URL` - Full Qdrant server URL (e.g., `http://localhost:6333`) - takes priority if set +- `QDRANT_DATABASE_URL` - Qdrant server URL without port (default: `http://localhost`) +- `QDRANT_PORT` - Qdrant server port (default: `6333`) +- `QDRANT_REQUIRE_API_KEY` - Whether API key is required (default: `false`, auto-detected for Qdrant Cloud) +- `QDRANT_API_KEY` - API key for authentication (required if `require_api_key` is true) + +**Configuration priority:** Application config takes priority over environment variables. + ## Usage -The Qdrant Elixir Client provides a simple interface for interacting with the Qdrant API. For example, you can create a new collection, insert vectors, search, and delete data using the provided functions. +The Qdrant Elixir Client provides a simple interface for interacting with the Qdrant API. + +### Collections ```elixir collection_name = "my-collection" # Create a new collection -# The vectors are 1536-dimensional (because of OpenAi embedding) and use the Cosine distance metric -Qdrant.create_collection(collection_name, %{vectors: %{size: 1536, distance: "Cosine"}}) +# The vectors are 1536-dimensional (for OpenAI embeddings) and use the Cosine distance metric +{:ok, _} = Qdrant.create_collection(collection_name, %{ + vectors: %{ + size: 1536, + distance: "Cosine" + } +}) + +# List all collections +{:ok, collections} = Qdrant.list_collections() + +# Get collection info +{:ok, info} = Qdrant.collection_info(collection_name) + +# Check if collection exists +{:ok, %{"result" => %{"exists" => true}}} = Qdrant.collection_exists(collection_name) + +# Update collection parameters +{:ok, _} = Qdrant.update_collection(collection_name, %{ + optimizers_config: %{ + deleted_threshold: 0.2 + } +}) + +# Delete a collection +{:ok, _} = Qdrant.delete_collection(collection_name) +``` -# Create embeddings for some text -vector1 = OpenAi.embed_text("Hello world") -vector2 = OpenAi.embed_text("This is OpenAI") +### Points -# Now we can insert the vectors with batch -Qdrant.upsert_points(collection_name, %{batch: %{ids: [1,2], vectors: [vector1, vector2]}}) -# Or one by one -Qdrant.upsert_point(collection_name, %{points: [%{id: 1, vector: vector1}, %{id: 2, vector: vector2}]}) +```elixir +collection_name = "my-collection" + +# Create embeddings for some text +vector1 = [0.1, 0.2, 0.3] # Your embedding vector +vector2 = [0.4, 0.5, 0.6] + +# Insert vectors with batch +{:ok, _} = Qdrant.upsert_point(collection_name, %{ + batch: %{ + ids: [1, 2], + vectors: [vector1, vector2] + } +}) + +# Or insert points one by one +{:ok, _} = Qdrant.upsert_point(collection_name, %{ + points: [ + %{id: 1, vector: vector1, payload: %{text: "Hello"}}, + %{id: 2, vector: vector2, payload: %{text: "World"}} + ] +}) # Search for similar vectors -vector3 = OpenAi.embed_text("Hello world!") -Qdrant.search_points(collection_name, %{vector: vector3, limit: 3}) +query_vector = [0.15, 0.25, 0.35] +{:ok, results} = Qdrant.search_points(collection_name, %{ + vector: query_vector, + limit: 3, + with_payload: true +}) + +# Get specific points by ID +{:ok, points} = Qdrant.get_points(collection_name, %{ + ids: [1, 2], + with_payload: true, + with_vector: true +}) + +# Get a single point +{:ok, point} = Qdrant.get_point(collection_name, 1) + +# Delete points +{:ok, _} = Qdrant.delete_points(collection_name, %{ + points: [1, 2] +}) ``` +### Advanced Features + +The library supports many advanced features including: + +- **Aliases**: Manage collection aliases +- **Indexes**: Create and manage field indexes for faster filtering +- **Snapshots**: Backup and restore collections +- **Cluster operations**: Manage distributed setups +- **Service endpoints**: Health checks, telemetry, metrics + +For full API documentation, see the [module documentation](https://hexdocs.pm/qdrant). + +## Direct HTTP Module Access + +You can also access the HTTP modules directly for more control: + +```elixir +# Collections +alias Qdrant.Api.Http.Collections +{:ok, collections} = Collections.list_collections() + +# Points +alias Qdrant.Api.Http.Points +{:ok, results} = Points.search_points("my-collection", %{vector: [0.1, 0.2], limit: 5}) + +# Service +alias Qdrant.Api.Http.Service +{:ok, info} = Service.root() # Get server version info +{:ok, health} = Service.healthz() # Health check +``` + +## Architecture + +The client uses the modern Tesla HTTP client pattern with middleware for: +- Base URL configuration +- API key authentication +- JSON encoding/decoding + +All modules follow consistent patterns and provide full coverage of the Qdrant REST API. + ## Contributing - Fork the repository - Create a branch for your changes - Make your changes - Run `mix format` to format your code +- Run `mix compile` to ensure everything compiles +- Submit a pull request ## Change Log diff --git a/lib/qdrant.ex b/lib/qdrant.ex index b0b32fc..1c0a0b3 100644 --- a/lib/qdrant.ex +++ b/lib/qdrant.ex @@ -1,13 +1,17 @@ defmodule Qdrant do @moduledoc """ - Documentation for Qdrant. + Qdrant Elixir client for interacting with Qdrant vector database. + + This module provides a high-level API for all Qdrant operations. """ use Qdrant.Api.Wrapper - @doc """ + # Collections operations + @doc """ Creates a collection with the given name and body. + Body must be a map with the key `vectors`, example: ```elixir @@ -35,7 +39,7 @@ defmodule Qdrant do @doc """ Returns a list of all collections. """ - def list_collections() do + def list_collections do api_call("Collections", :list_collections, []) end @@ -43,9 +47,37 @@ defmodule Qdrant do Returns information about a collection with the given name. """ def collection_info(collection_name) do - api_call("Collections", :collection_info, [collection_name]) + api_call("Collections", :get_collection, [collection_name]) + end + + @doc """ + Get detailed information about specified existing collection. + + Example: + ```elixir + Qdrant.get_collection("collection_name") + ``` + """ + def get_collection(collection_name) do + api_call("Collections", :get_collection, [collection_name]) + end + + @doc """ + Check if a collection exists. + """ + def collection_exists(collection_name) do + api_call("Collections", :collection_exists, [collection_name]) end + @doc """ + Update collection parameters. + """ + def update_collection(collection_name, body, timeout \\ nil) do + api_call("Collections", :update_collection, [collection_name, body, timeout]) + end + + # Points operations + @doc """ Perform insert + updates on points. If point with given ID already exists - it will be overwritten. @@ -146,12 +178,13 @@ defmodule Qdrant do @doc """ Delete multiple points that match filtering conditions - + Parameters: * `collection_name` - name of the collection to search in * `body` - search body - * `consistency` - Define read consistency guarentees for the operation - + * `wait` - wait for changes to actually happen + * `ordering` - Define ordering guarantees for the operation + Example: ```elixir body = %{ @@ -160,7 +193,43 @@ defmodule Qdrant do Qdrant.delete_points("collection_name", body) ``` """ - def delete_points(collection_name, body, consistency \\ nil) do - api_call("Points", :delete_points, [collection_name, body, consistency]) + def delete_points(collection_name, body, wait \\ false, ordering \\ nil) do + api_call("Points", :delete_points, [collection_name, body, wait, ordering]) + end + + @doc """ + Query points using a query string or vector query. + + Parameters: + * `collection_name` - name of the collection to query + * `body` - query body with `query` key (required) + * `consistency` - Define read consistency guarantees for the operation + + Body must be a map with the key `query`, example: + + ```elixir + %{ + query: %{ + vector: vector, + limit: 10 + }, + with_payload: true + } + ``` + + Example: + ```elixir + body = %{ + query: %{ + vector: vector, + limit: 10 + }, + with_payload: true + } + Qdrant.query_points("collection_name", body) + ``` + """ + def query_points(collection_name, body, consistency \\ nil) do + api_call("Points", :query_points, [collection_name, body, consistency]) end end diff --git a/lib/qdrant/api/http/aliases.ex b/lib/qdrant/api/http/aliases.ex new file mode 100644 index 0000000..c3b5894 --- /dev/null +++ b/lib/qdrant/api/http/aliases.ex @@ -0,0 +1,84 @@ +defmodule Qdrant.Api.Http.Aliases do + @moduledoc """ + Qdrant API Aliases operations. + + Aliases allow to give names to collections and use them instead of collection names. + """ + + alias Qdrant.Api.Http.Client + + defp client, do: Client.client() + + @doc """ + Update aliases of collections. + + ## Parameters + + * `body` **required** - Alias update operations (create, delete, rename) + * `timeout` - Optional timeout in seconds for operation commit + + ## Example + + iex> body = %{actions: [%{create_alias: %{collection_name: "my_collection", alias_name: "my_alias"}}]} + iex> Qdrant.Api.Http.Aliases.update_aliases(body) + {:ok, %{"result" => true, "status" => "ok"}} + + """ + @spec update_aliases(map(), integer() | nil) :: {:ok, map()} | {:error, any()} + def update_aliases(body, timeout \\ nil) do + path = "/collections/aliases" |> Client.add_query_param("timeout", timeout) + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Get aliases for a specific collection. + + ## Parameters + + * `collection_name` **required** - Name of the collection + + ## Example + + iex> Qdrant.Api.Http.Aliases.get_collection_aliases("my_collection") + {:ok, %{"result" => %{"aliases" => [...]}}} + + """ + @spec get_collection_aliases(String.t()) :: {:ok, map()} | {:error, any()} + def get_collection_aliases(collection_name) do + client() + |> Tesla.get("/collections/#{collection_name}/aliases") + |> parse_response() + end + + @doc """ + Get list of all existing collections aliases. + + ## Example + + iex> Qdrant.Api.Http.Aliases.get_collections_aliases() + {:ok, %{"result" => %{"aliases" => [...]}}} + + """ + @spec get_collections_aliases() :: {:ok, map()} | {:error, any()} + def get_collections_aliases do + client() + |> Tesla.get("/collections/aliases") + |> parse_response() + end + + # Private helpers + defp parse_response({:ok, %Tesla.Env{status: 200, body: body}}) do + {:ok, body} + end + + defp parse_response({:error, reason}) do + {:error, reason} + end + + defp parse_response({:ok, %Tesla.Env{} = env}) do + {:error, %{status: env.status, body: env.body}} + end +end diff --git a/lib/qdrant/api/http/client.ex b/lib/qdrant/api/http/client.ex index f607bef..5ee5f53 100644 --- a/lib/qdrant/api/http/client.ex +++ b/lib/qdrant/api/http/client.ex @@ -1,62 +1,68 @@ defmodule Qdrant.Api.Http.Client do @moduledoc """ - Qdrant.Api.Client is a Tesla-based client for the Qdrant API. - The module provides methods for interacting with the Qdrant API server. + Qdrant.Api.Http.Client provides a Tesla-based client for the Qdrant API. + + This module provides a `client/1` function that returns a configured Tesla client + with appropriate middleware for base URL, headers, and JSON encoding/decoding. ## Example - iex> Qdrant.Api.Client.get("/collections") + + iex> client = Qdrant.Api.Http.Client.client() + iex> Tesla.get(client, "/collections") {:ok, %Tesla.Env{status: 200, body: %{"collections" => []}}} - Or as a macro: + """ - iex> use Qdrant.Api.Http.Client - iex> scope("/collections") - iex> get("") - # The path is relative to the scope path set above ("/collections") - # and not the base url set in the config file. - # This is so that you don't have to repeat the scope path in every request just the relative path. - {:ok, %Tesla.Env{status: 200, body: %{"collections" => [...]}}} + @doc """ + Creates a Tesla client with Qdrant API configuration. - """ + ## Options - defmacro __using__(_opts) do - quote do - use Tesla, docs: false - plug Tesla.Middleware.JSON - plug Tesla.Middleware.BaseUrl, base_url() - plug Tesla.Middleware.Headers, [{"api-key", api_key()}] + - `:base_path` - Optional base path to prepend to all requests (default: "") - defp base_url, do: "#{api_url()}:#{port()}#{api_path()}" + ## Example - defp api_url do - case Application.get_env(:qdrant, :database_url) do - nil -> raise "Qdrant database url is not set" - base_url -> base_url - end - end + iex> client = Qdrant.Api.Http.Client.client() + iex> Tesla.get(client, "/collections") + {:ok, %Tesla.Env{status: 200, body: %{"collections" => []}}} - defp port do - case Application.get_env(:qdrant, :port) do - nil -> raise "Qdrant database port is not set" - port -> port - end - end + """ + def client(opts \\ []) do + base_path = Keyword.get(opts, :base_path, "") + + middleware = [ + {Tesla.Middleware.BaseUrl, Qdrant.Config.base_url() <> base_path}, + {Tesla.Middleware.JSON, engine: JSON} + ] - defp api_key do - case Application.get_env(:qdrant, :api_key) do - nil -> raise "Qdrant api key is not set" - api_key -> api_key - end + middleware = + case Qdrant.Config.get_api_key() do + nil -> middleware + key -> [{Tesla.Middleware.Headers, [{"api-key", key}]} | middleware] end - import(Qdrant.Api.Http.Client, only: [scope: 1]) - end + adapter = Qdrant.Config.get_adapter(opts) + + Tesla.client(middleware, adapter) end - @doc false - defmacro scope(path) do - quote do - def api_path, do: unquote(path) - end + @doc """ + Helper function to build query string from parameters. + + ## Examples + + iex> add_query_param("/path", "foo", "bar") + "/path?foo=bar" + + iex> add_query_param("/path?existing=value", "foo", "bar") + "/path?existing=value&foo=bar" + + iex> add_query_param("/path", "foo", nil) + "/path" + """ + def add_query_param(path, _key, nil), do: path + def add_query_param(path, key, value) when is_binary(path) do + separator = if String.contains?(path, "?"), do: "&", else: "?" + path <> separator <> URI.encode_query([{key, value}]) end end diff --git a/lib/qdrant/api/http/cluster.ex b/lib/qdrant/api/http/cluster.ex index e354e93..7ce233f 100644 --- a/lib/qdrant/api/http/cluster.ex +++ b/lib/qdrant/api/http/cluster.ex @@ -1,12 +1,11 @@ defmodule Qdrant.Api.Http.Cluster do @moduledoc """ - Service distributed setup. + Service distributed setup and cluster management. """ - use Qdrant.Api.Http.Client + alias Qdrant.Api.Http.Client - @doc false - scope "/cluster" + defp client, do: Client.client() @type shard_params :: %{ shard_id: non_neg_integer(), @@ -25,100 +24,181 @@ defmodule Qdrant.Api.Http.Cluster do @type shard_operations :: move_shard() | replicate_shard() | abort_transfer() | drop_replica() @doc """ - Get information about the current state and composition of the cluster. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/cluster/operation/cluster_status) + Create shard key for a collection. + + ## Parameters + + * `collection_name` **required** - Name of the collection to create shards for + * `body` **required** - Shard key configuration + * `timeout` - Optional timeout in seconds for operation commit + + ## Example + + iex> body = %{shard_key: "city"} + iex> Qdrant.Api.Http.Cluster.create_shard_key("my_collection", body) + {:ok, %{"result" => true, "status" => "ok"}} + + """ + @spec create_shard_key(String.t(), map(), integer() | nil) :: {:ok, map()} | {:error, any()} + def create_shard_key(collection_name, body, timeout \\ nil) do + path = + "/collections/#{collection_name}/shards" + |> Client.add_query_param("timeout", timeout) + + client() + |> Tesla.put(path, body) + |> parse_response() + end + + @doc """ + Delete shard key for a collection. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `body` **required** - Shard key to delete + * `timeout` - Optional timeout in seconds for operation commit + + ## Example + + iex> body = %{shard_key: "city"} + iex> Qdrant.Api.Http.Cluster.delete_shard_key("my_collection", body) + {:ok, %{"result" => true, "status" => "ok"}} + + """ + @spec delete_shard_key(String.t(), map(), integer() | nil) :: {:ok, map()} | {:error, any()} + def delete_shard_key(collection_name, body, timeout \\ nil) do + path = + "/collections/#{collection_name}/shards/delete" + |> Client.add_query_param("timeout", timeout) + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Get information about the current state and composition of the cluster. + + [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/cluster/operation/cluster_status) ## Example iex> Qdrant.Api.Http.Cluster.cluster_status() - {:ok, %Tesla.Env{status: 200, - body: %{ - "result" => %{ - "status" => "disabled", - }, - "status" => "ok", - "time" => 0 - } - } - } + {:ok, %{"result" => %{"status" => "disabled"}, "status" => "ok", "time" => 0}} """ - @spec cluster_status() :: {:ok, Tesla.Env.t()} | {:error, any()} - def cluster_status() do - get("") + @spec cluster_status() :: {:ok, map()} | {:error, any()} + def cluster_status do + client() + |> Tesla.get("/cluster") + |> parse_response() end @doc """ - Tries to recover current peer Raft state. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/cluster/operation/recover_current_peer + Tries to recover current peer Raft state. + + [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/cluster/operation/recover_current_peer) ## Example iex> Qdrant.Api.Http.Cluster.recover_current_peer() - {:ok, %Tesla.Env{status: 200, - body: %{ - "result" => true, - "status" => "ok", - "time" => 0 - } - } - } + {:ok, %{"result" => true, "status" => "ok", "time" => 0}} """ - @spec recover_current_peer() :: {:ok, Tesla.Env.t()} | {:error, any()} - def recover_current_peer() do - post("/recover", %{}) + @spec recover_current_peer() :: {:ok, map()} | {:error, any()} + def recover_current_peer do + client() + |> Tesla.post("/cluster/recover", %{}) + |> parse_response() end @doc """ - Remove peer from the cluster by its id. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/cluster/operation/remove_peer) + Remove peer from the cluster by its id. + Tries to remove peer from the cluster. Will return an error if peer has shards on it. + [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/cluster/operation/remove_peer) + ## Parameters - * `peer_id` **required** - `integer` peer id + * `peer_id` **required** - Peer id ## Example iex> Qdrant.Api.Http.Cluster.remove_peer(42) - {:ok, %Tesla.Env{status: 200, - body: %{ - "result" => true, - "status" => "ok", - "time" => 0 - } - } - } + {:ok, %{"result" => true, "status" => "ok", "time" => 0}} + """ - @spec remove_peer(String.t()) :: {:ok, Tesla.Env.t()} | {:error, any()} + @spec remove_peer(integer() | String.t()) :: {:ok, map()} | {:error, any()} def remove_peer(peer_id) do - delete("/peer/#{peer_id}") + client() + |> Tesla.delete("/cluster/peer/#{peer_id}") + |> parse_response() end @doc """ - Get cluster information for a collection. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/cluster/operation/collection_cluster_info) + Get cluster information for a collection. + + [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/cluster/operation/collection_cluster_info) ## Parameters - * `collection_name` **required** - `string` collection name + * `collection_name` **required** - Collection name + + ## Example + + iex> Qdrant.Api.Http.Cluster.collection_cluster_info("my_collection") + {:ok, %{"result" => %{...}, "status" => "ok"}} """ - @spec collection_cluster_info(String.t()) :: {:ok, Tesla.Env.t()} | {:error, any()} + @spec collection_cluster_info(String.t()) :: {:ok, map()} | {:error, any()} def collection_cluster_info(collection_name) do - get("/collection/#{collection_name}/cluster") + client() + |> Tesla.get("/collections/#{collection_name}/cluster") + |> parse_response() end @doc """ - Update collection cluster setup. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/cluster/operation/update_collection_cluster) + Update collection cluster setup. + + [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/cluster/operation/update_collection_cluster) ## Parameters - * `collection_name` **required** - `string` collection name + * `collection_name` **required** - Collection name + * `body` **required** - Cluster operations (move_shard, replicate_shard, abort_transfer, or drop_replica) + * `timeout` - Optional timeout in seconds for operation commit - ## Query Parameters + ## Example + + iex> body = %{move_shard: %{shard_id: 1, to_peer_id: 2, from_peer_id: 1}} + iex> Qdrant.Api.Http.Cluster.update_collection_cluster("my_collection", body) + {:ok, %{"result" => true, "status" => "ok"}} - - `timeout` - Wait for operation commit timeout in seconds. If timeout is reached - request will return with service error. """ - @spec update_collection_cluster(String.t(), shard_operations()) :: {:ok, Tesla.Env.t()} | {:error, any()} - def update_collection_cluster(collection_name, body) do - post("/collection/#{collection_name}/cluster", body) + @spec update_collection_cluster(String.t(), shard_operations(), integer() | nil) :: + {:ok, map()} | {:error, any()} + def update_collection_cluster(collection_name, body, timeout \\ nil) do + path = + "/collections/#{collection_name}/cluster" + |> Client.add_query_param("timeout", timeout) + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + # Private helpers + defp parse_response({:ok, %Tesla.Env{status: 200, body: body}}) do + {:ok, body} + end + + defp parse_response({:error, reason}) do + {:error, reason} + end + + defp parse_response({:ok, %Tesla.Env{} = env}) do + {:error, %{status: env.status, body: env.body}} end end diff --git a/lib/qdrant/api/http/collections.ex b/lib/qdrant/api/http/collections.ex index 99ecb9f..9fa776e 100644 --- a/lib/qdrant/api/http/collections.ex +++ b/lib/qdrant/api/http/collections.ex @@ -6,539 +6,171 @@ defmodule Qdrant.Api.Http.Collections do """ use Qdrant.Utils.Types - use Qdrant.Api.Http.Client - - @doc false - scope "/collections" - - @type create_collection_body :: %{ - vectors: %{ - size: non_neg_integer(), - distance: String.t(), - hnsw_config: hnsw_config() | nil, - quantization_config: quantization_config() | nil - }, - shard_number: non_neg_integer() | nil, - replication_factor: pos_integer(), - write_consistency_factor: non_neg_integer() | nil, - on_disk_payload: boolean() | nil, - hnsw_config: hnsw_config() | nil, - wal_config: %{ - wal_capacity_mb: pos_integer() | nil, - wal_segments_ahead: pos_integer() | nil - }, - optimizers_config: optimizers_config() | nil, - init_from: String.t() | nil, - quantization_config: quantization_config() | nil - } - - @type update_collection_body :: %{ - optimizers_config: optimizers_config(), - params: %{ - replication_factor: pos_integer() | nil, - write_consistency_factor: pos_integer() | nil - } - } - - # * Update aliases of the collections - @type delete_alias :: %{alias_name: String.t()} - @type create_alias :: %{alias_name: String.t(), collection_name: String.t()} - @type rename_alias :: %{old_alias_name: String.t(), new_alias_name: String.t()} - @type alias_actions_list :: %{actions: [delete_alias | create_alias | rename_alias]} - - # * Create index for the collection field - @type index_body_type :: :keyword | :integer | :float | :geo | :text - @type tokenizer_type :: :prefix | :whitespace | :word - @type field_schema :: %{ - type: index_body_type(), - tokenizers: tokenizer_type(), - min_token_len: non_neg_integer(), - max_token_len: non_neg_integer(), - lowercase: boolean() - } - - @type field_index :: %{field_name: String.t(), field_schema: field_schema} - - # * Update collection cluster setup - @type shadred_operation_params :: %{shard_id: integer(), from_peer_id: integer(), to_peer_id: integer()} - @type drop_replica_params :: %{shard_id: integer(), peer_id: integer()} - @type cluster_update_body :: - %{move_shard: shadred_operation_params} - | %{replicate_shard: shadred_operation_params} - | %{abort_transfer: shadred_operation_params} - | %{drop_replica: drop_replica_params} - @doc """ - Create shard key for a collection. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/create_shard_key) - - ## Path parameters - - - collection_name **required**: Name of the collection to create shards for. - - ## Query parameters - - - timeout *optional*: Wait for operation commit timeout in seconds. If timeout is reached - request will return with service error. - - ## Request body schema - - - `shard_key`, `shards_number`, `replication_factor`, `placement` - - ## Example - - iex> Qdrant.Api.Http.Collections.create_shard_key("my_collection", %{shard_key: ..., shards_number: ..., replication_factor: ..., placement: ...}, 10) - {:ok, %Tesla.Env{status: 200, body: %{...}}} + alias Qdrant.Api.Http.Client - """ - @spec create_shard_key(String.t(), map(), integer() | nil) :: {:ok, map()} | {:error, any()} - def create_shard_key(collection_name, body, timeout \\ nil) do - path = "/collections/#{collection_name}/shards" <> timeout_query(timeout) - put(path, body) - end + defp client, do: Client.client() @doc """ - Delete a shard key from a collection. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#operation/delete_shard_key) - - ## Path parameters - - - collection_name **required** : Name of the collection to delete shard key from - - ## Request body - - - The request body should conform to the `DropShardingKey` schema. - - ## Query parameters - - - timeout *optional* : Wait for operation commit timeout in seconds. If timeout is reached - request will return with service error. + Get list name of all existing collections. ## Example - iex> Qdrant.Api.Http.Collections.delete_shard_key("my_collection", %{shard_key: ...}, 10) - {:ok, %Tesla.Env{status: 200, body: %{...}}} - """ - @spec delete_shard_key(String.t(), map(), integer() | nil) :: {:ok, map()} | {:error, any()} - def delete_shard_key(collection_name, body, timeout \\ nil) do - path = "/collections/#{collection_name}/shards/delete" <> timeout_query(timeout) - post(path, body) - end - - @doc """ - Get list name of all existing collections. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/get_collection) - - ## Example iex> Qdrant.Api.Http.Collections.list_collections() - {:ok, %Tesla.Env{status: 200, - body: %{ - "result" => %{"collections" => [...]}, - "status" => "ok", - "time" => 2.043e-6 - } - } - } - """ - @spec list_collections() :: {:ok, Tesla.Env.t()} | {:error, any()} - def list_collections() do - get("") - end - - @doc """ - Get detailed information about specified existing collection. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/get_collection) - - ## Path parameters - - - collection_name **required**: name of the collection + {:ok, %{"result" => %{"collections" => [...]}}} - ## Example - iex> Qdrant.Api.Http.Collections.collection_info("my_collection") - {:ok, %Tesla.Env{status: 200, - body: %{ - "result" => %{ - "collection_type" => "Flat", - "name" => "my_collection", - "points_count" => 0, - "vectors_count" => 0 - }, - "status" => "ok", - "time" => 2.043e-6 - } - } - } """ - @spec collection_info(String.t()) :: {:ok, map()} | {:error, any()} - def collection_info(collection_name) do - get("/#{collection_name}") + @spec list_collections() :: {:ok, map()} | {:error, any()} + def list_collections do + client() + |> Tesla.get("/collections") + |> parse_response() end @doc """ - Create new collection with given parameters. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/create_collection) - - ## Path parameters - - - name **required** : Name of the new collection - - ## Query parameters - - - timeout *optional* : Wait for operation commit timeout in seconds. If timeout is reached - request will return with service error. - - ## Request body schema - - - `vectors` **required**: Vector params separator for single and multiple vector modes. Single mode: `%{size: 128, distance: "Cosine"}` or multiple mode: `%{default: {size: 128, distance: "Cosine"}}` - - - `shard_number` *optional*: `null` or `positive integer` Default: `null`. \n Number of shards in collection. Default is 1 for standalone, otherwise equal to the number of nodes Minimum is 1. - - - `replication_factor` *optional*: `null` or `positive integer` Default: `null`. \n Number of shards replicas. Default is 1 Minimum is 1 + Get detailed information about specified existing collection. - - `write_consistency_factor` *optional*: `null` or `positive integer` Default: `null`. \n Defines how many replicas should apply the operation for us to consider it successful. Increasing this number will make the collection more resilient to inconsistencies, but will also make it fail if not enough replicas are available. Does not have any performance impact. + ## Parameters + * `collection_name` - The name of the collection to retrieve information from. - - `on_disk_payload` *optional*: `boolean or null` Default: `null`. \n If true - point's payload will not be stored in memory. It will be read from the disk every time it is requested. This setting saves RAM by (slightly) increasing the response time. Note: those payload values that are involved in filtering and are indexed - remain in RAM. - - - `hnsw_config` *optional*: Custom params for HNSW index. If none - values from service configuration file are used. - - - `wal_config` *optional*: Custom params for WAL. If none - values from service configuration file are used. - - - `optimizers_config` *optional*: Custom params for Optimizers. If none - values from service configuration file are used. - - - `init_from` *optional*: `null` or `string` Default: `null`. \n Specify other collection to copy data from. - - - `quantization_config` *optional*: Default: `null`. \m Quantization parameters. If none - quantization is disabled. - """ - - @spec create_collection(String.t(), create_collection_body(), integer() | nil) :: {:ok, map()} | {:error, any()} - def create_collection(name, body, timeout \\ nil) do - path = "/#{name}" <> timeout_query(timeout) - put(path, body) - end - - @doc """ - Update collection parameters [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/update_collection) - - ## Path parameters - - - collection_name **required** : Name of the collection to update - - ## Query parameters - - - timeout *optional* : Wait for operation commit timeout in seconds. If timeout is reached - request will return with service error. - - ## Request body schema - - - `optimizers_config` *optional*: Custom params for Optimizers. If none - values from service configuration file are used. This operation is blocking, it will only proceed ones all current optimizations are complete - - - `params` *optional*: Collection base params. If none - values from service configuration file are used. - """ - - @spec update_collection(String.t(), update_collection_body(), integer() | nil) :: {:ok, map()} | {:error, any()} - def update_collection(collection_name, body, timeout \\ nil) do - path = "/#{collection_name}" <> timeout_query(timeout) - patch(path, body) - end - - @doc """ - Drop collection and all associated data [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/delete_collection) - - ## Path parameters - - - collection_name **required** : Name of the collection to delete + ## Example - ## Query parameters + iex> Qdrant.Api.Http.Collections.get_collection("my_collection") + {:ok, %{"result" => %{"name" => "my_collection", ...}}} - - timeout *optional* : Wait for operation commit timeout in seconds. If timeout is reached - request will return with service error. """ - @spec delete_collection(String.t(), integer() | nil) :: {:ok, map()} | {:error, any()} - def delete_collection(collection_name, timeout \\ nil) do - path = "/#{collection_name}" <> timeout_query(timeout) - delete(path) + @spec get_collection(String.t()) :: {:ok, map()} | {:error, any()} + def get_collection(collection_name) do + client() + |> Tesla.get("/collections/#{collection_name}") + |> parse_response() end @doc """ - Update aliases of the collections [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/update_aliases) - - ## Query parameters + Retrieves parameters from the specified collection. - - timeout *optional* : Wait for operation commit timeout in seconds. If timeout is reached - request will return with service error. - - ## Request body schema - - - `actions` *required*: List of actions to perform. Create_alias or delete_alias or rename_alias. + Alias for `get_collection/1` for backward compatibility. + ## Parameters + * `collection_name` - The name of the collection to retrieve parameters from. ## Example - iex> Qdrant.update_aliases(%{ - ...> actions: [ - ...> %{create_alias: %{alias: "alias_name", collection: "collection_name"}}, - ...> %{delete_alias: %{alias: "alias_name"}}, - ...> %{rename_alias: %{alias: "alias_name", new_alias: "new_alias_name"}} - ...> ] - ...> }) - {:ok, %{"result" => true, "status" => "ok", "time" => 0}} - + iex> Qdrant.Api.Http.Collections.get_collection_details("my_collection") + {:ok, %{"result" => %{"name" => "my_collection", ...}}} """ - @spec update_aliases(alias_actions_list(), integer() | nil) :: {:ok, map()} | {:error, any()} - def update_aliases(body, timeout \\ nil) do - path = "/aliases" <> timeout_query(timeout) - post(path, body) - end + @spec get_collection_details(String.t()) :: {:ok, map()} | {:error, any()} + def get_collection_details(collection_name), do: get_collection(collection_name) @doc """ - Create index for field in collection [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/create_field_index) - - ## Path parameters - - - collection_name **required** : Name of the collection - - ## Query parameters - - - `wait` *optional* : If true, wait for changes to actually happen - - - `ordering` *optional* : Define ordering guarantees for the operation + Create new collection with given parameters. - ## Request body schema - - - `field_name` *required* : Name of the field to index - - - `field_schema` *required* : Type of the field to index + ## Parameters + * `collection_name` - Name of the new collection + * `body` - Collection configuration parameters (must include `vectors` key) + * `timeout` - Optional timeout in seconds for operation commit ## Example - iex> Qdrant.create_field_index("collection_name", %{field_name: "field_name", field_schema: "field_schema"}) - {:ok, %{"status" => "ok", "time" => 0, "result" => %{"operation_id" => 42, status: "acknowledged"} }}}} + iex> body = %{vectors: %{size: 128, distance: "Cosine"}} + iex> Qdrant.Api.Http.Collections.create_collection("my_collection", body) + {:ok, %{"result" => true, "status" => "ok"}} + """ - @spec create_field_index(String.t(), field_index(), boolean(), ordering() | nil) :: {:ok, map()} | {:error, any()} - def create_field_index(collection_name, %{field_name: _} = body, wait \\ false, ordering \\ nil) do + @spec create_collection(String.t(), map(), integer() | nil) :: {:ok, map()} | {:error, any()} + def create_collection(collection_name, body, timeout \\ nil) do path = - "/#{collection_name}/index?" - |> add_query_param("wait", wait) - |> add_query_param("ordering", ordering) + "/collections/#{collection_name}" + |> Client.add_query_param("timeout", timeout) - put(path, body) + client() + |> Tesla.put(path, body) + |> parse_response() end @doc """ - Delete index for field in collection [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/delete_field_index) - - ## Path parameters - - - collection_name **required** : Name of the collection + Update parameters of the existing collection. - - field_name **required** : Name of the field where to delete the index - - ## Query parameters - - - `wait` *optional* : If true, wait for changes to actually happen - - - `ordering` *optional* : Define ordering guarantees for the operation + ## Parameters + * `collection_name` - Name of the collection to update + * `body` - New collection parameters + * `timeout` - Optional timeout in seconds for operation commit ## Example - iex> Qdrant.delete_field_index("collection_name", "field_name") - {:ok, %{"status" => "ok", "time" => 0, "result" => %{"operation_id" => 42, status: "acknowledged"} }}}} - """ + iex> body = %{optimizers_config: %{deleted_threshold: 0.2}} + iex> Qdrant.Api.Http.Collections.update_collection("my_collection", body) + {:ok, %{"result" => true, "status" => "ok"}} - @spec delete_field_index(String.t(), String.t(), boolean(), ordering() | nil) :: {:ok, map()} | {:error, any()} - def delete_field_index(collection_name, field_name, wait \\ false, ordering \\ nil) do + """ + @spec update_collection(String.t(), map(), integer() | nil) :: {:ok, map()} | {:error, any()} + def update_collection(collection_name, body, timeout \\ nil) do path = - "/#{collection_name}/index/#{field_name}?" - |> add_query_param("wait", wait) - |> add_query_param("ordering", ordering) - - delete(path) - end - - @doc """ - Get cluster information for a collection [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/collection_cluster_info) - - ## Path parameters - - - collection_name **required** : Name of the collection to retrieve the cluster info for - - ## Example + "/collections/#{collection_name}" + |> Client.add_query_param("timeout", timeout) - iex> Qdrant.collection_cluster_info("collection_name") - {:ok, %{"status" => "ok", "time" => 0, "result" => %{"operation_id" => 42, status: "acknowledged"} }}}} - """ - @spec collection_cluster_info(String.t()) :: {:ok, map()} | {:error, any()} - def collection_cluster_info(collection_name) do - path = "/#{collection_name}/cluster" - get(path) + client() + |> Tesla.patch(path, body) + |> parse_response() end @doc """ - Update collection cluster setup [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/update_collection_cluster) - - ## Path parameters - - - collection_name **required** : Name of the collection on which to to apply the cluster update operation - - ## Query parameters - - - `timeout` *optional* : Wait for operation commit timeout in seconds. If timeout is reached - request will return with service error. + Drop collection and all associated data. - ## Request body schema - - - `move_shard` or `replicate_shard` or `abort_transfer` or `drop_replica` **required** : List of actions to perform. + ## Parameters + * `collection_name` - Name of the collection to delete + * `timeout` - Optional timeout in seconds for operation commit ## Example - iex> Qdrant.update_collection_cluster("collection_name", %{ - ...> move_shard: %{ - ...> shard_id: 1, - ...> to_peer_id: 42, - ...> from_peer_id: 69 - ...> } - ...> }) - {:ok, %{"status" => "ok", "time" => 0, "result" => %{"operation_id" => 42, status: "acknowledged"} }}}} - - iex> Qdrant.update_collection_cluster("collection_name", %{ - ...> drop_replica: %{ - ...> shard_id: 1, - ...> peer_id: 42 - ...> } - ...> }) - {:ok, %{"status" => "ok", "time" => 0, "result" => %{"operation_id" => 42, status: "acknowledged"} }}}} - """ - @spec update_collection_cluster(String.t(), cluster_update_body(), integer() | nil) :: {:ok, map()} | {:error, any()} - def update_collection_cluster(collection_name, body, timeout \\ nil) do - path = "/#{collection_name}/cluster" <> timeout_query(timeout) - post(path, body) - end - - @doc """ - Get list of all aliases for a collection [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/get_collection_aliases) + iex> Qdrant.Api.Http.Collections.delete_collection("my_collection") + {:ok, %{"result" => true, "status" => "ok"}} - ## Path parameters - - - collection_name **required** : Name of the collection to retrieve the aliases for - - ## Example - - iex> Qdrant.list_collection_aliases("collection_name") - {:ok, %{"status" => "ok", "time" => 0, "result" => %{"operation_id" => 42, status: "acknowledged"} }}}} """ - @spec list_collection_aliases(String.t()) :: {:ok, map()} | {:error, any()} - def list_collection_aliases(collection_name) do - path = "/#{collection_name}/aliases" - get(path) - end + @spec delete_collection(String.t(), integer() | nil) :: {:ok, map()} | {:error, any()} + def delete_collection(collection_name, timeout \\ nil) do + path = + "/collections/#{collection_name}" + |> Client.add_query_param("timeout", timeout) - @doc """ - Get list of snapshots for a collection. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/list_snapshots) - """ - @spec list_collection_snapshots(String.t()) :: {:ok, map()} | {:error, any()} - def list_collection_snapshots(collection_name) do - path = "/#{collection_name}/snapshots" - get(path) + client() + |> Tesla.delete(path) + |> parse_response() end @doc """ - Create a new snapshot for a collection. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/create_snapshot) + Check if collection exists. - ## Path parameters + ## Parameters + * `collection_name` - Name of the collection to check - - collection_name **required** : Name of the collection to create a snapshot for + ## Example - ## Query parameters + iex> Qdrant.Api.Http.Collections.collection_exists("my_collection") + {:ok, %{"result" => %{"exists" => true}}} - - `wait` *optional* : If true, wait for changes to actually happen. If false - let changes happen in background. Default is true. """ - @spec create_collection_snapshot(String.t(), boolean()) :: {:ok, map()} | {:error, any()} - def create_collection_snapshot(collection_name, wait \\ true) do - path = "/#{collection_name}/snapshots?wait=#{wait}" - post(path, %{}) + @spec collection_exists(String.t()) :: {:ok, map()} | {:error, any()} + def collection_exists(collection_name) do + client() + |> Tesla.get("/collections/#{collection_name}/exists") + |> parse_response() end - @doc """ - Delete snapshot for a collection [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/delete_snapshot) - - ## Path parameters - - - collection_name **required** : Name of the collection to delete a snapshot for - - - snapshot_name **required** : Name of the snapshot to delete - - ## Query parameters - - - `wait` *optional* : If true, wait for changes to actually happen. If false - let changes happen in background. Default is true. - """ - @spec delete_collection_snapshot(String.t(), String.t(), boolean()) :: {:ok, map()} | {:error, any()} - def delete_collection_snapshot(collection_name, snapshot_name, wait \\ true) do - path = "/#{collection_name}/snapshots/#{snapshot_name}?wait=#{wait}" - delete(path) + # Private helpers + defp parse_response({:ok, %Tesla.Env{status: 200, body: body}}) do + {:ok, body} end - @doc """ - Download specified snapshot from a collection as a file. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/get_snapshot) - - ## Path parameters - - - collection_name **required** : Name of the collection to download a snapshot for - - - snapshot_name **required** : Name of the snapshot to download - """ - @spec download_collection_snapshot(String.t(), String.t()) :: {:ok, map()} | {:error, any()} - def download_collection_snapshot(collection_name, snapshot_name) do - path = "/#{collection_name}/snapshots/#{snapshot_name}" - get(path) + defp parse_response({:ok, %Tesla.Env{status: 404, body: body}}) do + {:error, body} end - # TODO: Add `recover_from_uploaded_snapshot` - # TODO: Add `recover_from_snapshot` - - @doc """ - Recover collection from an uploaded snapshot. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/recover_from_uploaded_snapshot) - - ## Path parameters - - - collection_name **required**: Name of the collection to recover. - - ## Query parameters - - - wait *optional*: If true, wait for changes to actually happen. If false - let changes happen in background. Default is true. - - ## Request body schema - - - `snapshot_path` **required**: Path to the uploaded snapshot file. - - ## Example - - iex> Qdrant.Api.Http.Collections.recover_from_uploaded_snapshot("my_collection", %{snapshot_path: "path/to/snapshot"}, true) - {:ok, %Tesla.Env{status: 200, body: %{"status" => "ok", "result" => "Collection recovered from the uploaded snapshot successfully"}}} - """ - @spec recover_from_uploaded_snapshot(String.t(), %{snapshot_path: String.t()}, boolean()) :: - {:ok, map()} | {:error, any()} - def recover_from_uploaded_snapshot(collection_name, %{snapshot_path: _} = body, wait \\ true) do - path = "/#{collection_name}/snapshots/recover/uploaded?wait=#{wait}" - post(path, body) + defp parse_response({:error, reason}) do + {:error, reason} end - @doc """ - Recover collection from an existing snapshot. [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/recover_from_snapshot) - - ## Path parameters - - - collection_name **required**: Name of the collection to recover. - - ## Query parameters - - - wait *optional*: If true, wait for changes to actually happen. If false - let changes happen in background. Default is true. - - ## Request body schema - - - `snapshot_name` **required**: Name of the snapshot to recover from. - - ## Example - - iex> Qdrant.Api.Http.Collections.recover_from_snapshot("my_collection", %{snapshot_name: "snapshot_name"}, true) - {:ok, %Tesla.Env{status: 200, body: %{"status" => "ok", "result" => "Collection recovered from the snapshot successfully"}}} - """ - @spec recover_from_snapshot(String.t(), %{snapshot_name: String.t()}, boolean()) :: {:ok, map()} | {:error, any()} - def recover_from_snapshot(collection_name, %{snapshot_name: _} = body, wait \\ true) do - path = "/#{collection_name}/snapshots/recover?wait=#{wait}" - post(path, body) + defp parse_response({:ok, %Tesla.Env{} = env}) do + {:error, %{status: env.status, body: env.body}} end - - # * Private helpers - defp timeout_query(nil), do: "" - defp timeout_query(timeout), do: "?timeout=#{timeout}" - defp add_query_param(path, _, nil), do: path - defp add_query_param(path, key, value), do: path <> "&#{key}=#{value}" end diff --git a/lib/qdrant/api/http/global.ex b/lib/qdrant/api/http/global.ex deleted file mode 100644 index 006b874..0000000 --- a/lib/qdrant/api/http/global.ex +++ /dev/null @@ -1,78 +0,0 @@ -defmodule Qdrant.Api.Http.Service do - @moduledoc """ - Fetch various telemetry data and all collections aliases. - """ - use Qdrant.Api.Http.Client - - @doc false - scope "" - - @doc """ - Get list of all existing collections aliases [See more on qdrant](https://qdrant.github.io/qdrant/redoc/index.html#tag/collections/operation/get_collections_aliases) - - ## Example - - iex> Qdrant.list_collections_aliases() - %{:ok, %{time: 0, status: "ok", result: %{}, aliases: [%{alias_name: "string", collection_name: "string"}]}} - """ - @spec list_collections_aliases() :: {:ok, map()} | {:error, any()} - def list_collections_aliases() do - path = "/aliases" - get(path) - end - - @doc """ - Collect telemetry data - - Collect telemetry data including app info, system info, collections info, cluster info, configs and statistics - """ - @spec telemetry() :: {:ok, map()} | {:error, any()} - def telemetry() do - path = "/telemetry" - get(path) - end - - @doc """ - Collect Prometheus metrics data - - Collect metrics data including app info, collections info, cluster info and statistics - """ - @spec metrics() :: {:ok, map()} | {:error, any()} - def metrics() do - path = "/metrics" - get(path) - end - - @doc """ - Get lock options - - Get lock options. If write is locked, all write operations and collection creation are forbidden - """ - @spec lock_options() :: {:ok, map()} | {:error, any()} - def lock_options() do - path = "/locks" - get(path) - end - - @doc """ - Set lock options - - Set lock options. If write is locked, all write operations and collection creation are forbidden. Returns previous lock options - - ## Request body schema - - - `error_message` - Error message to return on write operations - - - `write` - Write lock flag. If true, all write operations and collection creation are forbidden - - ## Example - - iex> Qdrant.set_lock_options(%{error_message: "string", write: true}) - %{:ok, %{time: 0, status: "ok", result: %{error_message: "string", write: true}}} - """ - @spec set_lock_options(map()) :: {:ok, map()} | {:error, any()} - def set_lock_options(body) do - path = "/locks" - post(path, body) - end -end diff --git a/lib/qdrant/api/http/indexes.ex b/lib/qdrant/api/http/indexes.ex new file mode 100644 index 0000000..6879647 --- /dev/null +++ b/lib/qdrant/api/http/indexes.ex @@ -0,0 +1,85 @@ +defmodule Qdrant.Api.Http.Indexes do + @moduledoc """ + Qdrant API Field Indexes operations. + + Field indexes allow to speed up filtering and sorting operations on payload fields. + """ + + use Qdrant.Utils.Types + + alias Qdrant.Api.Http.Client + + defp client, do: Client.client() + + @doc """ + Create index for field in collection. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `body` **required** - Field index configuration + * `wait` - Optional boolean, if true, wait for changes to actually happen + * `ordering` - Optional ordering guarantees for the operation + + ## Example + + iex> body = %{field_name: "category", field_schema: "keyword"} + iex> Qdrant.Api.Http.Indexes.create_field_index("my_collection", body) + {:ok, %{"result" => %{...}, "status" => "ok"}} + + """ + @spec create_field_index(String.t(), map(), boolean() | nil, ordering() | nil) :: + {:ok, map()} | {:error, any()} + def create_field_index(collection_name, body, wait \\ nil, ordering \\ nil) do + path = + "/collections/#{collection_name}/index" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("ordering", ordering) + + client() + |> Tesla.put(path, body) + |> parse_response() + end + + @doc """ + Delete index for field in collection. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `field_name` **required** - Name of the field to delete index for + * `wait` - Optional boolean, if true, wait for changes to actually happen + * `ordering` - Optional ordering guarantees for the operation + + ## Example + + iex> Qdrant.Api.Http.Indexes.delete_field_index("my_collection", "category") + {:ok, %{"result" => %{...}, "status" => "ok"}} + + """ + @spec delete_field_index(String.t(), String.t(), boolean() | nil, ordering() | nil) :: + {:ok, map()} | {:error, any()} + def delete_field_index(collection_name, field_name, wait \\ nil, ordering \\ nil) do + path = + "/collections/#{collection_name}/index/#{field_name}" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("ordering", ordering) + + client() + |> Tesla.delete(path) + |> parse_response() + end + + # Private helpers + defp parse_response({:ok, %Tesla.Env{status: 200, body: body}}) do + {:ok, body} + end + + defp parse_response({:error, reason}) do + {:error, reason} + end + + defp parse_response({:ok, %Tesla.Env{} = env}) do + {:error, %{status: env.status, body: env.body}} + end +end diff --git a/lib/qdrant/api/http/points.ex b/lib/qdrant/api/http/points.ex index e2b3762..180bbd0 100644 --- a/lib/qdrant/api/http/points.ex +++ b/lib/qdrant/api/http/points.ex @@ -8,11 +8,11 @@ defmodule Qdrant.Api.Http.Points do Points are stored in collections, and each collection has its own set of vectors. """ - use Qdrant.Api.Http.Client use Qdrant.Utils.Types - @doc false - scope "/collections" + alias Qdrant.Api.Http.Client + + defp client, do: Client.client() @type vectors :: list(vector()) @type points_batch :: %{ @@ -135,10 +135,12 @@ defmodule Qdrant.Api.Http.Points do @spec get_point(String.t(), String.t() | non_neg_integer(), consistency() | nil) :: {:ok, map()} | {:error, any()} def get_point(collection_name, id, consistency \\ nil) do path = - "/#{collection_name}/points/#{id}?" - |> add_query_param("consistency", consistency) + "/collections/#{collection_name}/points/#{id}" + |> Client.add_query_param("consistency", consistency) - get(path) + client() + |> Tesla.get(path) + |> parse_response() end @doc """ @@ -162,10 +164,12 @@ defmodule Qdrant.Api.Http.Points do @spec get_points(String.t(), get_points_body(), consistency() | nil) :: {:ok, map()} | {:error, any()} def get_points(collection_name, body, consistency \\ nil) do path = - "/#{collection_name}/points?" - |> add_query_param("consistency", consistency) + "/collections/#{collection_name}/points" + |> Client.add_query_param("consistency", consistency) - post(path, body) + client() + |> Tesla.post(path, body) + |> parse_response() end @doc """ @@ -190,11 +194,13 @@ defmodule Qdrant.Api.Http.Points do @spec upsert_points(String.t(), upsert_body(), boolean() | nil, ordering() | nil) :: {:ok, map()} | {:error, any()} def upsert_points(collection_name, body, wait \\ false, ordering \\ nil) do path = - "/#{collection_name}/points?" - |> add_query_param("wait", wait) - |> add_query_param("ordering", ordering) + "/collections/#{collection_name}/points" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("ordering", ordering) - put(path, body) + client() + |> Tesla.put(path, body) + |> parse_response() end @doc """ @@ -217,11 +223,13 @@ defmodule Qdrant.Api.Http.Points do @spec delete_points(String.t(), delete_body(), boolean() | nil, ordering() | nil) :: {:ok, map()} | {:error, any()} def delete_points(collection_name, body, wait \\ false, ordering \\ nil) do path = - "/#{collection_name}/points/delete?" - |> add_query_param("wait", wait) - |> add_query_param("ordering", ordering) + "/collections/#{collection_name}/points/delete" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("ordering", ordering) - post(path, body) + client() + |> Tesla.post(path, body) + |> parse_response() end @doc """ @@ -248,11 +256,13 @@ defmodule Qdrant.Api.Http.Points do @spec set_payload(String.t(), set_payload_body(), boolean() | nil, ordering() | nil) :: {:ok, map()} | {:error, any()} def set_payload(collection_name, body, wait \\ false, ordering \\ nil) do path = - "/#{collection_name}/points/payload?" - |> add_query_param("wait", wait) - |> add_query_param("ordering", ordering) + "/collections/#{collection_name}/points/payload" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("ordering", ordering) - post(path, body) + client() + |> Tesla.post(path, body) + |> parse_response() end @doc """ @@ -280,11 +290,13 @@ defmodule Qdrant.Api.Http.Points do {:ok, map()} | {:error, any()} def overwrite_payload(collection_name, body, wait \\ false, ordering \\ nil) do path = - "/#{collection_name}/points/payload?" - |> add_query_param("wait", wait) - |> add_query_param("ordering", ordering) + "/collections/#{collection_name}/points/payload" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("ordering", ordering) - put(path, body) + client() + |> Tesla.put(path, body) + |> parse_response() end @doc """ @@ -312,11 +324,13 @@ defmodule Qdrant.Api.Http.Points do {:ok, map()} | {:error, any()} def delete_payload(collection_name, body, wait \\ false, ordering \\ nil) do path = - "/#{collection_name}/points/payload/delete?" - |> add_query_param("wait", wait) - |> add_query_param("ordering", ordering) + "/collections/#{collection_name}/points/payload/delete" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("ordering", ordering) - post(path, body) + client() + |> Tesla.post(path, body) + |> parse_response() end @doc """ @@ -340,11 +354,13 @@ defmodule Qdrant.Api.Http.Points do {:ok, map()} | {:error, any()} def clear_payload(collection_name, body, wait \\ false, ordering \\ nil) do path = - "/#{collection_name}/points/payload/clear?" - |> add_query_param("wait", wait) - |> add_query_param("ordering", ordering) + "/collections/#{collection_name}/points/payload/clear" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("ordering", ordering) - post(path, body) + client() + |> Tesla.post(path, body) + |> parse_response() end @doc """ @@ -371,11 +387,13 @@ defmodule Qdrant.Api.Http.Points do @spec batch_update_points(String.t(), map(), boolean() | nil, String.t() | nil) :: {:ok, map()} | {:error, any()} def batch_update_points(collection_name, update_operations, wait \\ false, ordering \\ nil) do path = - "/#{collection_name}/points/batch" - |> add_query_param("wait", wait) - |> add_query_param("ordering", ordering) + "/collections/#{collection_name}/points/batch" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("ordering", ordering) - post(path, update_operations) + client() + |> Tesla.post(path, update_operations) + |> parse_response() end @doc """ @@ -404,10 +422,12 @@ defmodule Qdrant.Api.Http.Points do @spec scroll_points(String.t(), scroll_body(), consistency() | nil) :: {:ok, map()} | {:error, any()} def scroll_points(collection_name, body, consistency \\ nil) do path = - "/#{collection_name}/points/scroll?" - |> add_query_param("consistency", consistency) + "/collections/#{collection_name}/points/scroll" + |> Client.add_query_param("consistency", consistency) - post(path, body) + client() + |> Tesla.post(path, body) + |> parse_response() end @doc """ @@ -443,10 +463,12 @@ defmodule Qdrant.Api.Http.Points do @spec search_points(String.t(), search_body(), integer() | nil) :: {:ok, map()} | {:error, any()} def search_points(collection_name, body, consistency \\ nil) do path = - "/#{collection_name}/points/search" - |> add_query_param("consistency", consistency) + "/collections/#{collection_name}/points/search" + |> Client.add_query_param("consistency", consistency) - post(path, body) + client() + |> Tesla.post(path, body) + |> parse_response() end @doc """ @@ -467,10 +489,12 @@ defmodule Qdrant.Api.Http.Points do @spec search_points_batch(String.t(), search_batch_body(), consistency() | nil) :: {:ok, map()} | {:error, any()} def search_points_batch(collection_name, body, consistency \\ nil) do path = - "/#{collection_name}/points/search/batch" - |> add_query_param("consistency", consistency) + "/collections/#{collection_name}/points/search/batch" + |> Client.add_query_param("consistency", consistency) - post(path, body) + client() + |> Tesla.post(path, body) + |> parse_response() end @doc """ @@ -511,10 +535,12 @@ defmodule Qdrant.Api.Http.Points do @spec recommend_points(String.t(), recommend_body(), consistency() | nil) :: {:ok, map()} | {:error, any()} def recommend_points(collection_name, body, consistency \\ nil) do path = - "/#{collection_name}/points/recommend" - |> add_query_param("consistency", consistency) + "/collections/#{collection_name}/points/recommend" + |> Client.add_query_param("consistency", consistency) - post(path, body) + client() + |> Tesla.post(path, body) + |> parse_response() end @doc """ @@ -536,10 +562,316 @@ defmodule Qdrant.Api.Http.Points do {:ok, map()} | {:error, any()} def recommend_points_batch(collection_name, body, consistency \\ nil) do path = - "/#{collection_name}/points/recommend/batch" - |> add_query_param("consistency", consistency) + "/collections/#{collection_name}/points/recommend/batch" + |> Client.add_query_param("consistency", consistency) + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Update specified vectors on points. + + ## Path parameters + + - collection_name **required** : Name of the collection to update vectors in + + ## Query parameters + + - `wait` *optional* : If true, wait for changes to actually happen + - `ordering` *optional* : Define ordering guarantees for the operation + + ## Request body schema + + - `points` **required** : List of points to update vectors for + - `vector` **required** : Vector to update + """ + @spec update_vectors(String.t(), map(), boolean() | nil, ordering() | nil) :: {:ok, map()} | {:error, any()} + def update_vectors(collection_name, body, wait \\ false, ordering \\ nil) do + path = + "/collections/#{collection_name}/points/vectors" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("ordering", ordering) + + client() + |> Tesla.put(path, body) + |> parse_response() + end + + @doc """ + Delete specified vectors from points. + + ## Path parameters + + - collection_name **required** : Name of the collection to delete vectors from + + ## Query parameters + + - `wait` *optional* : If true, wait for changes to actually happen + - `ordering` *optional* : Define ordering guarantees for the operation + + ## Request body schema + + - `points` **required** : List of points to delete vectors from + - `vector` **required** : Vector name to delete + """ + @spec delete_vectors(String.t(), map(), boolean() | nil, ordering() | nil) :: {:ok, map()} | {:error, any()} + def delete_vectors(collection_name, body, wait \\ false, ordering \\ nil) do + path = + "/collections/#{collection_name}/points/vectors/delete" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("ordering", ordering) + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Search points grouped by a given field. + + ## Path parameters + + - collection_name **required** : Name of the collection to search in + + ## Query parameters + + - `consistency` *optional* : Define read consistency guarantees for the operation + + ## Request body schema + + Similar to search_points but with grouping parameters + """ + @spec search_points_groups(String.t(), map(), consistency() | nil) :: {:ok, map()} | {:error, any()} + def search_points_groups(collection_name, body, consistency \\ nil) do + path = + "/collections/#{collection_name}/points/search/groups" + |> Client.add_query_param("consistency", consistency) + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Recommend points grouped by a given field. + + ## Path parameters + + - collection_name **required** : Name of the collection to search in + + ## Query parameters + + - `consistency` *optional* : Define read consistency guarantees for the operation + + ## Request body schema + + Similar to recommend_points but with grouping parameters + """ + @spec recommend_points_groups(String.t(), map(), consistency() | nil) :: {:ok, map()} | {:error, any()} + def recommend_points_groups(collection_name, body, consistency \\ nil) do + path = + "/collections/#{collection_name}/points/recommend/groups" + |> Client.add_query_param("consistency", consistency) + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Discover points using context pairs. + + ## Path parameters + + - collection_name **required** : Name of the collection to search in + + ## Query parameters + + - `consistency` *optional* : Define read consistency guarantees for the operation + + ## Request body schema + + - `context` **required** : Pairs of {positive, negative} examples + - `target` *optional* : Target vector to discover + - Other similar parameters to search + """ + @spec discover_points(String.t(), map(), consistency() | nil) :: {:ok, map()} | {:error, any()} + def discover_points(collection_name, body, consistency \\ nil) do + path = + "/collections/#{collection_name}/points/discover" + |> Client.add_query_param("consistency", consistency) - post(path, body) + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Discover points using context pairs in batch. + + ## Path parameters + + - collection_name **required** : Name of the collection to search in + + ## Query parameters + + - `consistency` *optional* : Define read consistency guarantees for the operation + + ## Request body schema + + - `searches` **required** : List of discover requests + """ + @spec discover_points_batch(String.t(), list(map()), consistency() | nil) :: {:ok, map()} | {:error, any()} + def discover_points_batch(collection_name, body, consistency \\ nil) do + path = + "/collections/#{collection_name}/points/discover/batch" + |> Client.add_query_param("consistency", consistency) + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Calculate facet aggregation for points. + + ## Path parameters + + - collection_name **required** : Name of the collection + + ## Request body schema + + - `facet` **required** : Facet configuration + - `filter` *optional* : Filter to apply + """ + @spec facet_points(String.t(), map()) :: {:ok, map()} | {:error, any()} + def facet_points(collection_name, body) do + path = "/collections/#{collection_name}/facet" + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Query points using a query string. + + ## Path parameters + + - collection_name **required** : Name of the collection to query + + ## Query parameters + + - `consistency` *optional* : Define read consistency guarantees for the operation + + ## Request body schema + + - `query` **required** : Query string or vector query + - Other similar parameters to search + """ + @spec query_points(String.t(), map(), consistency() | nil) :: {:ok, map()} | {:error, any()} + def query_points(collection_name, body, consistency \\ nil) do + path = + "/collections/#{collection_name}/points/query" + |> Client.add_query_param("consistency", consistency) + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Query points using a query string in batch. + + ## Path parameters + + - collection_name **required** : Name of the collection to query + + ## Query parameters + + - `consistency` *optional* : Define read consistency guarantees for the operation + + ## Request body schema + + - `searches` **required** : List of query requests + """ + @spec query_points_batch(String.t(), list(map()), consistency() | nil) :: {:ok, map()} | {:error, any()} + def query_points_batch(collection_name, body, consistency \\ nil) do + path = + "/collections/#{collection_name}/points/query/batch" + |> Client.add_query_param("consistency", consistency) + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Query points grouped by a given field. + + ## Path parameters + + - collection_name **required** : Name of the collection to query + + ## Query parameters + + - `consistency` *optional* : Define read consistency guarantees for the operation + + ## Request body schema + + Similar to query_points but with grouping parameters + """ + @spec query_points_groups(String.t(), map(), consistency() | nil) :: {:ok, map()} | {:error, any()} + def query_points_groups(collection_name, body, consistency \\ nil) do + path = + "/collections/#{collection_name}/points/query/groups" + |> Client.add_query_param("consistency", consistency) + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Search points by vector pairs. + + ## Path parameters + + - collection_name **required** : Name of the collection to search in + + ## Request body schema + + - `searches` **required** : List of vector pairs to search + """ + @spec search_matrix_pairs(String.t(), map()) :: {:ok, map()} | {:error, any()} + def search_matrix_pairs(collection_name, body) do + path = "/collections/#{collection_name}/points/search/matrix/pairs" + + client() + |> Tesla.post(path, body) + |> parse_response() + end + + @doc """ + Search points by vector offsets. + + ## Path parameters + + - collection_name **required** : Name of the collection to search in + + ## Request body schema + + - `searches` **required** : List of vector offsets to search + """ + @spec search_matrix_offsets(String.t(), map()) :: {:ok, map()} | {:error, any()} + def search_matrix_offsets(collection_name, body) do + path = "/collections/#{collection_name}/points/search/matrix/offsets" + + client() + |> Tesla.post(path, body) + |> parse_response() end @doc """ @@ -557,12 +889,23 @@ defmodule Qdrant.Api.Http.Points do """ @spec count_points(String.t(), %{filter: filter_type(), exact: boolean()}) :: {:ok, map()} | {:error, any()} def count_points(collection_name, body) do - path = "/#{collection_name}/points/count" - post(path, body) + path = "/collections/#{collection_name}/points/count" + + client() + |> Tesla.post(path, body) + |> parse_response() end - # * Private helpers + # Private helpers + defp parse_response({:ok, %Tesla.Env{status: 200, body: body}}) do + {:ok, body} + end - defp add_query_param(path, _, nil), do: path - defp add_query_param(path, key, value), do: path <> "&#{key}=#{value}" + defp parse_response({:error, reason}) do + {:error, reason} + end + + defp parse_response({:ok, %Tesla.Env{} = env}) do + {:error, %{status: env.status, body: env.body}} + end end diff --git a/lib/qdrant/api/http/service.ex b/lib/qdrant/api/http/service.ex new file mode 100644 index 0000000..0769874 --- /dev/null +++ b/lib/qdrant/api/http/service.ex @@ -0,0 +1,236 @@ +defmodule Qdrant.Api.Http.Service do + @moduledoc """ + Qdrant service endpoints for health checks, telemetry, metrics, and system information. + """ + + alias Qdrant.Api.Http.Client + + defp client, do: Client.client() + + @doc """ + Returns information about the running Qdrant instance like version and commit id. + + ## Example + + iex> Qdrant.Api.Http.Service.root() + {:ok, %{"version" => "1.0.0", "commit" => "abc123"}} + + """ + @spec root() :: {:ok, map()} | {:error, any()} + def root do + client() + |> Tesla.get("/") + |> parse_response() + end + + @doc """ + Collect telemetry data including app info, system info, collections info, cluster info, configs and statistics. + + ## Parameters + + * `anonymize` - Optional boolean to anonymize result + * `details_level` - Optional integer level of details (minimum 0) + + ## Example + + iex> Qdrant.Api.Http.Service.telemetry() + {:ok, %{"result" => %{...}, "status" => "ok"}} + + """ + @spec telemetry(boolean() | nil, integer() | nil) :: {:ok, map()} | {:error, any()} + def telemetry(anonymize \\ nil, details_level \\ nil) do + path = + "/telemetry" + |> Client.add_query_param("anonymize", anonymize) + |> Client.add_query_param("details_level", details_level) + + client() + |> Tesla.get(path) + |> parse_response() + end + + @doc """ + Collect Prometheus metrics data. + + Returns metrics data in Prometheus format including app info, collections info, cluster info and statistics. + + ## Parameters + + * `anonymize` - Optional boolean to anonymize result + + ## Example + + iex> Qdrant.Api.Http.Service.metrics() + {:ok, "# HELP app_info..."} + + """ + @spec metrics(boolean() | nil) :: {:ok, String.t()} | {:error, any()} + def metrics(anonymize \\ nil) do + path = "/metrics" |> Client.add_query_param("anonymize", anonymize) + + client() + |> Tesla.get(path) + |> parse_response_text() + end + + @doc """ + Get lock options. + + If write is locked, all write operations and collection creation are forbidden. + + ## Example + + iex> Qdrant.Api.Http.Service.lock_options() + {:ok, %{"result" => %{"write" => false}}} + + """ + @spec lock_options() :: {:ok, map()} | {:error, any()} + def lock_options do + client() + |> Tesla.get("/locks") + |> parse_response() + end + + @doc """ + Set lock options. + + If write is locked, all write operations and collection creation are forbidden. + Returns previous lock options. + + ## Parameters + + * `body` - Lock configuration with `error_message` and `write` flag + + ## Example + + iex> body = %{error_message: "Maintenance mode", write: true} + iex> Qdrant.Api.Http.Service.set_lock_options(body) + {:ok, %{"result" => %{"error_message" => "Maintenance mode", "write" => true}}} + + """ + @spec set_lock_options(map()) :: {:ok, map()} | {:error, any()} + def set_lock_options(body) do + client() + |> Tesla.post("/locks", body) + |> parse_response() + end + + @doc """ + Health check endpoint. + + Returns a simple health check response. + + ## Example + + iex> Qdrant.Api.Http.Service.healthz() + {:ok, "healthz check passed"} + + """ + @spec healthz() :: {:ok, String.t()} | {:error, any()} + def healthz do + client() + |> Tesla.get("/healthz") + |> parse_response_text() + end + + @doc """ + Kubernetes livez endpoint. + + An endpoint for health checking used in Kubernetes. + + ## Example + + iex> Qdrant.Api.Http.Service.livez() + {:ok, "healthz check passed"} + + """ + @spec livez() :: {:ok, String.t()} | {:error, any()} + def livez do + client() + |> Tesla.get("/livez") + |> parse_response_text() + end + + @doc """ + Kubernetes readyz endpoint. + + An endpoint for health checking used in Kubernetes. + + ## Example + + iex> Qdrant.Api.Http.Service.readyz() + {:ok, "healthz check passed"} + + """ + @spec readyz() :: {:ok, String.t()} | {:error, any()} + def readyz do + client() + |> Tesla.get("/readyz") + |> parse_response_text() + end + + @doc """ + Get a report of performance issues and configuration suggestions. + + This is a Beta endpoint. + + ## Example + + iex> Qdrant.Api.Http.Service.get_issues() + {:ok, %{"issues" => [...]}} + + """ + @spec get_issues() :: {:ok, map()} | {:error, any()} + def get_issues do + client() + |> Tesla.get("/issues") + |> parse_response() + end + + @doc """ + Removes all issues reported so far. + + This is a Beta endpoint. + + ## Example + + iex> Qdrant.Api.Http.Service.clear_issues() + {:ok, true} + + """ + @spec clear_issues() :: {:ok, boolean()} | {:error, any()} + def clear_issues do + client() + |> Tesla.delete("/issues") + |> parse_response() + end + + # Private helpers + defp parse_response({:ok, %Tesla.Env{status: 200, body: body}}) do + {:ok, body} + end + + defp parse_response({:error, reason}) do + {:error, reason} + end + + defp parse_response({:ok, %Tesla.Env{} = env}) do + {:error, %{status: env.status, body: env.body}} + end + + defp parse_response_text({:ok, %Tesla.Env{status: 200, body: body}}) when is_binary(body) do + {:ok, body} + end + + defp parse_response_text({:ok, %Tesla.Env{status: 200, body: body}}) do + {:ok, to_string(body)} + end + + defp parse_response_text({:error, reason}) do + {:error, reason} + end + + defp parse_response_text({:ok, %Tesla.Env{} = env}) do + {:error, %{status: env.status, body: env.body}} + end +end diff --git a/lib/qdrant/api/http/snapshots.ex b/lib/qdrant/api/http/snapshots.ex new file mode 100644 index 0000000..39ffe2f --- /dev/null +++ b/lib/qdrant/api/http/snapshots.ex @@ -0,0 +1,438 @@ +defmodule Qdrant.Api.Http.Snapshots do + @moduledoc """ + Qdrant API Snapshots operations. + + Snapshots allow to backup and restore collection data. + """ + + alias Qdrant.Api.Http.Client + + defp client, do: Client.client() + + # Collection snapshots + + @doc """ + List snapshots for a collection. + + ## Parameters + + * `collection_name` **required** - Name of the collection + + ## Example + + iex> Qdrant.Api.Http.Snapshots.list_snapshots("my_collection") + {:ok, %{"result" => %{"name" => "snapshot.snapshot", ...}}} + + """ + @spec list_snapshots(String.t()) :: {:ok, map()} | {:error, any()} + def list_snapshots(collection_name) do + client() + |> Tesla.get("/collections/#{collection_name}/snapshots") + |> parse_response() + end + + @doc """ + Create a snapshot for a collection. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `wait` - Optional boolean, if true, wait for changes to actually happen + + ## Example + + iex> Qdrant.Api.Http.Snapshots.create_snapshot("my_collection") + {:ok, %{"result" => %{"name" => "snapshot.snapshot", ...}}} + + """ + @spec create_snapshot(String.t(), boolean() | nil) :: {:ok, map()} | {:error, any()} + def create_snapshot(collection_name, wait \\ nil) do + path = "/collections/#{collection_name}/snapshots" |> Client.add_query_param("wait", wait) + + client() + |> Tesla.post(path, %{}) + |> parse_response() + end + + @doc """ + Get information about a specific snapshot. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `snapshot_name` **required** - Name of the snapshot + + ## Example + + iex> Qdrant.Api.Http.Snapshots.get_snapshot("my_collection", "snapshot.snapshot") + {:ok, %{...}} + + """ + @spec get_snapshot(String.t(), String.t()) :: {:ok, map()} | {:error, any()} + def get_snapshot(collection_name, snapshot_name) do + client() + |> Tesla.get("/collections/#{collection_name}/snapshots/#{snapshot_name}") + |> parse_response_binary() + end + + @doc """ + Delete a specific snapshot. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `snapshot_name` **required** - Name of the snapshot + + ## Example + + iex> Qdrant.Api.Http.Snapshots.delete_snapshot("my_collection", "snapshot.snapshot") + {:ok, %{"result" => true, "status" => "ok"}} + + """ + @spec delete_snapshot(String.t(), String.t()) :: {:ok, map()} | {:error, any()} + def delete_snapshot(collection_name, snapshot_name) do + client() + |> Tesla.delete("/collections/#{collection_name}/snapshots/#{snapshot_name}") + |> parse_response() + end + + @doc """ + Recover collection from a snapshot. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `body` **required** - Snapshot recovery configuration + * `wait` - Optional boolean, if true, wait for changes to actually happen + * `priority` - Optional priority for snapshot recovery + + ## Example + + iex> body = %{location: "snapshot.snapshot"} + iex> Qdrant.Api.Http.Snapshots.recover_from_snapshot("my_collection", body) + {:ok, %{"result" => true, "status" => "ok"}} + + """ + @spec recover_from_snapshot(String.t(), map(), boolean() | nil, String.t() | nil) :: + {:ok, map()} | {:error, any()} + def recover_from_snapshot(collection_name, body, wait \\ nil, priority \\ nil) do + path = + "/collections/#{collection_name}/snapshots/recover" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("priority", priority) + + client() + |> Tesla.put(path, body) + |> parse_response() + end + + @doc """ + Recover collection from an uploaded snapshot. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `snapshot_data` **required** - Binary snapshot data (multipart form data) + * `wait` - Optional boolean, if true, wait for changes to actually happen + * `priority` - Optional priority for snapshot recovery + * `checksum` - Optional SHA256 checksum to verify snapshot integrity + + ## Example + + iex> snapshot_data = File.read!("snapshot.snapshot") + iex> Qdrant.Api.Http.Snapshots.recover_from_uploaded_snapshot("my_collection", snapshot_data) + {:ok, %{"result" => true, "status" => "ok"}} + + """ + @spec recover_from_uploaded_snapshot(String.t(), binary(), boolean() | nil, String.t() | nil, String.t() | nil) :: + {:ok, map()} | {:error, any()} + def recover_from_uploaded_snapshot( + collection_name, + snapshot_data, + wait \\ nil, + priority \\ nil, + checksum \\ nil + ) do + path = + "/collections/#{collection_name}/snapshots/upload" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("priority", priority) + |> Client.add_query_param("checksum", checksum) + + multipart = Tesla.Multipart.new() |> Tesla.Multipart.add_file_content(snapshot_data, "snapshot") + + client() + |> Tesla.post(path, multipart) + |> parse_response() + end + + # Full snapshots + + @doc """ + List full snapshots. + + ## Example + + iex> Qdrant.Api.Http.Snapshots.list_full_snapshots() + {:ok, %{"result" => %{"name" => "full-snapshot.snapshot", ...}}} + + """ + @spec list_full_snapshots() :: {:ok, map()} | {:error, any()} + def list_full_snapshots do + client() + |> Tesla.get("/snapshots") + |> parse_response() + end + + @doc """ + Create a full snapshot. + + ## Parameters + + * `wait` - Optional boolean, if true, wait for changes to actually happen + + ## Example + + iex> Qdrant.Api.Http.Snapshots.create_full_snapshot() + {:ok, %{"result" => %{"name" => "full-snapshot.snapshot", ...}}} + + """ + @spec create_full_snapshot(boolean() | nil) :: {:ok, map()} | {:error, any()} + def create_full_snapshot(wait \\ nil) do + path = "/snapshots" |> Client.add_query_param("wait", wait) + + client() + |> Tesla.post(path, %{}) + |> parse_response() + end + + @doc """ + Get information about a specific full snapshot. + + ## Parameters + + * `snapshot_name` **required** - Name of the snapshot + + ## Example + + iex> Qdrant.Api.Http.Snapshots.get_full_snapshot("full-snapshot.snapshot") + {:ok, %{...}} + + """ + @spec get_full_snapshot(String.t()) :: {:ok, binary()} | {:error, any()} + def get_full_snapshot(snapshot_name) do + client() + |> Tesla.get("/snapshots/#{snapshot_name}") + |> parse_response_binary() + end + + @doc """ + Delete a specific full snapshot. + + ## Parameters + + * `snapshot_name` **required** - Name of the snapshot + + ## Example + + iex> Qdrant.Api.Http.Snapshots.delete_full_snapshot("full-snapshot.snapshot") + {:ok, %{"result" => true, "status" => "ok"}} + + """ + @spec delete_full_snapshot(String.t()) :: {:ok, map()} | {:error, any()} + def delete_full_snapshot(snapshot_name) do + client() + |> Tesla.delete("/snapshots/#{snapshot_name}") + |> parse_response() + end + + # Shard snapshots + + @doc """ + List snapshots for a shard. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `shard_id` **required** - ID of the shard + + ## Example + + iex> Qdrant.Api.Http.Snapshots.list_shard_snapshots("my_collection", 1) + {:ok, %{"result" => %{"name" => "shard-snapshot.snapshot", ...}}} + + """ + @spec list_shard_snapshots(String.t(), integer()) :: {:ok, map()} | {:error, any()} + def list_shard_snapshots(collection_name, shard_id) do + client() + |> Tesla.get("/collections/#{collection_name}/shards/#{shard_id}/snapshots") + |> parse_response() + end + + @doc """ + Create a snapshot for a shard. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `shard_id` **required** - ID of the shard + * `wait` - Optional boolean, if true, wait for changes to actually happen + + ## Example + + iex> Qdrant.Api.Http.Snapshots.create_shard_snapshot("my_collection", 1) + {:ok, %{"result" => %{"name" => "shard-snapshot.snapshot", ...}}} + + """ + @spec create_shard_snapshot(String.t(), integer(), boolean() | nil) :: {:ok, map()} | {:error, any()} + def create_shard_snapshot(collection_name, shard_id, wait \\ nil) do + path = + "/collections/#{collection_name}/shards/#{shard_id}/snapshots" + |> Client.add_query_param("wait", wait) + + client() + |> Tesla.post(path, %{}) + |> parse_response() + end + + @doc """ + Get information about a specific shard snapshot. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `shard_id` **required** - ID of the shard + * `snapshot_name` **required** - Name of the snapshot + + ## Example + + iex> Qdrant.Api.Http.Snapshots.get_shard_snapshot("my_collection", 1, "shard-snapshot.snapshot") + {:ok, %{...}} + + """ + @spec get_shard_snapshot(String.t(), integer(), String.t()) :: {:ok, binary()} | {:error, any()} + def get_shard_snapshot(collection_name, shard_id, snapshot_name) do + client() + |> Tesla.get("/collections/#{collection_name}/shards/#{shard_id}/snapshots/#{snapshot_name}") + |> parse_response_binary() + end + + @doc """ + Delete a specific shard snapshot. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `shard_id` **required** - ID of the shard + * `snapshot_name` **required** - Name of the snapshot + + ## Example + + iex> Qdrant.Api.Http.Snapshots.delete_shard_snapshot("my_collection", 1, "shard-snapshot.snapshot") + {:ok, %{"result" => true, "status" => "ok"}} + + """ + @spec delete_shard_snapshot(String.t(), integer(), String.t()) :: {:ok, map()} | {:error, any()} + def delete_shard_snapshot(collection_name, shard_id, snapshot_name) do + client() + |> Tesla.delete("/collections/#{collection_name}/shards/#{shard_id}/snapshots/#{snapshot_name}") + |> parse_response() + end + + @doc """ + Recover shard from a snapshot. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `shard_id` **required** - ID of the shard + * `body` **required** - Snapshot recovery configuration + * `wait` - Optional boolean, if true, wait for changes to actually happen + * `priority` - Optional priority for snapshot recovery + + ## Example + + iex> body = %{location: "shard-snapshot.snapshot"} + iex> Qdrant.Api.Http.Snapshots.recover_shard_from_snapshot("my_collection", 1, body) + {:ok, %{"result" => true, "status" => "ok"}} + + """ + @spec recover_shard_from_snapshot(String.t(), integer(), map(), boolean() | nil, String.t() | nil) :: + {:ok, map()} | {:error, any()} + def recover_shard_from_snapshot(collection_name, shard_id, body, wait \\ nil, priority \\ nil) do + path = + "/collections/#{collection_name}/shards/#{shard_id}/snapshots/recover" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("priority", priority) + + client() + |> Tesla.put(path, body) + |> parse_response() + end + + @doc """ + Recover shard from an uploaded snapshot. + + ## Parameters + + * `collection_name` **required** - Name of the collection + * `shard_id` **required** - ID of the shard + * `snapshot_data` **required** - Binary snapshot data (multipart form data) + * `wait` - Optional boolean, if true, wait for changes to actually happen + * `priority` - Optional priority for snapshot recovery + + ## Example + + iex> snapshot_data = File.read!("shard-snapshot.snapshot") + iex> Qdrant.Api.Http.Snapshots.recover_shard_from_uploaded_snapshot("my_collection", 1, snapshot_data) + {:ok, %{"result" => true, "status" => "ok"}} + + """ + @spec recover_shard_from_uploaded_snapshot(String.t(), integer(), binary(), boolean() | nil, String.t() | nil) :: + {:ok, map()} | {:error, any()} + def recover_shard_from_uploaded_snapshot(collection_name, shard_id, snapshot_data, wait \\ nil, priority \\ nil) do + path = + "/collections/#{collection_name}/shards/#{shard_id}/snapshots/upload" + |> Client.add_query_param("wait", wait) + |> Client.add_query_param("priority", priority) + + multipart = Tesla.Multipart.new() |> Tesla.Multipart.add_file_content(snapshot_data, "snapshot") + + client() + |> Tesla.post(path, multipart) + |> parse_response() + end + + # Private helpers + defp parse_response({:ok, %Tesla.Env{status: 200, body: body}}) do + {:ok, body} + end + + defp parse_response({:ok, %Tesla.Env{status: 202, body: body}}) do + {:ok, body} + end + + defp parse_response({:error, reason}) do + {:error, reason} + end + + defp parse_response({:ok, %Tesla.Env{} = env}) do + {:error, %{status: env.status, body: env.body}} + end + + defp parse_response_binary({:ok, %Tesla.Env{status: 200, body: body}}) when is_binary(body) do + {:ok, body} + end + + defp parse_response_binary({:ok, %Tesla.Env{status: 200, body: body}}) do + {:ok, body} + end + + defp parse_response_binary({:error, reason}) do + {:error, reason} + end + + defp parse_response_binary({:ok, %Tesla.Env{} = env}) do + {:error, %{status: env.status, body: env.body}} + end +end diff --git a/lib/qdrant/api/wrapper.ex b/lib/qdrant/api/wrapper.ex index ad23542..7ea34de 100644 --- a/lib/qdrant/api/wrapper.ex +++ b/lib/qdrant/api/wrapper.ex @@ -2,7 +2,9 @@ defmodule Qdrant.Api.Wrapper do defmacro __using__(_opts) do quote do defp module_name(module) do - case Application.get_env(:qdrant, :interface) do + interface = Application.get_env(:qdrant, :interface, "rest") + + case interface do "rest" -> Module.concat(["Qdrant", "Api", "Http", module]) diff --git a/lib/qdrant/config.ex b/lib/qdrant/config.ex new file mode 100644 index 0000000..bec3bfb --- /dev/null +++ b/lib/qdrant/config.ex @@ -0,0 +1,239 @@ +defmodule Qdrant.Config do + @moduledoc """ + Configuration module for Qdrant client. + + This module handles all configuration logic, checking application config first, + then environment variables, and finally defaults. Application config takes priority. + + ## Configuration Options + + You can configure the client in your `config/config.exs`: + + ```elixir + config :qdrant, + url: "http://localhost:6333", + require_api_key: false, # Set to true for Qdrant Cloud + api_key: "your-api-key" # Required if require_api_key is true + ``` + + Or use separate URL and port (for backward compatibility): + + ```elixir + config :qdrant, + database_url: "http://localhost", + port: 6333, + require_api_key: false, + api_key: "your-api-key" + ``` + + ## Environment Variables + + Alternatively, you can set these via environment variables: + - `QDRANT_URL` - Full Qdrant server URL (e.g., `http://localhost:6333`) + - `QDRANT_DATABASE_URL` - Qdrant server URL without port (default: `http://localhost`) + - `QDRANT_PORT` - Qdrant server port (default: `6333`) + - `QDRANT_REQUIRE_API_KEY` - Whether API key is required (default: `false`, auto-detected for Qdrant Cloud) + - `QDRANT_API_KEY` - API key for authentication (required if `require_api_key` is true) + + Note: If both `QDRANT_URL` and `QDRANT_DATABASE_URL`+`QDRANT_PORT` are set, + `QDRANT_URL` takes priority. + + ## Qdrant Cloud Detection + + When connecting to Qdrant Cloud, the client will automatically detect this and require + an API key. Cloud instances are detected when: + - The URL contains `cloud.qdrant.io` + - The URL uses HTTPS and is not localhost + + You can also explicitly set `require_api_key: true` in your config. + """ + + require Logger + + @default_url "http://localhost" + @default_port 6333 + + @doc """ + Returns the base URL for Qdrant API requests. + + This function checks for configuration in the following order: + 1. Application config (`:url` - full URL) + 2. Application config (`:database_url` + `:port`) + 3. Environment variable `QDRANT_URL` + 4. Environment variables `QDRANT_DATABASE_URL` + `QDRANT_PORT` + 5. Default values + + Returns a string like `"http://localhost:6333"`. + """ + def base_url do + case get_url() do + nil -> + # Fallback to separate URL and port + "#{get_database_url()}:#{get_port()}" + + url -> + url + end + end + + @doc """ + Returns the full URL from configuration or environment variables. + + Returns `nil` if not set, allowing fallback to separate URL and port. + """ + def get_url do + case Application.get_env(:qdrant, :url) do + nil -> + case System.get_env("QDRANT_URL") do + nil -> nil + env_url -> env_url + end + + config_url -> + config_url + end + end + + @doc """ + Returns the database URL from configuration or environment variables. + + This is used as a fallback when `:url` is not set. + Returns the default URL if not configured. + """ + def get_database_url do + case Application.get_env(:qdrant, :database_url) do + nil -> + case System.get_env("QDRANT_DATABASE_URL") do + nil -> + Logger.warning("Qdrant database URL not set in config or environment variables. Using default URL.") + @default_url + + env_url -> + env_url + end + + config_url -> + config_url + end + end + + @doc """ + Returns the port from configuration or environment variables. + + Returns the default port if not configured. + """ + def get_port do + case Application.get_env(:qdrant, :port) do + nil -> + case System.get_env("QDRANT_PORT") do + nil -> + Logger.warning( + "Qdrant port not set in config or environment variables. Using default port #{@default_port}." + ) + + @default_port + + env_port -> + case Integer.parse(env_port) do + {port, _} -> + port + + :error -> + Logger.warning("Invalid QDRANT_PORT environment variable: #{env_port}") + raise "Invalid QDRANT_PORT environment variable" + end + end + + config_port -> + config_port + end + end + + @doc """ + Returns whether API key authentication is required. + + Returns `true` if: + - Explicitly set via config or environment variable + - Auto-detected as Qdrant Cloud (URL contains cloud.qdrant.io or HTTPS non-localhost) + + Returns `false` by default (for docker/local instances). + """ + def require_api_key? do + case Application.get_env(:qdrant, :require_api_key) do + nil -> + case System.get_env("QDRANT_REQUIRE_API_KEY") do + nil -> + # Auto-detect Qdrant Cloud + is_cloud_instance?() + + env_value -> + String.downcase(env_value) in ["true", "1", "yes"] + end + + config_value -> + config_value + end + end + + @doc """ + Returns the API key from configuration or environment variables. + + Returns `nil` if not set. If `require_api_key?` is true, this will log a warning. + """ + def get_api_key do + api_key = + case Application.get_env(:qdrant, :api_key) do + nil -> + System.get_env("QDRANT_API_KEY") + + config_key -> + config_key + end + + if require_api_key?() and is_nil(api_key) do + Logger.warning( + "Qdrant API key is required but not set. Please set QDRANT_API_KEY environment variable or :api_key in config." + ) + + nil + else + api_key + end + end + + @doc """ + Returns the Tesla adapter module to use for HTTP requests. + + Returns `Qdrant.TestMockAdapter` if a mock adapter is configured via + `:tesla_adapter` in application config, otherwise returns `Tesla.Adapter.Mint`. + + ## Options + + - `:adapter` - Optional adapter override. If provided and truthy, uses mock adapter. + + ## Example + + iex> Qdrant.Config.get_adapter() + Tesla.Adapter.Mint + + iex> Application.put_env(:qdrant, :tesla_adapter, fn _ -> {:ok, %{}} end) + iex> Qdrant.Config.get_adapter() + Qdrant.TestMockAdapter + """ + def get_adapter(opts \\ []) do + adapter_fn = Keyword.get(opts, :adapter) || Application.get_env(:qdrant, :tesla_adapter) + + if adapter_fn do + Qdrant.TestMockAdapter + else + Tesla.Adapter.Mint + end + end + + defp is_cloud_instance? do + url = base_url() + + String.contains?(String.downcase(url), "api.cloud.qdrant.io") || + (String.starts_with?(url, "https://") && not String.contains?(url, "localhost")) + end +end diff --git a/mix.exs b/mix.exs index 3612564..b5d5905 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Qdrant.MixProject do def project do [ app: :qdrant, - version: "0.0.9", + version: "0.1.0", elixir: "~> 1.16", start_permanent: Mix.env() == :prod, deps: deps(), @@ -31,11 +31,11 @@ defmodule Qdrant.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ex_doc, "~> 0.31.1"}, - {:tesla, "~> 1.8"}, - {:jason, "~> 1.4"}, - {:mox, "~> 1.1", only: :test}, - {:excoveralls, "~> 0.18", only: :test} + {:ex_doc, "~> 0.39.1"}, + {:tesla, "~> 1.15"}, + {:mint, "~> 1.7"}, + {:mox, "~> 1.2", only: :test, runtime: false}, + {:excoveralls, "~> 0.18.5", only: :test} ] end diff --git a/mix.lock b/mix.lock index 272d159..e0e9393 100644 --- a/mix.lock +++ b/mix.lock @@ -1,13 +1,20 @@ %{ - "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, - "ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"}, - "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, - "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, - "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, - "makeup_erlang": {:hex, :makeup_erlang, "0.1.4", "29563475afa9b8a2add1b7a9c8fb68d06ca7737648f28398e04461f008b69521", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f4ed47ecda66de70dd817698a703f8816daa91272e7e45812469498614ae8b29"}, - "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, - "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, - "tesla": {:hex, :tesla, "1.8.0", "d511a4f5c5e42538d97eef7c40ec4f3e44effdc5068206f42ed859e09e51d1fd", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "10501f360cd926a309501287470372af1a6e1cbed0f43949203a4c13300bc79f"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"}, }