├── test ├── readme_test.exs ├── arangox │ ├── request_test.exs │ ├── error_test.exs │ ├── endpoint_test.exs │ └── client_test.exs ├── cert.pem ├── test_helper.exs └── arangox_test.exs ├── .formatter.exs ├── .iex.exs ├── CHANGELOG.md ├── lib ├── arangox │ ├── response.ex │ ├── auth.ex │ ├── error.ex │ ├── request.ex │ ├── client.ex │ ├── client │ │ ├── gun.ex │ │ ├── mint.ex │ │ └── velocy.ex │ ├── endpoint.ex │ └── connection.ex └── arangox.ex ├── .gitignore ├── docker-compose.yml ├── LICENSE ├── .github └── workflows │ └── elixir.yml ├── mix.exs ├── mix.lock ├── .credo.exs └── README.md /test/readme_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ReadmeTest do 2 | use ExUnit.Case, async: true 3 | doctest_file "README.md" 4 | end -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | alias Arangox.Request 2 | alias Arangox.Response 3 | alias Arangox.Endpoint 4 | alias Arangox.Connection 5 | alias Arangox.Client 6 | alias Arangox.Error 7 | alias Arangox.VelocyClient 8 | alias Arangox.GunClient 9 | alias Arangox.MintClient 10 | alias Velocy, as: VelocyPack 11 | alias Mint.HTTP1, as: Mint 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.7.0 (2024-02-20) 4 | 5 | * Enhancements 6 | * Added support for ArangoDB JWT authentication via bearer tokens 7 | 8 | * Breaking changes 9 | * `auth` start option now only accepts `{:basic, username, password}` or `{:bearer, token}` 10 | * No longer authenticates with "root:" by default 11 | * Requires Elixir v1.7+. 12 | -------------------------------------------------------------------------------- /lib/arangox/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Arangox.Response do 2 | @moduledoc nil 3 | 4 | @type t :: %__MODULE__{ 5 | status: pos_integer, 6 | headers: Arangox.headers(), 7 | body: Arangox.body() 8 | } 9 | 10 | @enforce_keys [:status, :headers] 11 | defstruct [ 12 | :status, 13 | :headers, 14 | :body 15 | ] 16 | end 17 | -------------------------------------------------------------------------------- /lib/arangox/auth.ex: -------------------------------------------------------------------------------- 1 | defmodule Arangox.Auth do 2 | @type username :: String.t() 3 | @type password :: String.t() 4 | @type token :: String.t() 5 | 6 | @type t :: {:basic, username, password} | {:bearer, token} 7 | 8 | def validate(auth) do 9 | case auth do 10 | {:basic, _username, _password} -> 11 | :ok 12 | 13 | {:bearer, _token} -> 14 | :ok 15 | 16 | _ -> 17 | raise ArgumentError, """ 18 | The :auth option expects one of the following: 19 | 20 | {:basic, username, password} 21 | {:bearer, token}, 22 | 23 | Instead, got: #{inspect(auth)} 24 | """ 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | arangox-*.tar 24 | 25 | .idea 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | single_no_auth: 4 | image: arangodb/arangodb 5 | environment: 6 | - ARANGO_NO_AUTH=1 7 | ports: 8 | - '8529:8529' 9 | 10 | single_auth: 11 | image: arangodb/arangodb 12 | environment: 13 | - ARANGO_ROOT_PASSWORD= 14 | volumes: 15 | - ./test:/var/lib/arangox/test 16 | ports: 17 | - '8001:8529' 18 | - '8002:8530' 19 | command: 20 | - arangod 21 | - --server.endpoint=tcp://0.0.0.0:8529 22 | - --server.endpoint=ssl://0.0.0.0:8530 23 | - --ssl.keyfile=var/lib/arangox/test/cert.pem 24 | 25 | resilient_single: 26 | image: arangodb/arangodb:3.11 27 | ports: 28 | - '8003:8529' 29 | - '8004:8539' 30 | - '8005:8549' 31 | command: 32 | - arangodb 33 | - --starter.local 34 | - --starter.mode=activefailover 35 | -------------------------------------------------------------------------------- /lib/arangox/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Arangox.Error do 2 | @type t :: %__MODULE__{ 3 | endpoint: Arangox.endpoint() | nil, 4 | status: pos_integer | nil, 5 | error_num: non_neg_integer, 6 | message: binary 7 | } 8 | 9 | @keys [ 10 | :endpoint, 11 | :status, 12 | :error_num 13 | ] 14 | 15 | defexception [{:message, "arangox error"} | @keys] 16 | 17 | def message(%__MODULE__{message: message} = exception) when is_binary(message) do 18 | prepend(exception) <> message 19 | end 20 | 21 | def message(%__MODULE__{message: message} = exception) do 22 | prepend(exception) <> inspect(message) 23 | end 24 | 25 | defp prepend(%__MODULE__{} = exception) do 26 | for key <- @keys, into: "" do 27 | exception 28 | |> Map.get(key) 29 | |> prepend() 30 | end 31 | end 32 | 33 | defp prepend(nil), do: "" 34 | defp prepend(key), do: "[#{key}] " 35 | end 36 | -------------------------------------------------------------------------------- /lib/arangox/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Arangox.Request do 2 | @moduledoc nil 3 | 4 | alias __MODULE__ 5 | alias Arangox.Response 6 | 7 | @type t :: %__MODULE__{ 8 | method: Arangox.method(), 9 | path: Arangox.path(), 10 | headers: Arangox.headers(), 11 | body: Arangox.body() 12 | } 13 | 14 | @enforce_keys [:method, :path] 15 | 16 | defstruct [ 17 | :method, 18 | :path, 19 | headers: %{}, 20 | body: "" 21 | ] 22 | 23 | defimpl DBConnection.Query do 24 | def parse(request, _opts), do: request 25 | 26 | def describe(request, _opts), do: request 27 | 28 | def encode(%Request{path: "/" <> _path} = request, _params, _opts), do: request 29 | 30 | def encode(%Request{path: path} = request, params, opts), 31 | do: encode(%Request{request | path: "/" <> path}, params, opts) 32 | 33 | def decode(_query, %Response{} = response, _opts), do: response 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/arangox/request_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Arangox.RequestTest do 2 | use ExUnit.Case, async: true 3 | alias Arangox.{Request, Response} 4 | alias DBConnection.Query 5 | 6 | @request %Request{method: :method, path: "/path"} 7 | @response %Response{status: 000, headers: []} 8 | 9 | test "body must default to \"\" and headers to %{}" do 10 | assert @request == %{@request | body: "", headers: %{}} 11 | end 12 | 13 | describe "DBConnection.Query protocol:" do 14 | test "parse" do 15 | assert Query.parse(@request, []) == @request 16 | end 17 | 18 | test "describe" do 19 | assert Query.describe(@request, []) == @request 20 | end 21 | 22 | test "encode" do 23 | assert Query.encode(%{@request | path: "/path"}, [], []) == %{@request | path: "/path"} 24 | assert Query.encode(%{@request | path: "path"}, [], []) == %{@request | path: "/path"} 25 | end 26 | 27 | test "decode" do 28 | assert Query.decode(@request, @response, []) == @response 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9sDK1rm3Owql62pM 3 | CG2c8q1Xd/gTdfGd66bHUjZKV5WhRANCAATjHBHbgLELN2HyulWEMzm7BoBRP9Bt 4 | 6FRsOij9C4fk3MCBwVNSjadDe1m39LhCqhXxlFtvrQ/HKyh5CTFT0H3R 5 | -----END PRIVATE KEY----- 6 | -----BEGIN CERTIFICATE----- 7 | MIIB8DCCAZegAwIBAgIUVGn9eensV2HiUowHN6ShrEFn2H0wCgYIKoZIzj0EAwIw 8 | TjELMAkGA1UEBhMCQU0xEDAOBgNVBAgMB1llcmV2YW4xEDAOBgNVBAcMB1llcmV2 9 | YW4xGzAZBgNVBAoMEkFyYW5nb0RCIENvbW11bml0eTAeFw0yNDAyMjAwOTU2MzJa 10 | Fw0zNDAyMTcwOTU2MzJaME4xCzAJBgNVBAYTAkFNMRAwDgYDVQQIDAdZZXJldmFu 11 | MRAwDgYDVQQHDAdZZXJldmFuMRswGQYDVQQKDBJBcmFuZ29EQiBDb21tdW5pdHkw 12 | WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATjHBHbgLELN2HyulWEMzm7BoBRP9Bt 13 | 6FRsOij9C4fk3MCBwVNSjadDe1m39LhCqhXxlFtvrQ/HKyh5CTFT0H3Ro1MwUTAd 14 | BgNVHQ4EFgQUDw8NcsHfFuCLqCBl82A0bIgtFiQwHwYDVR0jBBgwFoAUDw8NcsHf 15 | FuCLqCBl82A0bIgtFiQwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNHADBE 16 | AiBbiKH0oNDLktTs0D9c46Wghy44TRvcLNHJ3Qi0O2KFzwIgWwyrylW1G86SSER5 17 | AVtGXozmB5x2Dx/F9AjUS2yX5jE= 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /test/arangox/error_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Arangox.ErrorTest do 2 | use ExUnit.Case, async: true 3 | alias Arangox.Error 4 | 5 | @endpoint %Error{ 6 | message: "message", 7 | endpoint: "endpoint" 8 | } 9 | 10 | @status %Error{ 11 | message: "message", 12 | status: "status" 13 | } 14 | 15 | @endpoint_and_status %Error{ 16 | message: "message", 17 | endpoint: "endpoint", 18 | status: "status" 19 | } 20 | 21 | test "stringify non-binary messages" do 22 | assert Exception.message(%Error{message: :a}) == ":a" 23 | assert Exception.message(%Error{message: {}}) == "{}" 24 | assert Exception.message(%Error{message: %{}}) == "%{}" 25 | end 26 | 27 | test "prepend messages with keys when present" do 28 | assert Exception.message(%Error{message: "message"}) == "message" 29 | assert Exception.message(@endpoint) == "[endpoint] message" 30 | assert Exception.message(@status) == "[status] message" 31 | assert Exception.message(@endpoint_and_status) == "[endpoint] [status] message" 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Zareh Petrossian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Elixir CI 7 | 8 | on: 9 | push: 10 | branches: 11 | - "main" 12 | - "dev" 13 | pull_request: 14 | branches: 15 | - "main" 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | build: 22 | 23 | name: Build and test 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v3 28 | - name: Set up Elixir 29 | uses: erlef/setup-beam@61e01a43a562a89bfc54c7f9a378ff67b03e4a21 # v1.16.0 30 | with: 31 | elixir-version: '1.16' 32 | otp-version: '26' 33 | - name: Restore dependencies cache 34 | uses: actions/cache@v3 35 | with: 36 | path: deps 37 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 38 | restore-keys: ${{ runner.os }}-mix- 39 | - name: Install dependencies 40 | run: mix deps.get 41 | - name: Run ArangoDB containers 42 | run: docker compose up --detach --wait --wait-timeout 45 43 | - name: Run tests 44 | run: mix test 45 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | defmodule TestHelper do 2 | def opts(opts \\ []) do 3 | default_opts = [ 4 | pool_size: 10, 5 | show_sensitive_data_on_connection_error: true, 6 | ] 7 | 8 | Keyword.merge(default_opts, opts) 9 | end 10 | 11 | def unreachable, do: "http://fake_endpoint:1234" 12 | # default is pointing to instance with disabled authentication 13 | def default, do: "http://localhost:8529" 14 | def auth, do: "http://localhost:8001" 15 | def ssl, do: "ssl://localhost:8002" 16 | def failover_1, do: "http://localhost:8003" 17 | def failover_2, do: "http://localhost:8004" 18 | def failover_3, do: "http://localhost:8005" 19 | 20 | def failover_callback(exception, self) do 21 | send(self, {:tuple, exception}) 22 | end 23 | end 24 | 25 | defmodule TestClient do 26 | alias Arangox.{ 27 | Connection, 28 | Request, 29 | Response 30 | } 31 | 32 | @behaviour Arangox.Client 33 | 34 | @impl true 35 | def connect(_endpoint, _opts), do: {:ok, :socket} 36 | 37 | @impl true 38 | def alive?(%Connection{client: __MODULE__}), do: true 39 | 40 | @impl true 41 | def request(%Request{}, %Connection{client: __MODULE__} = state), 42 | do: {:ok, struct(Response, []), state} 43 | 44 | @impl true 45 | def close(%Connection{client: __MODULE__}), do: :ok 46 | end 47 | 48 | {os_type, _} = :os.type() 49 | 50 | excludes = List.delete([:unix], os_type) 51 | 52 | assert_timeout = String.to_integer(System.get_env("ELIXIR_ASSERT_TIMEOUT") || "15000") 53 | 54 | ExUnit.start( 55 | exclude: excludes, 56 | assert_receive_timeout: assert_timeout, 57 | capture_log: true 58 | ) 59 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Arangox.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.7.0" 5 | @description """ 6 | ArangoDB 3.11 driver for Elixir with connection pooling, support for \ 7 | VelocyStream, active failover, transactions and streamed cursors. 8 | """ 9 | @source_url "https://github.com/ArangoDB-Community/arangox" 10 | @homepage_url "https://www.arangodb.com" 11 | 12 | def project do 13 | [ 14 | app: :arangox, 15 | version: @version, 16 | elixir: ">= 1.7.0", 17 | start_permanent: Mix.env() == :prod, 18 | name: "Arangox", 19 | description: @description, 20 | source_url: @source_url, 21 | homepage_url: @homepage_url, 22 | package: package(), 23 | docs: docs(), 24 | deps: deps() 25 | ] 26 | end 27 | 28 | # Run "mix help compile.app" to learn about applications. 29 | def application do 30 | [extra_applications: [:logger] ++ extras(Mix.env())] 31 | end 32 | 33 | defp extras(:prod), do: [] 34 | defp extras(_), do: [:gun] 35 | 36 | defp package do 37 | [ 38 | licenses: ["MIT"], 39 | links: %{"GitHub" => @source_url} 40 | ] 41 | end 42 | 43 | defp docs do 44 | [ 45 | source_ref: "v#{@version}", 46 | main: "readme", 47 | extras: ["README.md"] 48 | ] 49 | end 50 | 51 | # Run "mix help deps" to learn about dependencies. 52 | defp deps do 53 | [ 54 | {:db_connection, "~> 2.6"}, 55 | {:velocy, "~> 0.1", optional: true}, 56 | {:gun, "~> 2.0", optional: true}, 57 | {:mint, "~> 1.5", optional: true}, 58 | {:jason, "> 0.0.0", optional: true}, 59 | {:ex_doc, "> 0.0.0", only: :dev, runtime: false}, 60 | ] 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/arangox/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Arangox.Client do 2 | @moduledoc """ 3 | HTTP client behaviour for `Arangox`. Arangox uses client implementations to 4 | perform all it's connection and execution operations. 5 | 6 | To use an http library other than `:gun` or `:mint`, implement this behaviour 7 | in a module and pass that module to the `:client` start option. 8 | """ 9 | 10 | alias Arangox.{ 11 | Connection, 12 | Request, 13 | Response 14 | } 15 | 16 | @type socket :: any 17 | @type exception_or_reason :: any 18 | 19 | @doc """ 20 | Receives an `Arangox.Endpoint` struct and all the start options from `Arangox.start_link/1`. 21 | 22 | The `socket` returned from this callback gets placed in the `:socket` field 23 | of an `Arango.Connection` struct (a connection's state) to be used by the 24 | other callbacks as needed. It can be anything, a tuple, another struct, whatever 25 | the client needs. 26 | 27 | It's up to the client to consolidate the `:connect_timeout`, `:transport_opts` 28 | and `:client_opts` options. 29 | """ 30 | @callback connect(endpoint :: Endpoint.t(), start_options :: [Arangox.start_option()]) :: 31 | {:ok, socket} | {:error, exception_or_reason} 32 | 33 | @callback alive?(state :: Connection.t()) :: boolean 34 | 35 | @doc """ 36 | Receives a `Arangox.Request` struct and a connection's state (an `Arangox.Connection` 37 | struct), and returns an `Arangox.Response` struct or error (or exception struct), 38 | along with the new state (which doesn't necessarily need to change). 39 | 40 | Arangox handles the encoding and decoding of request and response bodies, and merging headers. 41 | 42 | If a connection is lost, this may return `{:error, :noproc, state}` to force a disconnect, 43 | otherwise an attempt to reconnect may not be made until the next request hitting this process 44 | fails. 45 | """ 46 | @callback request(request :: Request.t(), state :: Connection.t()) :: 47 | {:ok, Response.t(), Connection.t()} | {:error, exception_or_reason, Connection.t()} 48 | 49 | @callback close(state :: Connection.t()) :: :ok 50 | 51 | # API 52 | 53 | @spec connect(module, Endpoint.t(), [Arangox.start_option()]) :: 54 | {:ok, socket} | {:error, exception_or_reason} 55 | def connect(client, endpoint, start_options), do: client.connect(endpoint, start_options) 56 | 57 | @spec alive?(Connection.t()) :: boolean 58 | def alive?(%Connection{client: client} = state), do: client.alive?(state) 59 | 60 | @spec request(Request.t(), Connection.t()) :: 61 | {:ok, Response.t(), Connection.t()} | {:error, exception_or_reason, Connection.t()} 62 | def request(%Request{} = request, %Connection{client: client} = state), 63 | do: client.request(request, state) 64 | 65 | @spec close(Connection.t()) :: :ok 66 | def close(%Connection{client: client} = state), do: client.close(state) 67 | end 68 | -------------------------------------------------------------------------------- /test/arangox/endpoint_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Arangox.EndpointTest do 2 | use ExUnit.Case, async: true 3 | alias Arangox.Endpoint 4 | import Arangox.Endpoint 5 | 6 | test "parsing an endpoint" do 7 | assert %Endpoint{addr: {:tcp, "host", 123}, ssl?: false} = new("tcp://host:123") 8 | assert %Endpoint{addr: {:tcp, "host", 123}, ssl?: true} = new("ssl://host:123") 9 | assert %Endpoint{addr: {:tcp, "host", 123}, ssl?: true} = new("tls://host:123") 10 | assert %Endpoint{addr: {:tcp, "host", 123}, ssl?: false} = new("http://host:123") 11 | assert %Endpoint{addr: {:tcp, "host", 123}, ssl?: true} = new("https://host:123") 12 | assert %Endpoint{addr: {:unix, "/path.sock"}, ssl?: false} = new("unix:///path.sock") 13 | assert %Endpoint{addr: {:unix, "/path.sock"}, ssl?: false} = new("tcp+unix:///path.sock") 14 | assert %Endpoint{addr: {:unix, "/path.sock"}, ssl?: true} = new("ssl+unix:///path.sock") 15 | assert %Endpoint{addr: {:unix, "/path.sock"}, ssl?: true} = new("tls+unix:///path.sock") 16 | assert %Endpoint{addr: {:unix, "/path.sock"}, ssl?: false} = new("http+unix:///path.sock") 17 | assert %Endpoint{addr: {:unix, "/path.sock"}, ssl?: true} = new("https+unix:///path.sock") 18 | assert %Endpoint{addr: {:unix, "/path.sock"}, ssl?: false} = new("tcp://unix:/path.sock") 19 | assert %Endpoint{addr: {:unix, "/path.sock"}, ssl?: true} = new("ssl://unix:/path.sock") 20 | assert %Endpoint{addr: {:unix, "/path.sock"}, ssl?: true} = new("tls://unix:/path.sock") 21 | assert %Endpoint{addr: {:unix, "/path.sock"}, ssl?: false} = new("http://unix:/path.sock") 22 | assert %Endpoint{addr: {:unix, "/path.sock"}, ssl?: true} = new("https://unix:/path.sock") 23 | 24 | assert_raise ArgumentError, fn -> new("") end 25 | assert_raise ArgumentError, fn -> new("host") end 26 | assert_raise ArgumentError, fn -> new("host:123") end 27 | 28 | assert_raise ArgumentError, 29 | "Missing host or port in endpoint configuration: \"http://\"", 30 | fn -> new("http://") end 31 | 32 | assert_raise ArgumentError, 33 | "Missing host or port in endpoint configuration: \"http://host\"", 34 | fn -> new("http://host") end 35 | 36 | assert_raise ArgumentError, 37 | "Missing host or port in endpoint configuration: \"http://:123\"", 38 | fn -> new("http://:123") end 39 | 40 | assert_raise ArgumentError, 41 | "Invalid protocol in endpoint configuration: \"unexpected://host:123\"", 42 | fn -> new("unexpected://host:123") end 43 | 44 | assert_raise ArgumentError, 45 | "Invalid protocol in endpoint configuration: \"unexpected+ssl://host:123\"", 46 | fn -> new("unexpected+ssl://host:123") end 47 | 48 | assert_raise ArgumentError, 49 | "Invalid protocol in endpoint configuration: \"http+unexpected://host:123\"", 50 | fn -> new("http+unexpected://host:123") end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/arangox/client/gun.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:gun) do 2 | defmodule Arangox.GunClient do 3 | @moduledoc """ 4 | An HTTP client implementation of the \ 5 | [`:gun`](https://ninenines.eu/docs/en/gun/1.3/guide "documentation") \ 6 | library. Requires [`:gun`](https://hex.pm/packages/gun "hex.pm") to be added 7 | as a dependency. 8 | 9 | [__Hex.pm__](https://hex.pm/packages/gun) 10 | 11 | [__Documentation__](https://ninenines.eu/docs/en/gun/1.3/guide) 12 | """ 13 | 14 | alias :gun, as: Gun 15 | 16 | alias Arangox.{ 17 | Client, 18 | Connection, 19 | Endpoint, 20 | Request, 21 | Response 22 | } 23 | 24 | @behaviour Client 25 | 26 | @impl true 27 | def connect(%Endpoint{addr: addr, ssl?: ssl?}, opts) do 28 | transport = if ssl?, do: :tls, else: :tcp 29 | connect_timeout = Keyword.get(opts, :connect_timeout, 5_000) 30 | tcp_opts = Keyword.get(opts, :tcp_opts, []) 31 | tls_opts = Keyword.get(opts, :ssl_opts, []) 32 | client_opts = Keyword.get(opts, :client_opts, %{}) 33 | 34 | options = %{ 35 | protocols: [:http], 36 | http_opts: %{keepalive: :infinity}, 37 | retry: 0, 38 | transport: transport, 39 | tcp_opts: tcp_opts, 40 | tls_opts: tls_opts, 41 | connect_timeout: connect_timeout 42 | } 43 | 44 | options = Map.merge(options, client_opts) 45 | 46 | with( 47 | {:ok, pid} <- open(addr, options), 48 | {:ok, _protocol} <- Gun.await_up(pid, connect_timeout) 49 | ) do 50 | {:ok, pid} 51 | else 52 | {:error, {:options, options}} -> 53 | exit(options) 54 | 55 | {:error, {:badarg, _}} -> 56 | exit(:badarg) 57 | 58 | {:error, {:shutdown, reason}} -> 59 | {:error, reason} 60 | 61 | {:error, reason} -> 62 | {:error, reason} 63 | end 64 | end 65 | 66 | defp open({:unix, path}, options) do 67 | path 68 | |> to_charlist() 69 | |> Gun.open_unix(options) 70 | end 71 | 72 | defp open({:tcp, host, port}, options) do 73 | host 74 | |> to_charlist() 75 | |> Gun.open(port, options) 76 | end 77 | 78 | @impl true 79 | def request(%Request{} = request, %Connection{socket: pid} = state) do 80 | ref = 81 | Gun.request( 82 | pid, 83 | request.method |> Atom.to_string() |> String.upcase(), 84 | request.path, 85 | Enum.into(request.headers, [], fn {k, v} -> {k, v} end), 86 | request.body 87 | ) 88 | 89 | if alive?(state) do 90 | do_await(pid, ref, state) 91 | else 92 | {:error, :noproc, state} 93 | end 94 | end 95 | 96 | defp do_await(pid, ref, state) do 97 | case Gun.await(pid, ref, :infinity) do 98 | {:response, :fin, status, headers} -> 99 | {:ok, %Response{status: status, headers: Map.new(headers)}, state} 100 | 101 | {:response, :nofin, status, headers} -> 102 | case Gun.await_body(pid, ref, :infinity) do 103 | {:ok, body} -> 104 | {:ok, %Response{status: status, headers: Map.new(headers), body: body}, state} 105 | 106 | {:error, reason} -> 107 | {:error, reason, state} 108 | end 109 | 110 | {:error, reason} -> 111 | {:error, reason, state} 112 | end 113 | end 114 | 115 | @impl true 116 | def alive?(%Connection{socket: pid}), do: Process.alive?(pid) 117 | 118 | @impl true 119 | def close(%Connection{socket: pid}), do: Gun.close(pid) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/arangox/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule Arangox.Endpoint do 2 | @moduledoc """ 3 | Utilities for parsing _ArangoDB_ endpoints. 4 | 5 | iex> Endpoint.new("http://localhost:8529") 6 | %Arangox.Endpoint{addr: {:tcp, "localhost", 8529}, ssl?: false} 7 | 8 | iex> Endpoint.new("https://localhost:8529") 9 | %Arangox.Endpoint{addr: {:tcp, "localhost", 8529}, ssl?: true} 10 | 11 | iex> Endpoint.new("http://unix:/tmp/arangodb.sock") 12 | %Arangox.Endpoint{addr: {:unix, "/tmp/arangodb.sock"}, ssl?: false} 13 | """ 14 | 15 | @type addr :: 16 | {:unix, path :: binary} 17 | | {:tcp, host :: binary, port :: non_neg_integer} 18 | 19 | @type t :: %__MODULE__{ 20 | addr: addr, 21 | ssl?: boolean 22 | } 23 | 24 | @keys [:addr, :ssl?] 25 | 26 | @enforce_keys @keys 27 | defstruct @keys 28 | 29 | @doc """ 30 | Parses an endpoint and returns an `%Arangox.Endpoint{}` struct. 31 | """ 32 | @spec new(Arangox.endpoint()) :: %__MODULE__{addr: addr, ssl?: boolean} 33 | def new(endpoint) do 34 | uri = 35 | endpoint 36 | |> URI.parse() 37 | |> Map.update!(:port, &do_port(&1, endpoint)) 38 | 39 | %__MODULE__{addr: do_addr(uri, endpoint), ssl?: ssl?(uri, endpoint)} 40 | end 41 | 42 | defp do_port(80 = port, endpoint), do: maybe_do_port(port, endpoint) 43 | defp do_port(443 = port, endpoint), do: maybe_do_port(port, endpoint) 44 | defp do_port(port, _endpoint), do: port 45 | 46 | defp maybe_do_port(port, endpoint) do 47 | if String.contains?(endpoint, ":" <> Integer.to_string(port)), do: port, else: nil 48 | end 49 | 50 | defp do_addr(uri, endpoint) do 51 | if unix?(uri, endpoint), do: do_unix(uri, endpoint), else: do_tcp(uri, endpoint) 52 | end 53 | 54 | defp do_unix(%URI{path: nil}, endpoint) do 55 | raise ArgumentError, """ 56 | Missing path in unix endpoint configuration: #{inspect(endpoint)}\ 57 | """ 58 | end 59 | 60 | defp do_unix(%URI{path: path}, _endpoint), do: {:unix, path} 61 | 62 | defp do_tcp(%URI{host: nil}, endpoint) do 63 | raise ArgumentError, """ 64 | Missing host or port in endpoint configuration: #{inspect(endpoint)}\ 65 | """ 66 | end 67 | 68 | defp do_tcp(%URI{port: nil}, endpoint) do 69 | raise ArgumentError, """ 70 | Missing host or port in endpoint configuration: #{inspect(endpoint)}\ 71 | """ 72 | end 73 | 74 | defp do_tcp(%URI{host: host, port: port}, _endpoint), do: {:tcp, host, port} 75 | 76 | defp ssl?(%URI{scheme: "https" <> _}, _endpoint), do: true 77 | defp ssl?(%URI{scheme: "ssl" <> _}, _endpoint), do: true 78 | defp ssl?(%URI{scheme: "tls" <> _}, _endpoint), do: true 79 | defp ssl?(_, _endpoint), do: false 80 | 81 | defp unix?(%URI{scheme: "http", host: "unix"}, _endpoint), do: true 82 | defp unix?(%URI{scheme: "https", host: "unix"}, _endpoint), do: true 83 | defp unix?(%URI{scheme: "tcp", host: "unix"}, _endpoint), do: true 84 | defp unix?(%URI{scheme: "ssl", host: "unix"}, _endpoint), do: true 85 | defp unix?(%URI{scheme: "tls", host: "unix"}, _endpoint), do: true 86 | defp unix?(%URI{scheme: "unix"}, _endpoint), do: true 87 | defp unix?(%URI{scheme: "http+unix"}, _endpoint), do: true 88 | defp unix?(%URI{scheme: "https+unix"}, _endpoint), do: true 89 | defp unix?(%URI{scheme: "tcp+unix"}, _endpoint), do: true 90 | defp unix?(%URI{scheme: "ssl+unix"}, _endpoint), do: true 91 | defp unix?(%URI{scheme: "tls+unix"}, _endpoint), do: true 92 | defp unix?(%URI{scheme: "http"}, _endpoint), do: false 93 | defp unix?(%URI{scheme: "https"}, _endpoint), do: false 94 | defp unix?(%URI{scheme: "tcp"}, _endpoint), do: false 95 | defp unix?(%URI{scheme: "ssl"}, _endpoint), do: false 96 | defp unix?(%URI{scheme: "tls"}, _endpoint), do: false 97 | 98 | defp unix?(_, endpoint) do 99 | raise ArgumentError, """ 100 | Invalid protocol in endpoint configuration: #{inspect(endpoint)}\ 101 | """ 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, 4 | "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, 5 | "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 7 | "ex_doc": {:hex, :ex_doc, "0.31.2", "8b06d0a5ac69e1a54df35519c951f1f44a7b7ca9a5bb7a260cd8a174d6322ece", [: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", "317346c14febaba9ca40fd97b5b5919f7751fb85d399cc8e7e8872049f37e0af"}, 8 | "gun": {:hex, :gun, "2.0.1", "160a9a5394800fcba41bc7e6d421295cf9a7894c2252c0678244948e3336ad73", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "a10bc8d6096b9502205022334f719cc9a08d9adcfbfc0dbee9ef31b56274a20b"}, 9 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, 10 | "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, 11 | "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"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [: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", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, 14 | "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, 15 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 16 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 17 | "velocy": {:hex, :velocy, "0.1.7", "3172138eeb407861afee955e8b7612f78ca4600aa2af7cec265398d99168935e", [:mix], [], "hexpm", "2060b3eefbe7e5dbfc13f6c162229d63a8cd164bf10df9996293e64e0a31de40"}, 18 | } 19 | -------------------------------------------------------------------------------- /lib/arangox/client/mint.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Mint.HTTP1) do 2 | defmodule Arangox.MintClient do 3 | @moduledoc """ 4 | An HTTP client implementation of the \ 5 | [`:mint`](https://hexdocs.pm/mint/Mint.HTTP.html "documentation") \ 6 | library. Requires [`:mint`](https://hex.pm/packages/mint "hex.pm") to be 7 | added as a dependency. 8 | 9 | [__Hex.pm__](https://hex.pm/packages/mint) 10 | 11 | [__Documentation__](https://hexdocs.pm/mint/Mint.HTTP.html) 12 | """ 13 | 14 | alias Mint.HTTP1, as: Mint 15 | 16 | alias Arangox.{ 17 | Client, 18 | Connection, 19 | Endpoint, 20 | Request, 21 | Response 22 | } 23 | 24 | @behaviour Client 25 | 26 | @impl true 27 | def connect(%Endpoint{addr: addr, ssl?: ssl?}, opts) do 28 | connect_timeout = Keyword.get(opts, :connect_timeout, 5_000) 29 | transport_opts = if ssl?, do: :ssl_opts, else: :tcp_opts 30 | transport_opts = Keyword.get(opts, transport_opts, []) 31 | transport_opts = Keyword.merge([timeout: connect_timeout], transport_opts) 32 | 33 | transport_opts = 34 | if ssl?, 35 | do: Keyword.put_new(transport_opts, :verify, :verify_none), 36 | else: transport_opts 37 | 38 | client_opts = Keyword.get(opts, :client_opts, []) 39 | options = Keyword.merge([transport_opts: transport_opts], client_opts) 40 | options = Keyword.merge(options, mode: :passive) 41 | 42 | with( 43 | {:ok, conn} <- open(addr, ssl?, options), 44 | true <- Mint.open?(conn) 45 | ) do 46 | {:ok, conn} 47 | else 48 | {:error, exception} -> 49 | {:error, exception} 50 | 51 | false -> 52 | {:error, "connection lost"} 53 | end 54 | end 55 | 56 | defp open({:unix, _path}, _ssl?, _options) do 57 | raise ArgumentError, """ 58 | Mint doesn't support unix sockets :( 59 | """ 60 | end 61 | 62 | defp open({:tcp, host, port}, ssl?, options) do 63 | scheme = if ssl?, do: :https, else: :http 64 | 65 | Mint.connect(scheme, host, port, options) 66 | end 67 | 68 | @impl true 69 | def request( 70 | %Request{method: method, path: path, headers: headers, body: body}, 71 | %Connection{socket: socket} = state 72 | ) do 73 | with( 74 | {:ok, new_socket, ref} <- 75 | Mint.request( 76 | socket, 77 | method 78 | |> to_string() 79 | |> String.upcase(), 80 | path, 81 | Enum.into(headers, []), 82 | body 83 | ), 84 | {:ok, new_socket, buffer} <- 85 | do_recv(new_socket, ref) 86 | ) do 87 | do_response(ref, buffer, %{state | socket: new_socket}) 88 | else 89 | {:error, new_socket, %_{reason: :closed}} -> 90 | {:error, :noproc, %{state | socket: new_socket}} 91 | 92 | {:error, new_socket, %_{reason: :closed}, _} -> 93 | {:error, :noproc, %{state | socket: new_socket}} 94 | 95 | {:error, new_socket, exception} -> 96 | {:error, exception, %{state | socket: new_socket}} 97 | 98 | {:error, new_socket, exception, _} -> 99 | {:error, exception, %{state | socket: new_socket}} 100 | end 101 | end 102 | 103 | defp do_recv(conn, ref, buffer \\ []) do 104 | case Mint.recv(conn, 0, :infinity) do 105 | {:ok, new_conn, next_buffer} -> 106 | if {:done, ref} in next_buffer do 107 | {:ok, new_conn, buffer ++ next_buffer} 108 | else 109 | do_recv(new_conn, ref, buffer ++ next_buffer) 110 | end 111 | 112 | {:error, _, _, _} = error -> 113 | error 114 | end 115 | end 116 | 117 | defp do_response(ref, buffer, state) do 118 | case buffer do 119 | [{:status, ^ref, status}, {:headers, ^ref, headers}, {:done, ^ref}] -> 120 | {:ok, %Response{status: status, headers: Map.new(headers)}, state} 121 | 122 | [{:status, ^ref, status}, {:headers, ^ref, headers}, {:data, ^ref, body}, {:done, ^ref}] -> 123 | {:ok, %Response{status: status, headers: Map.new(headers), body: body}, state} 124 | 125 | [{:status, ^ref, status}, {:headers, ^ref, headers} | rest_buffer] -> 126 | body = 127 | for kv <- rest_buffer, into: "" do 128 | case kv do 129 | {:data, ^ref, data} -> 130 | data 131 | 132 | {:done, ^ref} -> 133 | "" 134 | end 135 | end 136 | 137 | {:ok, %Response{status: status, headers: Map.new(headers), body: body}, state} 138 | end 139 | end 140 | 141 | @impl true 142 | def alive?(%Connection{socket: conn}), do: Mint.open?(conn) 143 | 144 | @impl true 145 | def close(%Connection{socket: conn}) do 146 | Mint.close(conn) 147 | 148 | :ok 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "src/", "test/", "web/", "apps/", "dev/"], 25 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 26 | }, 27 | # 28 | # Load and configure plugins here: 29 | # 30 | plugins: [], 31 | # 32 | # If you create your own checks, you must specify the source files for 33 | # them here, so they can be loaded by Credo before running the analysis. 34 | # 35 | requires: [], 36 | # 37 | # If you want to enforce a style guide and need a more traditional linting 38 | # experience, you can change `strict` to `true` below: 39 | # 40 | strict: false, 41 | # 42 | # If you want to use uncolored output by default, you can change `color` 43 | # to `false` below: 44 | # 45 | color: true, 46 | # 47 | # You can customize the parameters of any check by adding a second element 48 | # to the tuple. 49 | # 50 | # To disable a check put `false` as second element: 51 | # 52 | # {Credo.Check.Design.DuplicatedCode, false} 53 | # 54 | checks: [ 55 | # 56 | ## Consistency Checks 57 | # 58 | {Credo.Check.Consistency.ExceptionNames, []}, 59 | {Credo.Check.Consistency.LineEndings, []}, 60 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 61 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 62 | {Credo.Check.Consistency.SpaceInParentheses, []}, 63 | {Credo.Check.Consistency.TabsOrSpaces, []}, 64 | 65 | # 66 | ## Design Checks 67 | # 68 | # You can customize the priority of any check 69 | # Priority values are: `low, normal, high, higher` 70 | # 71 | {Credo.Check.Design.AliasUsage, 72 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 73 | # You can also customize the exit_status of each check. 74 | # If you don't want TODO comments to cause `mix credo` to fail, just 75 | # set this value to 0 (zero). 76 | # 77 | {Credo.Check.Design.TagTODO, [exit_status: 0]}, 78 | {Credo.Check.Design.TagFIXME, []}, 79 | 80 | # 81 | ## Readability Checks 82 | # 83 | {Credo.Check.Readability.AliasOrder, []}, 84 | {Credo.Check.Readability.FunctionNames, []}, 85 | {Credo.Check.Readability.LargeNumbers, []}, 86 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 87 | {Credo.Check.Readability.ModuleAttributeNames, []}, 88 | {Credo.Check.Readability.ModuleDoc, []}, 89 | {Credo.Check.Readability.ModuleNames, []}, 90 | {Credo.Check.Readability.ParenthesesInCondition, []}, 91 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 92 | {Credo.Check.Readability.PredicateFunctionNames, []}, 93 | {Credo.Check.Readability.PreferImplicitTry, []}, 94 | {Credo.Check.Readability.RedundantBlankLines, []}, 95 | {Credo.Check.Readability.Semicolons, []}, 96 | {Credo.Check.Readability.SpaceAfterCommas, []}, 97 | {Credo.Check.Readability.StringSigils, []}, 98 | {Credo.Check.Readability.TrailingBlankLine, []}, 99 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 100 | # TODO: enable by default in Credo 1.1 101 | {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, 102 | {Credo.Check.Readability.VariableNames, []}, 103 | 104 | # 105 | ## Refactoring Opportunities 106 | # 107 | {Credo.Check.Refactor.CondStatements, []}, 108 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 109 | {Credo.Check.Refactor.FunctionArity, []}, 110 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 111 | {Credo.Check.Refactor.MapInto, false}, 112 | {Credo.Check.Refactor.MatchInCondition, []}, 113 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 114 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 115 | {Credo.Check.Refactor.Nesting, []}, 116 | {Credo.Check.Refactor.UnlessWithElse, []}, 117 | {Credo.Check.Refactor.WithClauses, []}, 118 | 119 | # 120 | ## Warnings 121 | # 122 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 123 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 124 | {Credo.Check.Warning.IExPry, []}, 125 | {Credo.Check.Warning.IoInspect, []}, 126 | {Credo.Check.Warning.LazyLogging, false}, 127 | {Credo.Check.Warning.OperationOnSameValues, []}, 128 | {Credo.Check.Warning.OperationWithConstantResult, []}, 129 | {Credo.Check.Warning.RaiseInsideRescue, []}, 130 | {Credo.Check.Warning.UnusedEnumOperation, []}, 131 | {Credo.Check.Warning.UnusedFileOperation, []}, 132 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 133 | {Credo.Check.Warning.UnusedListOperation, []}, 134 | {Credo.Check.Warning.UnusedPathOperation, []}, 135 | {Credo.Check.Warning.UnusedRegexOperation, []}, 136 | {Credo.Check.Warning.UnusedStringOperation, []}, 137 | {Credo.Check.Warning.UnusedTupleOperation, []}, 138 | 139 | # 140 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 141 | # 142 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 143 | {Credo.Check.Consistency.UnusedVariableNames, false}, 144 | {Credo.Check.Design.DuplicatedCode, false}, 145 | {Credo.Check.Readability.MultiAlias, false}, 146 | {Credo.Check.Readability.Specs, false}, 147 | {Credo.Check.Readability.SinglePipe, false}, 148 | {Credo.Check.Refactor.ABCSize, false}, 149 | {Credo.Check.Refactor.AppendSingleItem, false}, 150 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 151 | {Credo.Check.Refactor.ModuleDependencies, false}, 152 | {Credo.Check.Refactor.PipeChainStart, false}, 153 | {Credo.Check.Refactor.VariableRebinding, false}, 154 | {Credo.Check.Warning.MapGetUnsafePass, false}, 155 | {Credo.Check.Warning.UnsafeToAtom, false} 156 | 157 | # 158 | # Custom checks can be created using `mix credo.gen.check`. 159 | # 160 | ] 161 | } 162 | ] 163 | } 164 | -------------------------------------------------------------------------------- /test/arangox/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Arangox.ClientTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Arangox.{ 5 | Client, 6 | Connection, 7 | Endpoint, 8 | GunClient, 9 | MintClient, 10 | Request, 11 | Response, 12 | VelocyClient 13 | } 14 | 15 | @auth Endpoint.new(TestHelper.auth()) 16 | @ssl Endpoint.new(TestHelper.ssl()) 17 | 18 | def default_opts do 19 | [ 20 | auth: {:basic, "root", ""}, 21 | ] 22 | end 23 | 24 | describe "internal api:" do 25 | test "connect/3" do 26 | assert {:ok, _} = Client.connect(TestClient, "endpoint", []) 27 | end 28 | 29 | test "alive?/1" do 30 | state = struct(Connection, client: TestClient) 31 | 32 | assert true = Client.alive?(state) 33 | end 34 | 35 | test "request/2" do 36 | state = struct(Connection, client: TestClient) 37 | 38 | assert {:ok, %Response{}, _state} = Client.request(struct(Request, []), state) 39 | end 40 | 41 | test "close/1" do 42 | state = struct(Connection, client: TestClient) 43 | 44 | assert :ok = Client.close(state) 45 | end 46 | end 47 | 48 | describe "velocy client:" do 49 | test "implementation" do 50 | assert {:ok, socket} = VelocyClient.connect(@auth, default_opts()) 51 | state = struct(Connection, socket: socket, auth: {:basic, "root", ""}) 52 | assert VelocyClient.alive?(state) 53 | 54 | assert :ok = VelocyClient.maybe_authenticate(state) 55 | 56 | assert {:ok, %Response{status: 200}, ^state} = 57 | VelocyClient.request(%Request{method: :get, path: "/_api/database/current"}, state) 58 | 59 | assert :ok = VelocyClient.close(state) 60 | refute VelocyClient.alive?(state) 61 | end 62 | 63 | @tag :unix 64 | test "connecting to a unix socket" do 65 | if File.exists?("_build/#{Mix.env()}/velocy.sock") do 66 | File.rm("_build/#{Mix.env()}/velocy.sock") 67 | end 68 | 69 | _port = Port.open({:spawn, "nc -lU _build/#{Mix.env()}/velocy.sock"}, [:binary]) 70 | endpoint = Endpoint.new("unix://#{Path.expand("_build")}/#{Mix.env()}/velocy.sock") 71 | 72 | :timer.sleep(1000) 73 | 74 | assert {:ok, _conn} = VelocyClient.connect(endpoint, []) 75 | after 76 | File.rm("_build/#{Mix.env()}/velocy.sock") 77 | end 78 | 79 | test "building and receiving multiple chunks (large requests and responses)" do 80 | Application.put_env(:arangox, :vst_maxsize, 30) 81 | 82 | opts = default_opts() 83 | {:ok, socket} = VelocyClient.connect(@auth, opts) 84 | state = struct(Connection, socket: socket, auth: {:basic, "root", ""}) 85 | :ok = VelocyClient.maybe_authenticate(state) 86 | body = for _ <- 1..100, into: "", do: "a" 87 | 88 | assert {:ok, %Response{status: 200}, ^state} = 89 | VelocyClient.request( 90 | %Request{method: :post, path: "/_admin/echo", body: body}, 91 | state 92 | ) 93 | 94 | Application.put_env(:arangox, :vst_maxsize, 90) 95 | 96 | assert {:ok, %Response{status: 200}, ^state} = 97 | VelocyClient.request( 98 | %Request{method: :post, path: "/_admin/echo", body: body}, 99 | state 100 | ) 101 | after 102 | Application.delete_env(:arangox, :vst_maxsize) 103 | end 104 | 105 | test "ssl and ssl_opts" do 106 | assert {:ok, {:ssl, _port}} = VelocyClient.connect(@ssl, ssl_opts: [verify: :verify_none]) 107 | 108 | 109 | assert {:error, _} = VelocyClient.connect(@ssl, ssl_opts: [verify: :verify_peer]) 110 | end 111 | 112 | test "tcp_opts option" do 113 | catch_exit(VelocyClient.connect(@auth, tcp_opts: [verify: :verify_peer])) 114 | end 115 | 116 | # test "connect_timeout option" do 117 | # assert {:error, :timeout} = VelocyClient.connect(@auth, connect_timeout: 0) 118 | # end 119 | 120 | test "arangox's transport opts can't be overridden" do 121 | opts = default_opts() 122 | opts = Keyword.merge(opts, [packet: :raw, mode: :binary, active: false]) 123 | assert {:ok, socket} = 124 | VelocyClient.connect(@auth, opts) 125 | 126 | state = struct(Connection, socket: socket) 127 | assert VelocyClient.alive?(state) 128 | 129 | assert {:ok, %Response{}, ^state} = 130 | VelocyClient.request(%Request{method: :options, path: "/"}, state) 131 | end 132 | end 133 | 134 | describe "gun client:" do 135 | test "implementation" do 136 | assert {:ok, pid} = GunClient.connect(@auth, []) 137 | state = struct(Connection, socket: pid) 138 | assert GunClient.alive?(state) 139 | 140 | assert {:ok, %Response{}, ^state} = 141 | GunClient.request(%Request{method: :options, path: "/"}, state) 142 | 143 | assert :ok = GunClient.close(state) 144 | refute GunClient.alive?(state) 145 | end 146 | 147 | @tag :unix 148 | test "connecting to a unix socket" do 149 | if File.exists?("_build/#{Mix.env()}/gun.sock") do 150 | File.rm("_build/#{Mix.env()}/gun.sock") 151 | end 152 | 153 | _port = Port.open({:spawn, "nc -lU _build/#{Mix.env()}/gun.sock"}, [:binary]) 154 | endpoint = Endpoint.new("unix://#{Path.expand("_build")}/#{Mix.env()}/gun.sock") 155 | 156 | :timer.sleep(1000) 157 | 158 | assert {:ok, _conn} = GunClient.connect(endpoint, []) 159 | after 160 | File.rm("_build/#{Mix.env()}/gun.sock") 161 | end 162 | 163 | test "ssl and ssl_opts" do 164 | assert {:ok, _pid} = GunClient.connect(@ssl, ssl_opts: [verify: :verify_none]) 165 | 166 | assert {:error, _} = GunClient.connect(@ssl, ssl_opts: [verify: :verify_peer]) 167 | end 168 | 169 | test "tcp_opts option" do 170 | assert {:error, _} = GunClient.connect(@auth, tcp_opts: [verify: :verify_peer]) 171 | end 172 | 173 | test "connect_timeout option" do 174 | assert {:error, :timeout} = GunClient.connect(@auth, connect_timeout: 0) 175 | end 176 | 177 | test "client_opts option" do 178 | assert {:error, _} = 179 | GunClient.connect(@ssl, client_opts: %{tls_opts: [verify: :verify_peer]}) 180 | end 181 | 182 | test "client_opts takes precedence" do 183 | assert {:error, _} = 184 | GunClient.connect(@ssl, 185 | tls_opts: [verify: :verify_none], 186 | client_opts: %{tls_opts: [verify: :verify_peer]} 187 | ) 188 | end 189 | end 190 | 191 | describe "mint client:" do 192 | test "implementation" do 193 | assert {:ok, conn} = MintClient.connect(@auth, []) 194 | state = struct(Connection, socket: conn) 195 | assert MintClient.alive?(state) 196 | 197 | assert {:ok, %Response{}, new_state} = 198 | MintClient.request(%Request{method: :options, path: "/"}, state) 199 | 200 | assert :ok = MintClient.close(new_state) 201 | end 202 | 203 | test "ssl and ssl_opts" do 204 | assert {:ok, _conn} = MintClient.connect(@ssl, []) 205 | 206 | assert_raise RuntimeError, ~r/CA trust store/, fn -> 207 | MintClient.connect(@ssl, ssl_opts: [verify: :verify_peer]) 208 | end 209 | end 210 | 211 | test "tcp_opts option" do 212 | catch_exit(MintClient.connect(@auth, tcp_opts: [verify: :verify_peer])) 213 | end 214 | 215 | # Only fails in travis-ci :( 216 | # test "connect_timeout option" do 217 | # assert {:error, %TransportError{reason: :timeout}} = 218 | # MintClient.connect(@auth, connect_timeout: 0) 219 | # end 220 | 221 | test "client_opts option" do 222 | assert_raise RuntimeError, ~r/CA trust store/, fn -> 223 | MintClient.connect(@ssl, client_opts: [transport_opts: [verify: :verify_peer]]) 224 | end 225 | end 226 | 227 | test "client_opts takes precedence" do 228 | assert_raise RuntimeError, ~r/CA trust store/, fn -> 229 | MintClient.connect( 230 | @ssl, 231 | transport_opts: [verify: :verify_none], 232 | client_opts: [transport_opts: [verify: :verify_peer]] 233 | ) 234 | end 235 | end 236 | 237 | test "mode is always :passive" do 238 | assert {:ok, %_{mode: :passive}} = 239 | MintClient.connect(@auth, client_opts: [mode: :active]) 240 | end 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arangox 2 | 3 | [![Version](https://img.shields.io/hexpm/v/arangox.svg)](https://hex.pm/packages/arangox) 4 | [![CI](https://github.com/ArangoDB-Community/arangox/actions/workflows/elixir.yml/badge.svg?branch=main&event=push)](https://github.com/ArangoDB-Community/arangox/actions/workflows/elixir.yml) 5 | 6 | An implementation of [`DBConnection`](https://hex.pm/packages/db_connection) for 7 | [ArangoDB](https://www.arangodb.com). 8 | 9 | Supports [VelocyStream](https://www.arangodb.com/2017/08/velocystream-async-binary-protocol/), 10 | [active failover](https://www.arangodb.com/docs/stable/architecture-deployment-modes-active-failover-architecture.html), 11 | transactions and streamed cursors. 12 | 13 | Tested on: 14 | 15 | - **ArangoDB** 3.11 16 | - **Elixir** 1.16 17 | - **OTP** 26 18 | 19 | [HexDocs](https://hexdocs.pm/arangox/readme.html) 20 | 21 | ## Examples 22 | 23 | ```elixir 24 | iex> {:ok, conn} = Arangox.start_link(pool_size: 10) 25 | iex> {:ok, %Arangox.Response{status: 200, body: %{"code" => 200, "error" => false, "mode" => "default"}}} = Arangox.get(conn, "/_admin/server/availability") 26 | iex> {:error, %Arangox.Error{status: 404}} = Arangox.get(conn, "/invalid") 27 | iex> %Arangox.Response{status: 200, body: %{"code" => 200, "error" => false, "mode" => "default"}} = Arangox.get!(conn, "/_admin/server/availability") 28 | iex> {:ok, 29 | iex> %Arangox.Request{ 30 | iex> body: "", 31 | iex> headers: %{}, 32 | iex> method: :get, 33 | iex> path: "/_admin/server/availability" 34 | iex> }, 35 | iex> %Arangox.Response{ 36 | iex> status: 200, 37 | iex> body: %{"code" => 200, "error" => false, "mode" => "default"} 38 | iex> } 39 | iex> } = Arangox.request(conn, :get, "/_admin/server/availability") 40 | iex> Arangox.transaction(conn, fn c -> 41 | iex> stream = 42 | iex> Arangox.cursor( 43 | iex> c, 44 | iex> "FOR i IN [1, 2, 3] FILTER i == 1 || i == @num RETURN i", 45 | iex> %{num: 2}, 46 | iex> properties: [batchSize: 1] 47 | iex> ) 48 | iex> 49 | iex> Enum.reduce(stream, [], fn resp, acc -> 50 | iex> acc ++ resp.body["result"] 51 | iex> end) 52 | iex> end) 53 | {:ok, [1, 2]} 54 | ``` 55 | 56 | ## Clients 57 | 58 | ### Velocy 59 | 60 | By default, Arangox communicates with _ArangoDB_ via _VelocyStream_, which requires the `:velocy` library: 61 | 62 | ```elixir 63 | def deps do 64 | [ 65 | ... 66 | {:arangox, "~> 0.4.0"}, 67 | {:velocy, "~> 0.1"} 68 | ] 69 | end 70 | ``` 71 | 72 | The default vst chunk size is `30_720`. To change it, you can include the following in your `config/config.exs`: 73 | 74 | ```elixir 75 | config :arangox, :vst_maxsize, 12_345 76 | ``` 77 | 78 | ### HTTP 79 | 80 | Arangox has two HTTP clients, `Arangox.GunClient` and `Arangox.MintClient`, they require a json library: 81 | 82 | ```elixir 83 | def deps do 84 | [ 85 | ... 86 | {:arangox, "~> 0.4.0"}, 87 | {:jason, "~> 1.1"}, 88 | {:gun, "~> 1.3.0"} # or {:mint, "~> 0.4.0"} 89 | ] 90 | end 91 | ``` 92 | 93 | ```elixir 94 | Arangox.start_link(client: Arangox.GunClient) # or Arangox.MintClient 95 | ``` 96 | 97 | ```elixir 98 | iex> {:ok, conn} = Arangox.start_link(client: Arangox.GunClient) 99 | iex> {:ok, %Arangox.Response{status: 200, body: nil}} = Arangox.options(conn, "/") 100 | ``` 101 | 102 | **NOTE:** `:mint` doesn't support unix sockets. 103 | 104 | **NOTE:** Since `:gun` is an Erlang library, you _might_ need to add it as an extra application in `mix.exs`: 105 | 106 | ```elixir 107 | def application() do 108 | [ 109 | extra_applications: [:logger, :gun] 110 | ] 111 | end 112 | ``` 113 | 114 | To use something else, you'd have to implement the `Arangox.Client` behaviour in a 115 | module somewhere and set that instead. 116 | 117 | The default json library is `Jason`. To use a different library, set the `:json_library` config to the module of your choice, i.e: 118 | 119 | ```elixir 120 | config :arangox, :json_library, Poison 121 | ``` 122 | 123 | ### Benchmarks 124 | 125 | **pool size** 10 126 | **parallel processes** 1000 127 | **system** virtual machine, 1 cpu (not shared), 2GB RAM 128 | 129 | | Name | Latency | 130 | | ------------ | --------- | 131 | | Velocy: GET | 179.74 ms | 132 | | Velocy: POST | 201.23 ms | 133 | | Mint: GET | 207.00 ms | 134 | | Mint: POST | 216.53 ms | 135 | | Gun: GET | 222.61 ms | 136 | | Gun: POST | 243.65 ms | 137 | 138 | Results generated with [`Benchee`](https://hex.pm/packages/benchee). 139 | 140 | ## Start Options 141 | 142 | Arangox assumes defaults for the `:endpoints`, `:username` and `:password` options, 143 | and [`db_connection`](https://hex.pm/packages/db_connection) assumes a default 144 | `:pool_size` of `1`, so the following: 145 | 146 | ```elixir 147 | Arangox.start_link() 148 | ``` 149 | 150 | Is equivalent to: 151 | 152 | ```elixir 153 | options = [ 154 | endpoints: "http://localhost:8529", 155 | pool_size: 1 156 | ] 157 | Arangox.start_link(options) 158 | ``` 159 | 160 | ## Endpoints 161 | 162 | Unencrypted endpoints can be specified with either `http://` or 163 | `tcp://`, whereas encrypted endpoints can be specified with `https://`, 164 | `ssl://` or `tls://`: 165 | 166 | ```elixir 167 | "tcp://localhost:8529" == "http://localhost:8529" 168 | "https://localhost:8529" == "ssl://localhost:8529" == "tls://localhost:8529" 169 | 170 | "tcp+unix:///tmp/arangodb.sock" == "http+unix:///tmp/arangodb.sock" 171 | "https+unix:///tmp/arangodb.sock" == "ssl+unix:///tmp/arangodb.sock" == "tls+unix:///tmp/arangodb.sock" 172 | 173 | "tcp://unix:/tmp/arangodb.sock" == "http://unix:/tmp/arangodb.sock" 174 | "https://unix:/tmp/arangodb.sock" == "ssl://unix:/tmp/arangodb.sock" == "tls://unix:/tmp/arangodb.sock" 175 | ``` 176 | 177 | The `:endpoints` option accepts either a binary, or a list of binaries. In the case of a list, 178 | Arangox will try to establish a connection with the first endpoint it can. 179 | 180 | If a connection is established, the availability of the server will be checked (via the _ArangoDB_ api), and 181 | if an endpoint is in maintenance mode or is a _Follower_ in an _Active Failover_ setup, the connection 182 | will be dropped, or in the case of a list, the endpoint skipped. 183 | 184 | With the `:read_only?` option set to `true`, arangox will try to find a server in 185 | _readonly_ mode instead and add the _x-arango-allow-dirty-read_ header to every request: 186 | 187 | ```elixir 188 | iex> endpoints = ["http://localhost:8003", "http://localhost:8004", "http://localhost:8005"] 189 | iex> {:ok, conn} = Arangox.start_link(endpoints: endpoints, read_only?: true) 190 | iex> %Arangox.Response{body: body} = Arangox.get!(conn, "/_admin/server/mode") 191 | iex> body["mode"] 192 | "readonly" 193 | iex> {:error, %Arangox.Error{status: 403}} = Arangox.post(conn, "/_api/database", %{name: "newDatabase"}) 194 | ``` 195 | 196 | ## Authentication 197 | 198 | ### Velocy 199 | 200 | ArangoDB's VelocyStream endpoints _do not_ read authorization headers, authentication configuration _must_ be 201 | provided as options to `Arangox.start_link/1`. 202 | 203 | As a consequence, if you're using bearer auth, there are a couple of caveats to bear in mind: 204 | 205 | * New JWT tokens can only be requested in a seperate connection (i.e. during startup before the primary pool 206 | is initialized) 207 | * Refreshed tokens can only be authorized by restarting a connection pool 208 | 209 | ### HTTP 210 | 211 | When using an HTTP client, Arangox will generate a _Basic_ or _Bearer_ authorization header if the `:auth` option is set to `{:basic, username, password}` or to `{:bearer, token}` respectively, and append it to every request. If the `:auth` option is not explicitly set, no authorization header will be appended. 212 | 213 | ```elixir 214 | iex> {:ok, conn} = Arangox.start_link(client: Arangox.GunClient, endpoints: "http://localhost:8001") 215 | iex> {:error, %Arangox.Error{status: 401}} = Arangox.get(conn, "/_admin/server/mode") 216 | ``` 217 | 218 | The header value is obfuscated in transfomed requests returned by arangox, for obvious reasons: 219 | 220 | ```elixir 221 | iex> {:ok, conn} = Arangox.start_link(client: Arangox.GunClient, auth: {:basic, "root", ""}) 222 | iex> {:ok, request, _response} = Arangox.request(conn, :options, "/") 223 | iex> request.headers 224 | %{"authorization" => "..."} 225 | ``` 226 | 227 | ## Databases 228 | 229 | ### Velocy 230 | 231 | If the `:database` option is set, it can be overridden by prepending the path of a 232 | request with `/_db/:value`. If nothing is set, the request will be sent as-is and 233 | _ArangoDB_ will assume the `_system` database. 234 | 235 | ### HTTP 236 | 237 | When using an HTTP client, arangox will prepend `/_db/:value` to the path of every request 238 | only if one isn't already prepended. If a `:database` option is not set, nothing is prepended. 239 | 240 | ```elixir 241 | iex> {:ok, conn} = Arangox.start_link(client: Arangox.GunClient) 242 | iex> {:ok, request, _response} = Arangox.request(conn, :get, "/_admin/time") 243 | iex> request.path 244 | "/_admin/time" 245 | iex> {:ok, conn} = Arangox.start_link(database: "_system", client: Arangox.GunClient) 246 | iex> {:ok, request, _response} = Arangox.request(conn, :get, "/_admin/time") 247 | iex> request.path 248 | "/_db/_system/_admin/time" 249 | iex> {:ok, request, _response} = Arangox.request(conn, :get, "/_db/_system/_admin/time") 250 | iex> request.path 251 | "/_db/_system/_admin/time" 252 | ``` 253 | 254 | ## Headers 255 | 256 | Headers can be given as maps: 257 | 258 | ```elixir 259 | %{"header" => "value"} 260 | ``` 261 | 262 | Or lists of two binary element tuples: 263 | 264 | ```elixir 265 | [{"header", "value"}] 266 | ``` 267 | 268 | Headers given to the start option are merged with every request, but will not override 269 | any of the headers set by Arangox: 270 | 271 | ```elixir 272 | iex> {:ok, conn} = Arangox.start_link(headers: %{"header" => "value"}) 273 | iex> {:ok, request, _response} = Arangox.request(conn, :get, "/_api/version") 274 | iex> request.headers 275 | %{"header" => "value"} 276 | ``` 277 | 278 | Headers passed to requests will override any of the headers given to the start option 279 | or set by Arangox: 280 | 281 | ```elixir 282 | iex> {:ok, conn} = Arangox.start_link(headers: %{"header" => "value"}) 283 | iex> {:ok, request, _response} = Arangox.request(conn, :get, "/_api/version", "", %{"header" => "new_value"}) 284 | iex> request.headers 285 | %{"header" => "new_value"} 286 | ``` 287 | 288 | ## Transport 289 | 290 | The `:connect_timeout` start option defaults to `5_000`. 291 | 292 | Transport options can be specified via `:tcp_opts` and `:ssl_opts`, for unencrypted and 293 | encrypted connections respectively. When using `:gun` or `:mint`, these options are passed 294 | directly to the `:transport_opts` connect option. 295 | 296 | See [`:gen_tcp.connect_option()`](http://erlang.org/doc/man/gen_tcp.html#type-connect_option) 297 | for more information on `:tcp_opts`, 298 | or [`:ssl.tls_client_option()`](http://erlang.org/doc/man/ssl.html#type-tls_client_option) for `:ssl_opts`. 299 | 300 | The `:client_opts` option can be used to pass client-specific options to `:gun` or `:mint`. 301 | These options are merged with and may override values set by arangox. Some options cannot be 302 | overridden (i.e. `:mint`'s `:mode` option). If `:transport_opts` is set here it will override 303 | everything given to `:tcp_opts` or `:ssl_opts`, regardless of whether or not a connection is 304 | encrypted. 305 | 306 | See the `gun:opts()` type in the [gun docs](https://ninenines.eu/docs/en/gun/1.3/manual/gun/) 307 | or [`connect/4`](https://hexdocs.pm/mint/Mint.HTTP.html#connect/4) in the mint docs for more 308 | information. 309 | 310 | ## Request Options 311 | 312 | Request options are handled by and passed directly to `:db_connection`. 313 | See [execute/4](https://hexdocs.pm/db_connection/DBConnection.html#execute/4) in the `:db_connection` docs for supported 314 | options. 315 | 316 | Request timeouts default to `15_000`. 317 | 318 | ```elixir 319 | iex> {:ok, conn} = Arangox.start_link() 320 | iex> %Arangox.Response{status: 200, body: %{"code" => 200, "error" => false, "mode" => "default"}} = Arangox.get!(conn, "/_admin/server/availability", [], timeout: 15_000) 321 | ``` 322 | 323 | ## Contributing 324 | 325 | ``` 326 | mix format 327 | mix do format, credo --strict 328 | docker-compose up -d 329 | mix test 330 | ``` 331 | 332 | ## Roadmap 333 | 334 | - `:get_endpoints` and `:port_mappings` options 335 | - An Ecto adapter 336 | - More descriptive logs 337 | -------------------------------------------------------------------------------- /test/arangox_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ArangoxTest do 2 | use ExUnit.Case, async: true 3 | import ExUnit.CaptureLog 4 | import TestHelper, only: [opts: 1, opts: 0] 5 | 6 | alias Arangox.{ 7 | Error, 8 | GunClient, 9 | Request, 10 | Response 11 | } 12 | 13 | @unreachable TestHelper.unreachable() 14 | @auth TestHelper.auth() 15 | @default TestHelper.default() 16 | @ssl TestHelper.ssl() 17 | @failover_1 TestHelper.failover_1() 18 | @failover_2 TestHelper.failover_2() 19 | @failover_3 TestHelper.failover_3() 20 | 21 | describe "invalid endpoints option:" do 22 | test "not a list" do 23 | assert_raise ArgumentError, fn -> 24 | Arangox.start_link(opts(endpoints: {})) 25 | end 26 | end 27 | 28 | test "empty list" do 29 | assert_raise ArgumentError, fn -> 30 | Arangox.start_link(opts(endpoints: [])) 31 | end 32 | end 33 | 34 | test "non-binary element in list" do 35 | assert_raise ArgumentError, fn -> 36 | Arangox.start_link(opts(endpoints: ["binary", :not_a_binary])) 37 | end 38 | end 39 | end 40 | 41 | @tag capture_log: false 42 | test "disconnect_on_error_codes option" do 43 | {:ok, conn_empty} = 44 | Arangox.start_link(opts(endpoints: [@auth], disconnect_on_error_codes: [])) 45 | 46 | refute capture_log(fn -> 47 | Arangox.get(conn_empty, "/_admin/server/mode") 48 | :timer.sleep(500) 49 | end) =~ "disconnected" 50 | 51 | {:ok, conn_401} = 52 | Arangox.start_link(opts(endpoints: [@auth], disconnect_on_error_codes: [401])) 53 | 54 | assert capture_log(fn -> 55 | Arangox.get(conn_401, "/_admin/server/mode") 56 | :timer.sleep(500) 57 | end) =~ "disconnected" 58 | end 59 | 60 | test "connecting with default options" do 61 | {:ok, conn} = Arangox.start_link(opts()) 62 | Arangox.get!(conn, "/_admin/time") 63 | end 64 | 65 | test "connecting with bogus auth" do 66 | assert_raise ArgumentError, fn -> 67 | Arangox.start_link(opts(auth: "bogus")) 68 | end 69 | end 70 | 71 | test "connecting with auth disabled" do 72 | {:ok, conn1} = Arangox.start_link(opts(endpoints: [@auth])) 73 | assert {:error, %Error{status: 401}} = Arangox.get(conn1, "/_admin/server/mode") 74 | 75 | {:ok, conn2} = Arangox.start_link(opts(endpoints: [@default])) 76 | assert %Response{status: 200} = Arangox.get!(conn2, "/_admin/server/mode") 77 | end 78 | 79 | test "connecting with ssl" do 80 | {:ok, conn} = 81 | Arangox.start_link(opts(auth: {:basic, "root", ""}, endpoints: [@ssl], ssl_opts: [verify: :verify_none])) 82 | 83 | Arangox.get!(conn, "/_admin/time") 84 | end 85 | 86 | @tag :unix 87 | test "connecting to a unix socket" do 88 | if File.exists?("_build/#{Mix.env()}/unix.sock") do 89 | File.rm("_build/#{Mix.env()}/unix.sock") 90 | end 91 | 92 | port = Port.open({:spawn, "nc -lU _build/#{Mix.env()}/unix.sock"}, [:binary]) 93 | endpoint = "unix://#{Path.expand("_build")}/#{Mix.env()}/unix.sock" 94 | 95 | :timer.sleep(1000) 96 | 97 | assert {:ok, _conn} = 98 | Arangox.start_link(opts(endpoints: endpoint, client: Arangox.VelocyClient)) 99 | 100 | assert_receive {^port, {:data, _data}} 101 | after 102 | File.rm("_build/#{Mix.env()}/unix.sock") 103 | end 104 | 105 | test "finding an available endpoint" do 106 | {:ok, conn} = Arangox.start_link(opts(endpoints: [@unreachable, @unreachable, @default])) 107 | 108 | Arangox.get!(conn, "/_admin/time") 109 | end 110 | 111 | test "finding the leader in an active-failover setup" do 112 | {:ok, conn1} = Arangox.start_link(opts(endpoints: [@failover_1, @failover_2, @failover_3])) 113 | {:ok, conn2} = Arangox.start_link(opts(endpoints: [@failover_3, @failover_1, @failover_2])) 114 | {:ok, conn3} = Arangox.start_link(opts(endpoints: [@failover_2, @failover_3, @failover_1])) 115 | assert %Response{status: 200} = Arangox.get!(conn1, "/_admin/server/availability") 116 | assert %Response{status: 200} = Arangox.get!(conn2, "/_admin/server/availability") 117 | assert %Response{status: 200} = Arangox.get!(conn3, "/_admin/server/availability") 118 | end 119 | 120 | test "finding a follower in an active-failover setup" do 121 | {:ok, conn1} = 122 | Arangox.start_link( 123 | opts(endpoints: [@failover_1, @failover_2, @failover_3], read_only?: true) 124 | ) 125 | 126 | {:ok, conn2} = 127 | Arangox.start_link( 128 | opts(endpoints: [@failover_3, @failover_1, @failover_2], read_only?: true) 129 | ) 130 | 131 | {:ok, conn3} = 132 | Arangox.start_link( 133 | opts(endpoints: [@failover_2, @failover_3, @failover_1], read_only?: true) 134 | ) 135 | 136 | assert {:error, %Error{status: 403}} = Arangox.delete(conn1, "/_api/database/mydatabase") 137 | assert {:error, %Error{status: 403}} = Arangox.delete(conn2, "/_api/database/mydatabase") 138 | assert {:error, %Error{status: 403}} = Arangox.delete(conn3, "/_api/database/mydatabase") 139 | end 140 | 141 | describe "database option:" do 142 | test "invalid value" do 143 | assert_raise ArgumentError, fn -> 144 | Arangox.start_link(opts(database: :not_a_binary)) 145 | end 146 | end 147 | 148 | test "prepends request paths when using velocy client unless already prepended" do 149 | {:ok, conn} = Arangox.start_link(opts(database: "does_not_exist")) 150 | 151 | assert {:error, %Error{status: 404}} = Arangox.get(conn, "/_api/database/current") 152 | 153 | assert %Response{body: %{"result" => %{"name" => "_system"}}} = 154 | Arangox.get!(conn, "/_db/_system/_api/database/current") 155 | end 156 | 157 | test "prepends request paths when using an http client unless already prepended" do 158 | {:ok, conn} = Arangox.start_link(opts(database: "does_not_exist", client: GunClient)) 159 | 160 | assert {:error, %Error{status: 404}} = Arangox.get(conn, "/_api/database/current") 161 | 162 | assert %Response{body: %{"result" => %{"name" => "_system"}}} = 163 | Arangox.get!(conn, "/_db/_system/_api/database/current") 164 | end 165 | end 166 | 167 | test "auth resolution with velocy client" do 168 | {:ok, conn1} = 169 | Arangox.start_link( 170 | opts(endpoints: [@auth], auth: {:basic, "root", ""}, client: Arangox.VelocyClient) 171 | ) 172 | 173 | assert %Response{status: 200} = Arangox.get!(conn1, "/_admin/server/mode") 174 | 175 | {:ok, conn2} = 176 | Arangox.start_link( 177 | opts( 178 | endpoints: [@auth], 179 | auth: {:basic, "root", "invalid"}, 180 | client: Arangox.VelocyClient 181 | ) 182 | ) 183 | 184 | assert {:error, %DBConnection.ConnectionError{}} = Arangox.get(conn2, "/_admin/server/mode") 185 | 186 | {:ok, conn3} = 187 | Arangox.start_link( 188 | opts(endpoints: [@auth], auth: {:basic, "invalid", ""}, client: Arangox.VelocyClient) 189 | ) 190 | 191 | assert {:error, %DBConnection.ConnectionError{}} = Arangox.get(conn3, "/_admin/server/mode") 192 | end 193 | 194 | test "auth resolution with an http client" do 195 | {:ok, conn1} = 196 | Arangox.start_link( 197 | opts(endpoints: [@auth], auth: {:basic, "root", ""}, client: GunClient) 198 | ) 199 | 200 | assert %Response{status: 200} = Arangox.get!(conn1, "/_admin/server/mode") 201 | 202 | {:ok, conn2} = 203 | Arangox.start_link( 204 | opts(endpoints: [@auth], username: "root", password: "invalid", client: GunClient) 205 | ) 206 | 207 | assert {:error, %Error{status: 401}} = Arangox.get(conn2, "/_admin/server/mode") 208 | 209 | {:ok, conn3} = 210 | Arangox.start_link( 211 | opts(endpoints: [@auth], username: "invalid", password: "", client: GunClient) 212 | ) 213 | 214 | assert {:error, %Error{status: 401}} = Arangox.get(conn3, "/_admin/server/mode") 215 | end 216 | 217 | test "auth resolution with an http client and invalid Bearer token" do 218 | {:ok, conn1} = 219 | Arangox.start_link(opts(endpoints: [@auth], auth: {:bearer, "invalid"}, client: GunClient)) 220 | 221 | assert {:error, %Error{status: 401}} = Arangox.get(conn1, "/_admin/server/mode") 222 | end 223 | 224 | test "auth resolution with an http client and valid Bearer token" do 225 | {:ok, conn1} = 226 | Arangox.start_link( 227 | opts(endpoints: [@auth], auth: {:basic, "root", ""}, client: GunClient) 228 | ) 229 | 230 | assert %Response{status: 200} = Arangox.get!(conn1, "/_admin/server/mode") 231 | 232 | assert %Response{status: 200, body: body1} = 233 | Arangox.post!(conn1, "/_open/auth", %{"username" => "root", "password" => ""}) 234 | 235 | assert Map.has_key?(body1, "jwt") 236 | 237 | {:ok, conn2} = 238 | Arangox.start_link(opts(auth: {:bearer, body1["jwt"]}, client: GunClient)) 239 | 240 | assert %Response{status: 200} = Arangox.get!(conn2, "/_admin/server/mode") 241 | end 242 | 243 | test "headers option" do 244 | header = {"header", "value"} 245 | {:ok, conn} = Arangox.start_link(opts(headers: Map.new([header]))) 246 | {:ok, %Request{headers: headers}, %Response{}} = Arangox.request(conn, :get, "/_admin/time") 247 | 248 | assert header in headers 249 | end 250 | 251 | test "request headers override values in headers option" do 252 | header = {"header", "value"} 253 | {:ok, conn} = Arangox.start_link(opts(headers: Map.new([header]))) 254 | 255 | {:ok, %Request{headers: headers}, %Response{}} = 256 | Arangox.request(conn, :get, "/_admin/time", "", %{"header" => "new_value"}) 257 | 258 | assert header not in headers 259 | end 260 | 261 | describe "client option:" do 262 | test "when not an atom" do 263 | assert_raise ArgumentError, fn -> 264 | Arangox.start_link(opts(client: "client")) 265 | end 266 | end 267 | 268 | test "when not loaded" do 269 | assert_raise RuntimeError, fn -> 270 | Arangox.start_link(opts(client: :not_a_loaded_module)) 271 | end 272 | end 273 | 274 | test "when is loaded" do 275 | {:ok, conn} = Arangox.start_link(opts(client: Arangox.MintClient)) 276 | 277 | assert {:ok, %Response{}} = Arangox.get(conn, "/_admin/time") 278 | end 279 | end 280 | 281 | test "failover_callback option" do 282 | pid = self() 283 | fun = fn exception -> send(pid, {:fun, exception}) end 284 | tuple = {TestHelper, :failover_callback, [pid]} 285 | 286 | {:ok, _} = 287 | Arangox.start_link( 288 | opts( 289 | endpoints: [@unreachable, @unreachable, @auth], 290 | failover_callback: fun 291 | ) 292 | ) 293 | 294 | {:ok, _} = 295 | Arangox.start_link( 296 | opts( 297 | endpoints: [@unreachable, @unreachable, @auth], 298 | failover_callback: tuple 299 | ) 300 | ) 301 | 302 | assert_receive {:fun, %Error{}} 303 | assert_receive {:tuple, %Error{}} 304 | end 305 | 306 | test "json_library function and config" do 307 | assert Arangox.json_library() == Jason 308 | 309 | Application.put_env(:arangox, :json_library, Poison) 310 | assert Arangox.json_library() == Poison 311 | after 312 | Application.delete_env(:arangox, :json_library) 313 | end 314 | 315 | test "request functions" do 316 | {:ok, conn} = Arangox.start_link(opts()) 317 | 318 | assert {:error, _} = Arangox.request(conn, :invalid_method, "/") 319 | assert_raise Error, fn -> Arangox.request!(conn, :invalid_method, "/") end 320 | 321 | assert {:ok, %Request{method: :get}, %Response{}} = Arangox.request(conn, :get, "/") 322 | assert %Response{} = Arangox.get!(conn, "/") 323 | end 324 | 325 | test "transaction/3" do 326 | {:ok, conn1} = 327 | Arangox.start_link(opts(endpoints: [@auth], auth: {:basic, "root", ""})) 328 | 329 | assert {:ok, %Response{}} = 330 | Arangox.transaction( 331 | conn1, 332 | fn c -> Arangox.get!(c, "/_admin/time") end, 333 | timeout: 15_000 334 | ) 335 | 336 | {:ok, conn2} = Arangox.start_link(opts(endpoints: [@auth])) 337 | 338 | assert {:error, :rollback} = 339 | Arangox.transaction( 340 | conn2, 341 | fn c -> Arangox.get(c, "/_admin/server/status") end, 342 | timeout: 15_000 343 | ) 344 | end 345 | 346 | test "cursors and run/3" do 347 | {:ok, conn} = Arangox.start_link(opts()) 348 | 349 | assert [%Response{status: 201}] = 350 | Arangox.run(conn, fn c -> 351 | stream = Arangox.cursor(c, "return @this", [this: "this"], timeout: 15_000) 352 | Enum.to_list(stream) 353 | end) 354 | end 355 | 356 | test "ownership pool" do 357 | {:ok, conn} = Arangox.start_link(opts(pool: DBConnection.Ownership)) 358 | 359 | assert %Response{} = Arangox.get!(conn, "/_admin/time") 360 | assert :ok = DBConnection.Ownership.ownership_checkin(conn, []) 361 | end 362 | end 363 | -------------------------------------------------------------------------------- /lib/arangox/client/velocy.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(VelocyPack) do 2 | defmodule Arangox.VelocyClient do 3 | @moduledoc """ 4 | The default client. Implements the \ 5 | [VelocyStream](https://github.com/arangodb/velocystream) \ 6 | protocol. 7 | 8 | URI query parsing functions proudly stolen from Plataformatec and 9 | licensed under Apache 2.0. 10 | """ 11 | 12 | alias Arangox.{ 13 | Client, 14 | Connection, 15 | Endpoint, 16 | Error, 17 | Request, 18 | Response 19 | } 20 | 21 | @behaviour Client 22 | 23 | @vst_version 1.1 24 | @vst_version_trunc trunc(@vst_version) 25 | @chunk_header_size 24 26 | 27 | vst_maxsize = Application.compile_env(:arangox, :vst_maxsize, 30_720) 28 | 29 | unless vst_maxsize > @chunk_header_size, 30 | do: raise(":vst_maxsize must be greater than #{@chunk_header_size}") 31 | 32 | @vst_maxsize vst_maxsize 33 | @chunk_without_header_size @vst_maxsize - @chunk_header_size 34 | 35 | @doc """ 36 | Returns the configured maximum size (in bytes) for a _VelocyPack_ chunk. 37 | 38 | To change the chunk size, include the following in your `config/config.exs`: 39 | 40 | config :arangox, :vst_maxsize, 12_345 41 | 42 | Cannot be changed during runtime. Defaults to `30_720`. 43 | """ 44 | @spec vst_maxsize() :: pos_integer() 45 | def vst_maxsize, do: @vst_maxsize 46 | 47 | @spec maybe_authenticate(Connection.t()) :: :ok | {:error, Error.t()} 48 | def maybe_authenticate(%Connection{auth: {:basic, username, password}} = state), 49 | do: do_maybe_authenticate(state, [@vst_version_trunc, 1000, "plain", username, password]) 50 | def maybe_authenticate(%Connection{auth: {:bearer, token}} = state), 51 | do: do_maybe_authenticate(state, [@vst_version_trunc, 1000, "jwt", token]) 52 | def maybe_authenticate(%Connection{}), do: :ok 53 | 54 | defp do_maybe_authenticate(%Connection{socket: socket, endpoint: endpoint}, auth_msg) do 55 | with( 56 | {:ok, encoded_message} <- 57 | VelocyPack.encode(auth_msg), 58 | :ok <- 59 | send_stream(socket, build_stream(encoded_message)), 60 | {:ok, header} <- 61 | recv_header(socket), 62 | {:ok, stream} <- 63 | recv_stream(socket, header), 64 | {:ok, [[@vst_version_trunc, 2, 200, _headers] | _body]} <- 65 | decode_stream(stream) 66 | ) do 67 | :ok 68 | else 69 | {:ok, [[@vst_version_trunc, 2, status, _headers] | [body | _]]} -> 70 | {:error, 71 | %Error{status: status, message: body["errorMessage"], endpoint: endpoint}} 72 | 73 | {:error, reason} -> 74 | {:error, reason} 75 | end 76 | end 77 | 78 | @impl true 79 | def connect(%Endpoint{addr: addr, ssl?: ssl?}, opts) do 80 | mod = if ssl?, do: :ssl, else: :gen_tcp 81 | transport_opts = if ssl?, do: :ssl_opts, else: :tcp_opts 82 | transport_opts = Keyword.get(opts, transport_opts, []) 83 | connect_timeout = Keyword.get(opts, :connect_timeout, 5_000) 84 | 85 | options = Keyword.merge(transport_opts, packet: :raw, mode: :binary, active: false) 86 | 87 | with( 88 | {:ok, port} <- 89 | mod.connect(addr_for(addr), port_for(addr), options, connect_timeout), 90 | :ok <- 91 | mod.send(port, "VST/#{@vst_version}\r\n\r\n") 92 | ) do 93 | {:ok, {mod, port}} 94 | end 95 | end 96 | 97 | defp addr_for({:unix, path}), do: {:local, to_charlist(path)} 98 | defp addr_for({:tcp, host, _port}), do: to_charlist(host) 99 | 100 | defp port_for({:unix, _path}), do: 0 101 | defp port_for({:tcp, _host, port}), do: port 102 | 103 | @impl true 104 | def request( 105 | %Request{method: method, path: path, headers: headers, body: body}, 106 | %Connection{socket: socket, database: database} = state 107 | ) do 108 | %{path: path, query: query} = URI.parse(path) 109 | 110 | {database, path} = 111 | case path do 112 | "/_db/" <> rest -> 113 | [database, path] = :binary.split(rest, "/") 114 | 115 | {database, "/" <> path} 116 | 117 | _ -> 118 | {database || "", path} 119 | end 120 | 121 | request = [ 122 | @vst_version_trunc, 123 | 1, 124 | database, 125 | method_for(method), 126 | path, 127 | query_for(query), 128 | headers_for(headers) 129 | ] 130 | 131 | with( 132 | {:ok, request} <- 133 | VelocyPack.encode(request), 134 | {:ok, body} <- 135 | body_for(body), 136 | :ok <- 137 | send_stream(socket, build_stream(request <> body)), 138 | {:ok, header} <- 139 | recv_header(socket), 140 | {:ok, stream} <- 141 | recv_stream(socket, header), 142 | {:ok, [[@vst_version_trunc, 2, status, headers] | body]} <- 143 | decode_stream(stream) 144 | ) do 145 | {:ok, %Response{status: status, headers: headers, body: body_from(body)}, state} 146 | else 147 | {:error, :closed} -> 148 | {:error, :noproc, state} 149 | 150 | {:error, reason} -> 151 | {:error, reason, state} 152 | end 153 | end 154 | 155 | defp method_for(:delete), do: 0 156 | defp method_for(:get), do: 1 157 | defp method_for(:post), do: 2 158 | defp method_for(:put), do: 3 159 | defp method_for(:head), do: 4 160 | defp method_for(:patch), do: 5 161 | defp method_for(:options), do: 6 162 | defp method_for(_), do: -1 163 | 164 | # ------- Begin Query Parsing Functions (Plataformatec) -------- 165 | 166 | defp query_for(nil), do: %{} 167 | 168 | defp query_for(query) do 169 | parts = :binary.split(query, "&", [:global]) 170 | 171 | Enum.reduce(Enum.reverse(parts), %{}, &decode_www_pair(&1, &2)) 172 | end 173 | 174 | defp decode_www_pair("", acc), do: acc 175 | 176 | defp decode_www_pair(binary, acc) do 177 | current = 178 | case :binary.split(binary, "=") do 179 | [key, value] -> 180 | {decode_www_form(key), decode_www_form(value)} 181 | 182 | [key] -> 183 | {decode_www_form(key), nil} 184 | end 185 | 186 | decode_pair(current, acc) 187 | end 188 | 189 | defp decode_www_form(value), do: URI.decode_www_form(value) 190 | 191 | defp decode_pair({key, value}, acc) do 192 | if key != "" and :binary.last(key) == ?] do 193 | subkey = :binary.part(key, 0, byte_size(key) - 1) 194 | 195 | assign_split(:binary.split(subkey, "["), value, acc, :binary.compile_pattern("][")) 196 | else 197 | assign_map(acc, key, value) 198 | end 199 | end 200 | 201 | defp assign_split(["", rest], value, acc, pattern) do 202 | parts = :binary.split(rest, pattern) 203 | 204 | case acc do 205 | [_ | _] -> [assign_split(parts, value, :none, pattern) | acc] 206 | :none -> [assign_split(parts, value, :none, pattern)] 207 | _ -> acc 208 | end 209 | end 210 | 211 | defp assign_split([key, rest], value, acc, pattern) do 212 | parts = :binary.split(rest, pattern) 213 | 214 | case acc do 215 | %{^key => current} -> 216 | Map.put(acc, key, assign_split(parts, value, current, pattern)) 217 | 218 | %{} -> 219 | Map.put(acc, key, assign_split(parts, value, :none, pattern)) 220 | 221 | _ -> 222 | %{key => assign_split(parts, value, :none, pattern)} 223 | end 224 | end 225 | 226 | defp assign_split([""], nil, acc, _pattern) do 227 | case acc do 228 | [_ | _] -> acc 229 | _ -> [] 230 | end 231 | end 232 | 233 | defp assign_split([""], value, acc, _pattern) do 234 | case acc do 235 | [_ | _] -> [value | acc] 236 | :none -> [value] 237 | _ -> acc 238 | end 239 | end 240 | 241 | defp assign_split([key], value, acc, _pattern) do 242 | assign_map(acc, key, value) 243 | end 244 | 245 | defp assign_map(acc, key, value) do 246 | case acc do 247 | %{^key => _} -> acc 248 | %{} -> Map.put(acc, key, value) 249 | _ -> %{key => value} 250 | end 251 | end 252 | 253 | # ------- End Query Parsing Functions -------- 254 | 255 | defp headers_for(%{} = headers), do: headers 256 | defp headers_for(headers) when is_list(headers), do: :maps.from_list(headers) 257 | 258 | defp body_for(""), do: {:ok, ""} 259 | defp body_for(body), do: VelocyPack.encode(body) 260 | 261 | defp body_from([]), do: nil 262 | defp body_from([body]), do: body 263 | defp body_from(body), do: body 264 | 265 | defp build_stream(message) do 266 | case chunk_every(message, @chunk_without_header_size) do 267 | [first_chunk | rest_chunks] -> 268 | n_chunks = length([first_chunk | rest_chunks]) 269 | msg_length = byte_size(message) + n_chunks * @chunk_header_size 270 | 271 | rest_chunks = 272 | for n <- 1..length(rest_chunks), rest_chunks != [] do 273 | prepend_chunk(:lists.nth(n, rest_chunks), n, 0, 0, msg_length) 274 | end 275 | 276 | [prepend_chunk(first_chunk, n_chunks, 1, 0, msg_length) | rest_chunks] 277 | 278 | only_chunk -> 279 | prepend_chunk(only_chunk, 1, 1, 0, byte_size(message) + @chunk_header_size) 280 | end 281 | end 282 | 283 | defp chunk_every(bytes, size) when byte_size(bytes) <= size, do: bytes 284 | 285 | defp chunk_every(bytes, size) do 286 | <> = bytes 287 | 288 | [chunk | List.wrap(chunk_every(rest, size))] 289 | end 290 | 291 | defp prepend_chunk(chunk, chunk_n, is_first, msg_id, msg_length) do 292 | << 293 | @chunk_header_size + byte_size(chunk)::little-32, 294 | :binary.decode_unsigned(<>, :little)::32, 295 | msg_id::little-64, 296 | msg_length::little-64, 297 | chunk::binary 298 | >> 299 | end 300 | 301 | defp send_stream({mod, port}, chunk) when is_binary(chunk), do: mod.send(port, chunk) 302 | 303 | defp send_stream({mod, port}, chunks) when is_list(chunks) do 304 | for c <- chunks do 305 | case mod.send(port, c) do 306 | :ok -> 307 | :ok 308 | 309 | {_, error} -> 310 | throw(error) 311 | end 312 | end 313 | 314 | :ok 315 | catch 316 | error -> {:error, error} 317 | end 318 | 319 | defp recv_header({mod, port}) do 320 | case mod.recv(port, @chunk_header_size) do 321 | {:ok, 322 | << 323 | chunk_length::little-32, 324 | chunk_x::32, 325 | msg_id::little-64, 326 | msg_length::little-64 327 | >>} -> 328 | <> = <> 329 | 330 | {:ok, [chunk_length, chunk_n, is_first, msg_id, msg_length]} 331 | 332 | {:error, reason} -> 333 | {:error, reason} 334 | end 335 | end 336 | 337 | # TODO: this could be refactored to decode streams as they are received 338 | defp recv_stream(socket, [chunk_length, 1, 1, _msg_id, _msg_length]), 339 | do: recv_chunk(socket, chunk_length) 340 | 341 | defp recv_stream(socket, [chunk_length, n_chunks, 1, _msg_id, _msg_length]) do 342 | with( 343 | {:ok, buffer} <- 344 | recv_chunk(socket, chunk_length), 345 | {:ok, stream} <- 346 | recv_stream(socket, n_chunks, buffer) 347 | ) do 348 | {:ok, stream} 349 | end 350 | end 351 | 352 | defp recv_stream(socket, n_chunks, buffer) do 353 | Enum.reduce_while(1..(n_chunks - 1), buffer, fn n, buffer -> 354 | with( 355 | {:ok, [chunk_length, _, _, _, _]} <- 356 | recv_header(socket), 357 | {:ok, chunk} <- 358 | recv_chunk(socket, chunk_length) 359 | ) do 360 | if n == n_chunks - 1 do 361 | {:halt, {:ok, buffer <> chunk}} 362 | else 363 | {:cont, buffer <> chunk} 364 | end 365 | else 366 | {:error, reason} -> 367 | {:halt, {:error, reason}} 368 | end 369 | end) 370 | end 371 | 372 | defp recv_chunk({mod, port}, chunk_length), 373 | do: mod.recv(port, chunk_length - @chunk_header_size) 374 | 375 | defp decode_stream(stream, acc \\ []) 376 | 377 | defp decode_stream("", acc), do: {:ok, acc} 378 | 379 | defp decode_stream(stream, acc) do 380 | case VelocyPack.decode(stream) do 381 | {:ok, {term, rest}} -> 382 | decode_stream(rest, acc ++ [term]) 383 | 384 | {:ok, term} -> 385 | {:ok, acc ++ [term]} 386 | 387 | {:error, reason} -> 388 | {:error, reason} 389 | end 390 | end 391 | 392 | @impl true 393 | def alive?(%Connection{} = state) do 394 | case request(%Request{method: :options, path: "/"}, state) do 395 | {:ok, _response, _state} -> 396 | true 397 | 398 | {:error, _reason, _state} -> 399 | false 400 | end 401 | end 402 | 403 | @impl true 404 | def close(%Connection{socket: {mod, port}}), do: mod.close(port) 405 | end 406 | end 407 | -------------------------------------------------------------------------------- /lib/arangox.ex: -------------------------------------------------------------------------------- 1 | defmodule Arangox do 2 | @moduledoc File.read!("#{__DIR__}/../README.md") 3 | |> String.split("\n") 4 | |> Enum.drop(2) 5 | |> Enum.join("\n") 6 | 7 | alias __MODULE__.{ 8 | Auth, 9 | Error, 10 | GunClient, 11 | MintClient, 12 | Request, 13 | Response, 14 | VelocyClient 15 | } 16 | 17 | @type method :: 18 | :get 19 | | :head 20 | | :delete 21 | | :post 22 | | :put 23 | | :patch 24 | | :options 25 | 26 | @type conn :: DBConnection.conn() 27 | @type client :: module 28 | @type endpoint :: binary 29 | @type path :: binary 30 | @type body :: binary | map | list | nil 31 | @type headers :: map | [{binary, binary}] 32 | @type query :: binary 33 | @type bindvars :: keyword | map 34 | 35 | @type start_option :: 36 | {:client, module} 37 | | {:endpoints, list(endpoint)} 38 | | {:auth, Arangox.Auth.t()} 39 | | {:database, binary} 40 | | {:headers, headers} 41 | | {:read_only?, boolean} 42 | | {:connect_timeout, timeout} 43 | | {:failover_callback, (Error.t() -> any) | {module, atom, [any]}} 44 | | {:tcp_opts, [:gen_tcp.connect_option()]} 45 | | {:ssl_opts, [:ssl.tls_client_option()]} 46 | | {:client_opts, :gun.opts() | keyword()} 47 | | DBConnection.start_option() 48 | 49 | @type transaction_option :: 50 | {:read, binary() | [binary()]} 51 | | {:write, binary() | [binary()]} 52 | | {:exclusive, binary() | [binary()]} 53 | | {:properties, list() | map()} 54 | | DBConnection.option() 55 | 56 | @doc """ 57 | Returns a supervisor child specification for a DBConnection pool. 58 | """ 59 | @spec child_spec([start_option()]) :: Supervisor.child_spec() 60 | def child_spec(opts \\ []) do 61 | ensure_opts_valid!(opts) 62 | 63 | DBConnection.child_spec(__MODULE__.Connection, opts) 64 | end 65 | 66 | @doc """ 67 | Starts a connection pool. 68 | 69 | ## Options 70 | 71 | Accepts any of the options accepted by `DBConnection.start_link/2`, as well as any of the 72 | following: 73 | 74 | * `:endpoints` - Either a single _ArangoDB_ endpoint binary, or a list of endpoints in 75 | order of presedence. Each process in a pool will individually attempt to establish a connection 76 | with and check the availablility of each endpoint in the order given until an available endpoint 77 | is found. Defaults to `"http://localhost:8529"`. 78 | * `:database` - Arangox will prepend `/_db/:value` to the path of every request that 79 | isn't already prepended. If a value is not given, nothing is prepended (_ArangoDB_ will 80 | assume the __system_ database). 81 | * `:headers` - A map of headers to merge with every request. 82 | * `:disconnect_on_error_codes` - A list of status codes that will trigger a forced disconnect. 83 | Only integers within the range `400..599` are affected. Defaults to 84 | `[401, 405, 503, 505]`. 85 | * `:auth` - Configure whether to resolve authorization. 86 | Options are: `{:basic, username, password}`, `{:bearer, token}`. 87 | * `:read_only?` - Read-only pools will only connect to _followers_ in an active failover 88 | setup and add an _x-arango-allow-dirty-read_ header to every request. Defaults to `false`. 89 | * `:connect_timeout` - Sets the timeout for establishing connections with a database. 90 | * `:tcp_opts` - Transport options for the tcp socket interface (`:gen_tcp` in the case 91 | of gun or mint). 92 | * `:ssl_opts` - Transport options for the ssl socket interface (`:ssl` in the case of 93 | gun or mint). 94 | * `:client` - A module that implements the `Arangox.Client` behaviour. Defaults to 95 | `Arangox.VelocyClient`. 96 | * `:client_opts` - Options for the client library being used. *WARNING*: If `:transport_opts` 97 | is set here it will override the options given to `:tcp_opts` _and_ `:ssl_opts`. 98 | * `:failover_callback` - A function to call every time arangox fails to establish a 99 | connection. This is only called if a list of endpoints is given, regardless of whether or not 100 | it's connecting to an endpoint in an _active failover_ setup. Can be either an anonymous function 101 | that takes one argument (which is an `%Arangox.Error{}` struct), or a three-element tuple 102 | containing arguments to pass to `apply/3` (in which case an `%Arangox.Error{}` struct is always 103 | prepended to the arguments). 104 | """ 105 | @spec start_link([start_option]) :: GenServer.on_start() 106 | def start_link(opts \\ []) do 107 | ensure_opts_valid!(opts) 108 | 109 | DBConnection.start_link(__MODULE__.Connection, opts) 110 | end 111 | 112 | @doc """ 113 | Runs a GET request against a connection pool. 114 | 115 | Accepts any of the options accepted by `DBConnection.execute/4`. 116 | """ 117 | @spec get(conn, path, headers, [DBConnection.option()]) :: 118 | {:ok, Response.t()} | {:error, any} 119 | def get(conn, path, headers \\ %{}, opts \\ []) do 120 | request(conn, :get, path, "", headers, opts) |> do_result() 121 | end 122 | 123 | @doc """ 124 | Runs a GET request against a connection pool. Raises in the case of an error. 125 | 126 | Accepts any of the options accepted by `DBConnection.execute!/4`. 127 | """ 128 | @spec get!(conn, path, headers, [DBConnection.option()]) :: Response.t() 129 | def get!(conn, path, headers \\ %{}, opts \\ []) do 130 | request!(conn, :get, path, "", headers, opts) 131 | end 132 | 133 | @doc """ 134 | Runs a HEAD request against a connection pool. 135 | 136 | Accepts any of the options accepted by `DBConnection.execute/4`. 137 | """ 138 | @spec head(conn, path, headers, [DBConnection.option()]) :: 139 | {:ok, Response.t()} | {:error, any} 140 | def head(conn, path, headers \\ %{}, opts \\ []) do 141 | request(conn, :head, path, "", headers, opts) |> do_result() 142 | end 143 | 144 | @doc """ 145 | Runs a HEAD request against a connection pool. Raises in the case of an error. 146 | 147 | Accepts any of the options accepted by `DBConnection.execute!/4`. 148 | """ 149 | @spec head!(conn, path, headers, [DBConnection.option()]) :: Response.t() 150 | def head!(conn, path, headers \\ %{}, opts \\ []) do 151 | request!(conn, :head, path, "", headers, opts) 152 | end 153 | 154 | @doc """ 155 | Runs a DELETE request against a connection pool. 156 | 157 | Accepts any of the options accepted by `DBConnection.execute/4`. 158 | """ 159 | @spec delete(conn, path, headers, [DBConnection.option()]) :: 160 | {:ok, Response.t()} | {:error, any} 161 | def delete(conn, path, headers \\ %{}, opts \\ []) do 162 | request(conn, :delete, path, "", headers, opts) |> do_result() 163 | end 164 | 165 | @doc """ 166 | Runs a DELETE request against a connection pool. Raises in the case of an error. 167 | 168 | Accepts any of the options accepted by `DBConnection.execute!/4`. 169 | """ 170 | @spec delete!(conn, path, headers, [DBConnection.option()]) :: Response.t() 171 | def delete!(conn, path, headers \\ %{}, opts \\ []) do 172 | request!(conn, :delete, path, "", headers, opts) 173 | end 174 | 175 | @doc """ 176 | Runs a POST request against a connection pool. 177 | 178 | Accepts any of the options accepted by `DBConnection.execute/4`. 179 | """ 180 | @spec post(conn, path, body, headers, [DBConnection.option()]) :: 181 | {:ok, Response.t()} | {:error, any} 182 | def post(conn, path, body \\ "", headers \\ %{}, opts \\ []) do 183 | request(conn, :post, path, body, headers, opts) |> do_result() 184 | end 185 | 186 | @doc """ 187 | Runs a POST request against a connection pool. Raises in the case of an error. 188 | 189 | Accepts any of the options accepted by `DBConnection.execute!/4`. 190 | """ 191 | @spec post!(conn, path, body, headers, [DBConnection.option()]) :: Response.t() 192 | def post!(conn, path, body \\ "", headers \\ %{}, opts \\ []) do 193 | request!(conn, :post, path, body, headers, opts) 194 | end 195 | 196 | @doc """ 197 | Runs a PUT request against a connection pool. 198 | 199 | Accepts any of the options accepted by `DBConnection.execute/4`. 200 | """ 201 | @spec put(conn, path, body, headers, [DBConnection.option()]) :: 202 | {:ok, Response.t()} | {:error, any} 203 | def put(conn, path, body \\ "", headers \\ %{}, opts \\ []) do 204 | request(conn, :put, path, body, headers, opts) |> do_result() 205 | end 206 | 207 | @doc """ 208 | Runs a PUT request against a connection pool. Raises in the case of an error. 209 | 210 | Accepts any of the options accepted by `DBConnection.execute!/4`. 211 | """ 212 | @spec put!(conn, path, body, headers, [DBConnection.option()]) :: Response.t() 213 | def put!(conn, path, body \\ "", headers \\ %{}, opts \\ []) do 214 | request!(conn, :put, path, body, headers, opts) 215 | end 216 | 217 | @doc """ 218 | Runs a PATCH request against a connection pool. 219 | 220 | Accepts any of the options accepted by `DBConnection.execute/4`. 221 | """ 222 | @spec patch(conn, path, body, headers, [DBConnection.option()]) :: 223 | {:ok, Response.t()} | {:error, any} 224 | def patch(conn, path, body \\ "", headers \\ %{}, opts \\ []) do 225 | request(conn, :patch, path, body, headers, opts) |> do_result() 226 | end 227 | 228 | @doc """ 229 | Runs a PATCH request against a connection pool. Raises in the case of an error. 230 | 231 | Accepts any of the options accepted by `DBConnection.execute!/4`. 232 | """ 233 | @spec patch!(conn, path, body, headers, [DBConnection.option()]) :: Response.t() 234 | def patch!(conn, path, body \\ "", headers \\ %{}, opts \\ []) do 235 | request!(conn, :patch, path, body, headers, opts) 236 | end 237 | 238 | @doc """ 239 | Runs a OPTIONS request against a connection pool. 240 | 241 | Accepts any of the options accepted by `DBConnection.execute/4`. 242 | """ 243 | @spec options(conn, path, headers, [DBConnection.option()]) :: 244 | {:ok, Response.t()} | {:error, any} 245 | def options(conn, path, headers \\ %{}, opts \\ []) do 246 | request(conn, :options, path, "", headers, opts) |> do_result() 247 | end 248 | 249 | @doc """ 250 | Runs a OPTIONS request against a connection pool. Raises in the case of an error. 251 | 252 | Accepts any of the options accepted by `DBConnection.execute!/4`. 253 | """ 254 | @spec options!(conn, path, headers, [DBConnection.option()]) :: Response.t() 255 | def options!(conn, path, headers \\ %{}, opts \\ []) do 256 | request!(conn, :options, path, "", headers, opts) 257 | end 258 | 259 | @doc """ 260 | Runs a request against a connection pool. 261 | 262 | Accepts any of the options accepted by `DBConnection.execute/4`. 263 | """ 264 | @spec request(conn, method, path, body, headers, [DBConnection.option()]) :: 265 | {:ok, Request.t(), Response.t()} | {:error, any} 266 | def request(conn, method, path, body \\ "", headers \\ %{}, opts \\ []) do 267 | request = %Request{method: method, path: path, body: body, headers: headers} 268 | 269 | DBConnection.execute(conn, request, nil, opts) 270 | end 271 | 272 | @doc """ 273 | Runs a request against a connection pool. Raises in the case of an error. 274 | 275 | Accepts any of the options accepted by `DBConnection.execute!/4`. 276 | """ 277 | @spec request!(conn, method, path, body, headers, [DBConnection.option()]) :: 278 | Response.t() 279 | def request!(conn, method, path, body \\ "", headers \\ %{}, opts \\ []) do 280 | request = %Request{method: method, path: path, body: body, headers: headers} 281 | 282 | DBConnection.execute!(conn, request, nil, opts) 283 | end 284 | 285 | defp do_result({:ok, _request, response}) do 286 | {:ok, response} 287 | end 288 | 289 | defp do_result({:error, exception}), do: {:error, exception} 290 | 291 | @doc """ 292 | Acquires a connection from a pool and runs a series of requests or cursors with it. 293 | If the connection disconnects, all future calls using that connection reference will 294 | fail. 295 | 296 | Runs can be nested multiple times if the connection reference is used to start a 297 | nested run (i.e. calling another function that calls this one). The top level run 298 | function will represent the actual run. 299 | 300 | Delegates to `DBConnection.run/3`. 301 | 302 | ## Example 303 | 304 | result = 305 | Arangox.run(conn, fn c -> 306 | Arangox.request!(c, ...) 307 | end) 308 | """ 309 | @spec run(conn, (DBConnection.t() -> result), [DBConnection.option()]) :: result 310 | when result: var 311 | defdelegate run(conn, fun, opts \\ []), to: DBConnection 312 | 313 | @doc """ 314 | Acquires a connection from a pool, begins a transaction in the database and runs a 315 | series of requests or cursors with it. If the connection disconnects, all future calls 316 | using that connection reference will fail. 317 | 318 | Transactions can be nested multiple times if the connection reference is used to start a 319 | nested transactions (i.e. calling another function that calls this one). The top level 320 | transaction function will represent the actual transaction and nested transactions will 321 | be interpreted as a `run/3`, erego, any collections declared in nested transactions will 322 | have no effect. 323 | 324 | Accepts any of the options accepted by `DBConnection.transaction/3`, as well as any of the 325 | following: 326 | 327 | * `:read` - An array of collection names or a single collection name as a binary. 328 | * `:write` - An array of collection names or a single collection name as a binary. 329 | * `:exclusive` - An array of collection names or a single collection name as a binary. 330 | * `:database` - Sets what database to run the transaction on 331 | * `:properties` - A list or map of additional body attributes to append to the request 332 | body when beginning a transaction. 333 | 334 | Delegates to `DBConnection.transaction/3`. 335 | 336 | ## Example 337 | 338 | Arangox.transaction(conn, fn c -> 339 | Arangox.status(c) #=> :transaction 340 | 341 | # do stuff 342 | end, [ 343 | write: "something", 344 | properties: [waitForSync: true] 345 | ]) 346 | """ 347 | @spec transaction(conn, (DBConnection.t() -> result), [transaction_option()]) :: 348 | {:ok, result} | {:error, any} 349 | when result: var 350 | defdelegate transaction(conn, fun, opts \\ []), to: DBConnection 351 | 352 | @doc """ 353 | Fetches the current status of a transaction from the database and returns its 354 | corresponding `DBconnection` status. 355 | 356 | Delegates to `DBConnection.status/1`. 357 | """ 358 | @spec status(conn) :: DBConnection.status() 359 | defdelegate status(conn), to: DBConnection 360 | 361 | @doc """ 362 | Aborts a transaction for the given reason. 363 | 364 | Delegates to `DBConnection.rollback/2`. 365 | 366 | ## Example 367 | 368 | iex> {:ok, conn} = Arangox.start_link() 369 | iex> Arangox.transaction(conn, fn c -> 370 | iex> Arangox.abort(c, :reason) 371 | iex> end) 372 | {:error, :reason} 373 | """ 374 | @spec abort(conn, reason :: any) :: no_return() 375 | defdelegate abort(conn, reason), to: DBConnection, as: :rollback 376 | 377 | @doc """ 378 | Creates a cursor and returns a `DBConnection.Stream` struct. Results are fetched 379 | upon enumeration. 380 | 381 | The cursor is created, results fetched, then deleted from the database upon each 382 | enumeration (not to be confused with iteration). When a cursor is created, an initial 383 | result set is fetched from the database. The initial result is returned with the first 384 | iteration, subsequent iterations are fetched lazily. 385 | 386 | Can only be used within a `transaction/3` or `run/3` call. 387 | 388 | Accepts any of the options accepted by `DBConnection.stream/4`, as well as any of the 389 | following: 390 | 391 | * `:database` - Sets what database to run the cursor query on 392 | * `:properties` - A list or map of additional body attributes to append to the 393 | request body when creating the cursor. 394 | 395 | Delegates to `DBConnection.stream/4`. 396 | 397 | ## Example 398 | 399 | iex> {:ok, conn} = Arangox.start_link() 400 | iex> Arangox.transaction(conn, fn c -> 401 | iex> stream = 402 | iex> Arangox.cursor( 403 | iex> c, 404 | iex> "FOR i IN [1, 2, 3] FILTER i == 1 || i == @num RETURN i", 405 | iex> %{num: 2}, 406 | iex> properties: [batchSize: 1] 407 | iex> ) 408 | iex> 409 | iex> first_batch = Enum.at(stream, 0).body["result"] 410 | iex> 411 | iex> exhaust_cursor = 412 | iex> Enum.reduce(stream, [], fn resp, acc -> 413 | iex> acc ++ resp.body["result"] 414 | iex> end) 415 | iex> 416 | iex> {first_batch, exhaust_cursor} 417 | iex> end) 418 | {:ok, {[1], [1, 2]}} 419 | """ 420 | @spec cursor(conn(), query, bindvars, [DBConnection.option()]) :: DBConnection.Stream.t() 421 | defdelegate cursor(conn, query, bindvars \\ [], opts \\ []), to: DBConnection, as: :stream 422 | 423 | @doc """ 424 | Returns the configured JSON library. 425 | 426 | To change the library, include the following in your `config/config.exs`: 427 | 428 | config :arangox, :json_library, Module 429 | 430 | Defaults to `Jason`. 431 | """ 432 | @spec json_library() :: module() 433 | def json_library, do: Application.get_env(:arangox, :json_library, Jason) 434 | 435 | defp ensure_opts_valid!(opts) do 436 | if endpoints = Keyword.get(opts, :endpoints) do 437 | unless is_binary(endpoints) or (is_list(endpoints) and endpoints_valid?(endpoints)) do 438 | raise ArgumentError, """ 439 | The :endpoints option expects a binary or a non-empty list of binaries,\ 440 | got: #{inspect(endpoints)} 441 | """ 442 | end 443 | end 444 | 445 | if auth = Keyword.get(opts, :auth) do 446 | Auth.validate(auth) 447 | end 448 | 449 | if client = Keyword.get(opts, :client) do 450 | ensure_client_loaded!(client) 451 | end 452 | 453 | if database = Keyword.get(opts, :database) do 454 | unless is_binary(database) do 455 | raise ArgumentError, """ 456 | The :database option expects a binary, got: #{inspect(endpoints)} 457 | """ 458 | end 459 | end 460 | end 461 | 462 | defp endpoints_valid?(endpoints) when is_list(endpoints) do 463 | length(endpoints) > 0 and 464 | Enum.count(endpoints, &is_binary/1) == length(endpoints) 465 | end 466 | 467 | defp ensure_client_loaded!(client) do 468 | cond do 469 | not is_atom(client) -> 470 | raise ArgumentError, """ 471 | The :client option expects a module, got: #{inspect(client)} 472 | """ 473 | 474 | client in [VelocyClient, GunClient, MintClient] -> 475 | unless Code.ensure_loaded?(client) do 476 | library = 477 | client 478 | |> Module.split() 479 | |> List.last() 480 | |> String.downcase() 481 | 482 | raise """ 483 | Missing client dependency. Please add #{library} to your mix deps: 484 | 485 | # mix.exs 486 | defp deps do 487 | ... 488 | {:#{library}, "~> ..."} 489 | end 490 | """ 491 | end 492 | 493 | client -> 494 | unless Code.ensure_loaded?(client), 495 | do: raise("Module #{client} does not exist") 496 | end 497 | end 498 | end 499 | -------------------------------------------------------------------------------- /lib/arangox/connection.ex: -------------------------------------------------------------------------------- 1 | defimpl DBConnection.Query, for: BitString do 2 | def parse(query, _opts), do: query 3 | 4 | def describe(query, _opts), do: query 5 | 6 | def encode(_query, params, _opts), do: Enum.into(params, %{}) 7 | 8 | def decode(_query, params, _opts), do: params 9 | end 10 | 11 | defmodule Arangox.Connection do 12 | @moduledoc """ 13 | `DBConnection` implementation for `Arangox`. 14 | """ 15 | 16 | use DBConnection 17 | 18 | alias Arangox.{ 19 | Client, 20 | Endpoint, 21 | Error, 22 | Request, 23 | Response, 24 | VelocyClient 25 | } 26 | 27 | @type t :: %__MODULE__{ 28 | socket: any, 29 | client: module, 30 | endpoint: Arangox.endpoint(), 31 | failover?: boolean, 32 | database: binary, 33 | auth: Arangox.Auth.t(), 34 | headers: Arangox.headers(), 35 | disconnect_on_error_codes: [integer], 36 | read_only?: boolean, 37 | cursors: map 38 | } 39 | 40 | @type failover? :: boolean 41 | 42 | @enforce_keys [:socket, :client, :endpoint] 43 | 44 | defstruct [ 45 | :socket, 46 | :client, 47 | :endpoint, 48 | :failover?, 49 | :database, 50 | :cursors, 51 | :auth, 52 | headers: %{}, 53 | disconnect_on_error_codes: [401, 405, 503, 505], 54 | read_only?: false 55 | ] 56 | 57 | @spec new( 58 | Client.socket(), 59 | Arangox.client(), 60 | Arangox.endpoint(), 61 | failover?, 62 | [Arangox.start_option()] 63 | ) :: t 64 | def new(socket, client, endpoint, failover?, opts) do 65 | __MODULE__ 66 | |> struct(opts) 67 | |> Map.put(:socket, socket) 68 | |> Map.put(:client, client) 69 | |> Map.put(:endpoint, endpoint) 70 | |> Map.put(:failover?, failover?) 71 | |> Map.put(:cursors, %{}) 72 | end 73 | 74 | # @header_arango_endpoint "x-arango-endpoint" 75 | @path_trx "/_api/transaction/" 76 | @header_trx_id "x-arango-trx-id" 77 | @header_dirty_read {"x-arango-allow-dirty-read", "true"} 78 | @request_ping %Request{method: :get, path: "/_admin/server/availability"} 79 | @request_availability %Request{method: :get, path: "/_admin/server/availability"} 80 | @request_mode %Request{method: :get, path: "/_admin/server/mode"} 81 | @exception_no_prep %Error{message: "ArangoDB doesn't support prepared queries yet"} 82 | 83 | @impl true 84 | def connect(opts) do 85 | client = Keyword.get(opts, :client, Arangox.VelocyClient) 86 | endpoints = Keyword.get(opts, :endpoints, "http://localhost:8529") 87 | 88 | with( 89 | {:ok, %__MODULE__{} = state} <- do_connect(client, endpoints, opts), 90 | {:ok, %__MODULE__{} = state} <- resolve_auth(stringify_kvs(state)), 91 | {:ok, %__MODULE__{} = state} <- check_availability(state) 92 | ) do 93 | {:ok, state} 94 | else 95 | {:connect, endpoint} -> 96 | connect(Keyword.put(opts, :endpoints, [endpoint])) 97 | 98 | {:error, reason} when reason in [:failed, :unavailable] -> 99 | connect(Keyword.put(opts, :endpoints, tl(endpoints))) 100 | 101 | {:error, %_{} = reason} -> 102 | {:error, reason} 103 | 104 | {:error, reason} -> 105 | {:error, %Error{message: reason}} 106 | end 107 | end 108 | 109 | defp do_connect(client, endpoint, opts) when is_binary(endpoint) do 110 | case Client.connect(client, Endpoint.new(endpoint), opts) do 111 | {:ok, socket} -> 112 | {:ok, new(socket, client, endpoint, false, opts)} 113 | 114 | {:error, reason} -> 115 | {:error, exception(new(nil, client, endpoint, false, opts), reason)} 116 | end 117 | end 118 | 119 | defp do_connect(client, [], opts) do 120 | {:error, 121 | __MODULE__ 122 | |> struct(opts) 123 | |> Map.put(:client, client) 124 | |> Map.put(:failover?, true) 125 | |> exception("all endpoints are unavailable") 126 | |> failover_callback(opts)} 127 | end 128 | 129 | defp do_connect(client, endpoints, opts) when is_list(endpoints) do 130 | endpoint = hd(endpoints) 131 | 132 | case Client.connect(client, Endpoint.new(endpoint), opts) do 133 | {:ok, socket} -> 134 | {:ok, new(socket, client, endpoint, true, opts)} 135 | 136 | {:error, reason} -> 137 | new(nil, client, endpoint, true, opts) 138 | |> exception(reason) 139 | |> failover_callback(opts) 140 | 141 | {:error, :failed} 142 | end 143 | end 144 | 145 | defp failover_callback(%_{} = exception, opts) do 146 | case Keyword.get(opts, :failover_callback) do 147 | {mod, fun, args} -> 148 | apply(mod, fun, [exception | args]) 149 | 150 | fun when is_function(fun, 1) -> 151 | fun.(exception) 152 | 153 | _invalid_callback -> 154 | nil 155 | end 156 | 157 | exception 158 | end 159 | 160 | defp resolve_auth(%__MODULE__{client: VelocyClient} = state) do 161 | case apply(VelocyClient, :maybe_authenticate, [state]) do 162 | :ok -> 163 | {:ok, state} 164 | 165 | {:error, reason} -> 166 | {:error, reason} 167 | end 168 | end 169 | 170 | defp resolve_auth(%__MODULE__{auth: {:basic, un, pw}} = state) do 171 | base64_encoded = Base.encode64("#{un}:#{pw}") 172 | {:ok, put_header(state, {"authorization", "Basic #{base64_encoded}"})} 173 | end 174 | 175 | defp resolve_auth(%__MODULE__{auth: {:bearer, token}} = state) do 176 | {:ok, put_header(state, {"authorization", "Bearer #{token}"})} 177 | end 178 | 179 | defp resolve_auth(%__MODULE__{} = state) do 180 | {:ok, state} 181 | end 182 | 183 | defp check_availability(%__MODULE__{read_only?: true} = state) do 184 | state = put_header(state, @header_dirty_read) 185 | request = merge_headers(@request_mode, state.headers) 186 | result = Client.request(request, state) 187 | 188 | with( 189 | {:ok, %Response{status: 200} = response, state} <- result, 190 | %Response{body: %{"mode" => "readonly"}} <- maybe_decode_body(response, state) 191 | ) do 192 | {:ok, state} 193 | else 194 | {:error, reason, state} -> 195 | error = 196 | if state.failover?, 197 | do: :failed, 198 | else: exception(state, reason) 199 | 200 | {:error, error} 201 | 202 | _ -> 203 | error = 204 | if state.failover?, 205 | do: :unavailable, 206 | else: exception(state, "not a readonly server") 207 | 208 | {:error, error} 209 | end 210 | end 211 | 212 | defp check_availability(%__MODULE__{failover?: failover?} = state) do 213 | request = merge_headers(@request_availability, state.headers) 214 | # result = Client.request(request, state) 215 | 216 | # if a server is running inside a container, it's x-arango-endpoint 217 | # header will need to be mapped to a different value somehow 218 | with( 219 | result <- Client.request(request, state), 220 | {:ok, %Response{status: 503}, _state} <- result 221 | # {:ok, %Response{status: 503} = response, _state} <- result 222 | # endpoint when not is_nil(endpoint) <- response.headers[@header_arango_endpoint] 223 | ) do 224 | # {:connect, endpoint} 225 | 226 | error = if failover?, do: :unavailable, else: exception(state, "service unavailable") 227 | {:error, error} 228 | else 229 | {:ok, %Response{status: _status}, state} -> 230 | {:ok, state} 231 | 232 | {:error, reason, _state} -> 233 | {:error, reason} 234 | 235 | # nil -> 236 | # {:error, :unavailable} 237 | end 238 | end 239 | 240 | @impl true 241 | def disconnect(_reason, %__MODULE__{} = state), do: Client.close(state) 242 | 243 | @impl true 244 | def checkout(%__MODULE__{} = state), do: {:ok, state} 245 | 246 | # Transaction handlers 247 | 248 | @impl true 249 | def handle_begin(_opts, %__MODULE__{headers: %{@header_trx_id => _id}} = state), 250 | do: {:transaction, state} 251 | 252 | @impl true 253 | def handle_begin(opts, %__MODULE__{} = state) do 254 | collections = 255 | opts 256 | |> Keyword.take([:read, :write, :exclusive]) 257 | |> Enum.into(%{}) 258 | 259 | body = 260 | opts 261 | |> Keyword.get(:properties, []) 262 | |> Enum.into(%{collections: collections}) 263 | 264 | request = %Request{ 265 | method: :post, 266 | path: Path.join(@path_trx, "begin"), 267 | body: body 268 | } 269 | 270 | case handle_execute(nil, request, opts, state) do 271 | {:ok, _request, %Response{status: 201, body: %{"result" => %{"id" => id}}} = response, 272 | state} -> 273 | {:ok, response, put_header(state, {@header_trx_id, id})} 274 | 275 | {:ok, _request, %Response{}, state} -> 276 | {:error, state} 277 | 278 | {:error, _exception, state} -> 279 | {:error, state} 280 | 281 | {:disconnect, _exception, state} -> 282 | {:error, state} 283 | end 284 | end 285 | 286 | @impl true 287 | def handle_status(opts, %__MODULE__{} = state) do 288 | with( 289 | {id, _headers} when is_binary(id) <- 290 | Map.pop(state.headers, @header_trx_id), 291 | {:ok, _request, %Response{status: 200}, state} <- 292 | handle_execute( 293 | nil, 294 | %Request{method: :get, path: Path.join(@path_trx, id)}, 295 | opts, 296 | state 297 | ) 298 | ) do 299 | {:transaction, state} 300 | else 301 | {nil, _headers} -> 302 | {:idle, state} 303 | 304 | {:ok, _request, %Response{}, state} -> 305 | {:error, state} 306 | 307 | {:error, _exception, state} -> 308 | {:error, state} 309 | 310 | {:disconnect, exception, state} -> 311 | {:disconnect, exception, state} 312 | end 313 | end 314 | 315 | @impl true 316 | def handle_commit(opts, %__MODULE__{} = state) do 317 | with( 318 | {id, headers} when is_binary(id) <- 319 | Map.pop(state.headers, @header_trx_id), 320 | {:ok, _request, %Response{status: 200} = response, state} <- 321 | handle_execute( 322 | nil, 323 | %Request{method: :put, path: Path.join(@path_trx, id)}, 324 | opts, 325 | %{state | headers: headers} 326 | ) 327 | ) do 328 | {:ok, response, state} 329 | else 330 | {nil, _headers} -> 331 | {:idle, state} 332 | 333 | {:ok, _request, %Response{}, state} -> 334 | {:error, state} 335 | 336 | {:error, _exception, state} -> 337 | {:error, state} 338 | 339 | {:disconnect, exception, state} -> 340 | {:disconnect, exception, state} 341 | end 342 | end 343 | 344 | @impl true 345 | def handle_rollback(opts, %__MODULE__{} = state) do 346 | with( 347 | {id, headers} when is_binary(id) <- Map.pop(state.headers, @header_trx_id), 348 | {:ok, _request, %Response{status: 200} = response, state} <- 349 | handle_execute( 350 | nil, 351 | %Request{method: :delete, path: Path.join(@path_trx, id)}, 352 | opts, 353 | %{state | headers: headers} 354 | ) 355 | ) do 356 | {:ok, response, state} 357 | else 358 | {nil, _headers} -> 359 | {:idle, state} 360 | 361 | {:ok, _request, %Response{}, state} -> 362 | {:error, state} 363 | 364 | {:error, _exception, state} -> 365 | {:error, state} 366 | 367 | {:disconnect, exception, state} -> 368 | {:disconnect, exception, state} 369 | end 370 | end 371 | 372 | @impl true 373 | def handle_declare(query, params, opts, %__MODULE__{} = state) do 374 | body = 375 | opts 376 | |> Keyword.get(:properties, []) 377 | |> Enum.into(%{query: query, bindVars: params}) 378 | 379 | request = %Request{ 380 | method: :post, 381 | path: "/_api/cursor", 382 | body: body 383 | } 384 | 385 | case handle_execute(query, request, opts, state) do 386 | {:ok, _req, %Response{body: %{"id" => cursor}} = initial, state} -> 387 | {:ok, query, cursor, %{state | cursors: Map.put(state.cursors, cursor, initial)}} 388 | 389 | {:ok, _req, %Response{} = initial, state} -> 390 | cursor = rand() 391 | {:ok, query, cursor, %{state | cursors: Map.put(state.cursors, cursor, initial)}} 392 | 393 | error -> 394 | error 395 | end 396 | end 397 | 398 | @rand_min String.to_integer("10000000", 36) 399 | @rand_max String.to_integer("ZZZZZZZZ", 36) 400 | 401 | defp rand do 402 | @rand_max 403 | |> Kernel.-(@rand_min) 404 | |> :rand.uniform() 405 | |> Kernel.+(@rand_min) 406 | end 407 | 408 | @impl true 409 | def handle_fetch(query, cursor, opts, %__MODULE__{cursors: cursors} = state) do 410 | with( 411 | {nil, _cursors} <- 412 | Map.pop(cursors, cursor), 413 | {:ok, _req, %Response{body: %{"hasMore" => true}} = response, state} <- 414 | handle_execute( 415 | query, 416 | %Request{method: :put, path: "/_api/cursor/" <> cursor}, 417 | opts, 418 | state 419 | ) 420 | ) do 421 | {:cont, response, state} 422 | else 423 | {%Response{body: %{"hasMore" => false}} = initial, cursors} -> 424 | {:halt, initial, %{state | cursors: Map.put(cursors, cursor, :noop)}} 425 | 426 | {%Response{body: %{"hasMore" => true}} = initial, cursors} -> 427 | {:cont, initial, %{state | cursors: cursors}} 428 | 429 | {:ok, _req, %Response{body: %{"hasMore" => false}} = response, state} -> 430 | {:halt, response, %{state | cursors: Map.put(cursors, cursor, :noop)}} 431 | 432 | error -> 433 | error 434 | end 435 | end 436 | 437 | @impl true 438 | def handle_deallocate(query, cursor, opts, %__MODULE__{cursors: cursors} = state) do 439 | state = %{state | cursors: Map.delete(cursors, cursor)} 440 | 441 | case cursors do 442 | %{^cursor => :noop} -> 443 | {:ok, :noop, state} 444 | 445 | _ -> 446 | request = %Request{method: :delete, path: "/_api/cursor/" <> cursor} 447 | 448 | case handle_execute(query, request, opts, state) do 449 | {:ok, _req, response, state} -> 450 | {:ok, response, state} 451 | 452 | error -> 453 | error 454 | end 455 | end 456 | end 457 | 458 | @impl true 459 | def ping(%__MODULE__{} = state) do 460 | case handle_execute(nil, @request_ping, [], state) do 461 | {:ok, _request, %Response{}, state} -> 462 | {:ok, state} 463 | 464 | {call, exception, state} when call in [:error, :disconnect] -> 465 | {:disconnect, exception, state} 466 | end 467 | end 468 | 469 | @impl true 470 | def handle_execute(_q, %Request{} = request, opts, %__MODULE__{} = state) do 471 | request = 472 | request 473 | |> merge_headers(state.headers) 474 | |> maybe_prepend_database(state, opts) 475 | |> maybe_encode_body(state) 476 | 477 | case Client.request(request, state) do 478 | {:ok, %Response{status: status} = response, state} when status in 400..599 -> 479 | { 480 | err_or_disc(status, state.disconnect_on_error_codes), 481 | exception(state, response), 482 | state 483 | } 484 | 485 | {:ok, response, state} -> 486 | {:ok, sanitize_headers(request), maybe_decode_body(response, state), state} 487 | 488 | {:error, :noproc, state} -> 489 | {:disconnect, exception(state, "connection lost"), state} 490 | 491 | {:error, %_{} = reason, state} -> 492 | {:error, reason, state} 493 | 494 | {:error, reason, state} -> 495 | {:error, exception(state, reason), state} 496 | end 497 | end 498 | 499 | defp err_or_disc(status, codes) do 500 | if status in codes, do: :disconnect, else: :error 501 | end 502 | 503 | # Unsupported callbacks 504 | 505 | @impl true 506 | def handle_prepare(_q, _opts, %__MODULE__{} = state) do 507 | {:error, %{@exception_no_prep | endpoint: state.endpoint}, state} 508 | end 509 | 510 | @impl true 511 | def handle_close(_q, _opts, %__MODULE__{} = state) do 512 | {:error, %{@exception_no_prep | endpoint: state.endpoint}, state} 513 | end 514 | 515 | # Utils 516 | 517 | defp put_header(%__MODULE__{headers: headers} = struct, {key, value}), 518 | do: %{struct | headers: Map.put(headers, key, value)} 519 | 520 | defp merge_headers(%Request{headers: req_headers} = struct, headers) when is_map(headers), 521 | do: %{struct | headers: Map.merge(headers, stringify_kvs(req_headers))} 522 | 523 | defp stringify_kvs(%{headers: headers} = struct), 524 | do: %{struct | headers: stringify_kvs(headers)} 525 | 526 | defp stringify_kvs(headers), 527 | do: Map.new(headers, fn {k, v} -> {to_string(k), to_string(v)} end) 528 | 529 | defp sanitize_headers(%{headers: %{"authorization" => _auth} = headers} = struct) 530 | when is_map(struct) do 531 | %{struct | headers: %{headers | "authorization" => "..."}} 532 | end 533 | 534 | defp sanitize_headers(%{headers: _headers} = struct) when is_map(struct), do: struct 535 | 536 | defp maybe_prepend_database(%Request{path: path} = request, state, opts) do 537 | case Keyword.get(opts, :database) do 538 | nil -> 539 | do_db_prepend(request, state) 540 | 541 | db -> 542 | %{request | path: "/_db/" <> db <> path} 543 | end 544 | end 545 | 546 | # Only prepends when not velocy or nil or path already contains /_db/ 547 | defp do_db_prepend(%Request{} = request, %{client: VelocyClient}), 548 | do: request 549 | 550 | defp do_db_prepend(%Request{} = request, %{database: nil}), 551 | do: request 552 | 553 | defp do_db_prepend(%Request{path: "/_db/" <> _} = request, %{database: _db}), 554 | do: request 555 | 556 | defp do_db_prepend(%Request{path: path} = request, %{database: db}), 557 | do: %{request | path: "/_db/" <> db <> path} 558 | 559 | # Only encodes when not velocy or empty string 560 | defp maybe_encode_body(%_{} = struct, %__MODULE__{client: VelocyClient}), do: struct 561 | 562 | defp maybe_encode_body(%_{body: ""} = struct, %__MODULE__{}), do: struct 563 | 564 | defp maybe_encode_body(%_{body: body} = struct, %__MODULE__{}) do 565 | %{struct | body: Arangox.json_library().encode!(body)} 566 | end 567 | 568 | # Only decodes when not velocy or nil 569 | defp maybe_decode_body(%_{} = struct, %__MODULE__{client: VelocyClient}), do: struct 570 | 571 | defp maybe_decode_body(%_{body: nil} = struct, %__MODULE__{}), do: struct 572 | 573 | defp maybe_decode_body( 574 | %_{body: body, headers: %{"content-type" => "application/x-arango-dump"}} = struct, 575 | %__MODULE__{} 576 | ) do 577 | content = 578 | body 579 | |> String.split("\n") 580 | |> Enum.filter(fn line -> String.length(line) > 0 end) 581 | |> Enum.map(fn line -> Arangox.json_library().decode!(line) end) 582 | 583 | %{struct | body: content} 584 | end 585 | 586 | defp maybe_decode_body(%_{body: body} = struct, %__MODULE__{}) do 587 | %{struct | body: Arangox.json_library().decode!(body)} 588 | end 589 | 590 | defp exception(state, %Response{body: nil} = response), 591 | do: %Error{endpoint: state.endpoint, status: response.status} 592 | 593 | defp exception(state, %Response{} = response) do 594 | response = maybe_decode_body(response, state) 595 | message = response.body["errorMessage"] 596 | error_num = response.body["errorNum"] 597 | 598 | %Error{ 599 | endpoint: state.endpoint, 600 | status: response.status, 601 | error_num: error_num, 602 | message: message 603 | } 604 | end 605 | 606 | defp exception(state, reason), 607 | do: %Error{endpoint: state.endpoint, message: reason} 608 | end 609 | --------------------------------------------------------------------------------