Skip to main content

lib/terminus_db/client.ex

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