defmodule TerminusDB.Client do @moduledoc """ The HTTP wire module for `terminusdb_ex`. `TerminusDB.Client` is the **only** module that issues HTTP requests. Every higher-level API module (`TerminusDB.Database`, `TerminusDB.Document`, …) composes a request and delegates here. Centralizing the wire logic keeps auth, headers, JSON, telemetry, retries, and error mapping in one place. Built on [Req](https://hexdocs.pm/req). Connection context is carried by an immutable `TerminusDB.Config` struct. ## Functions - `request/4` — returns `{:ok, body}` or `{:error, TerminusDB.Error.t()}`. - `request!/4` — returns the body or raises `TerminusDB.Error`. - `request_response/4` — returns `{:ok, Req.Response.t()}` when the full response (headers, status, streamed body) is needed. ## Options In addition to the telemetry-only `:area` and `:raw` flags, request options are forwarded to Req: `:json` (JSON body), `:body` (raw body), `:params` (query string), `:into` (response streaming target), `:form`, `:form_multipart`, `:decode_body`. ## Examples # Using the Database API (preferred) {:ok, body} = TerminusDB.Database.create(config, "mydb", label: "My DB") # Using the raw client directly {:ok, body} = TerminusDB.Client.request(config, :post, "db/admin/mydb", json: %{label: "My DB", comment: "demo", schema: true}, area: :database ) """ alias TerminusDB.{Config, Error, Telemetry} @type method :: :get | :post | :put | :patch | :delete | :head @doc """ Performs an HTTP request and returns `{:ok, decoded_body}` or `{:error, Error.t()}`. The body is auto-decoded by Req when the response is JSON (string keys). For non-2xx responses, an `TerminusDB.Error` is built from the structured `api:*` body when present, or a generic `:http` error otherwise. ## Options See the module documentation. `:area` sets the telemetry event area (default `:connection`). `:raw` returns the full `Req.Response.t()` instead of the body. ## Examples iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} end ...> ) iex> {:ok, body} = TerminusDB.Client.request(config, :get, "ok") iex> body["api:status"] "api:success" """ @spec request(Config.t(), method(), String.t(), keyword()) :: {:ok, term()} | {:error, Error.t()} def request(config, method, path, opts \\ []) do case request_response(config, method, path, opts) do {:ok, resp} -> {:ok, if(opts[:raw], do: resp, else: resp.body)} {:error, _} = error -> error end end @doc """ Performs an HTTP request and returns the decoded body, or raises `TerminusDB.Error`. iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> ...> {req, Req.Response.new(status: 200, body: %{"api:status" => "api:success"})} ...> end ...> ) iex> TerminusDB.Client.request!(config, :get, "ok") %{"api:status" => "api:success"} """ @spec request!(Config.t(), method(), String.t(), keyword()) :: term() def request!(config, method, path, opts \\ []) do case request(config, method, path, opts) do {:ok, body} -> body {:error, error} -> raise error end end @doc """ Performs an HTTP request and returns `{:ok, Req.Response.t()}` with the full response (status, headers, body). Use this when you need headers or a streamed body (`:into`). ## Examples iex> config = TerminusDB.Config.new( ...> endpoint: "http://localhost:6363", ...> adapter: fn req -> {req, Req.Response.new(status: 200, body: %{"ok" => true})} end ...> ) iex> {:ok, resp} = TerminusDB.Client.request_response(config, :get, "ok") iex> resp.status 200 """ @spec request_response(Config.t(), method(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, Error.t()} def request_response(config, method, path, opts \\ []) do area = opts[:area] || :connection meta = %{method: method, path: path, area: area, config: Config.redact(config)} start_monotonic = Telemetry.start(area, meta, config) req = build_request(config, method, path, opts) {result, status, error} = case Req.request(req) do {:ok, resp} -> if resp.status in 200..299 do {{:ok, resp}, resp.status, nil} else error = build_status_error(resp) {{:error, error}, error.status, error} end {:error, exception} -> error = Error.transport(exception) {{:error, error}, nil, error} end Telemetry.stop(area, meta, start_monotonic, config, status: status, error: error) result end @doc """ Builds the `organization/database` resource segment for the given config and options, resolving the organization from `opts[:organization]` or `config.organization`. Raises `TerminusDB.Error` if no database is scoped. iex> config = TerminusDB.Config.new(endpoint: "http://localhost:6363") ...> |> TerminusDB.Config.with_database("mydb") iex> TerminusDB.Client.resource_path(config, []) "admin/mydb" iex> TerminusDB.Client.resource_path(config, organization: "acme") "acme/mydb" """ @spec resource_path(Config.t(), keyword()) :: String.t() def resource_path(%Config{} = config, opts) do org = opts[:organization] || config.organization db = config.database || raise Error, reason: :http, message: "no database scoped in config" "#{org}/#{db}" end # Request construction ------------------------------------------------------ defp build_request(config, method, path, opts) do base_url = String.trim_trailing(config.endpoint, "/") <> "/api/" base_opts = [ base_url: base_url, auth: Config.auth(config), receive_timeout: config.receive_timeout, headers: req_headers(config), redirect: false, retry: false ] base_opts = if config.adapter, do: Keyword.put(base_opts, :adapter, config.adapter), else: base_opts req = Req.new(base_opts) Req.merge(req, [method: method, url: path] ++ req_opts(opts)) end defp req_headers(%Config{user_agent: ua, headers: headers}) do Map.merge(%{"user-agent" => ua}, headers) end defp req_opts(opts) do Keyword.take(opts, [ :json, :body, :params, :headers, :into, :form, :form_multipart, :decode_body ]) end # Response handling --------------------------------------------------------- defp build_status_error(resp) do case resp.body do %{} = body -> Error.api(resp.status, body) body when is_binary(body) and body != "" -> case Jason.decode(body) do {:ok, %{} = decoded} -> Error.api(resp.status, decoded) _ -> Error.http(resp.status, body) end body -> Error.http(resp.status, body) end end end