defmodule TerminusDB.Config do @version Mix.Project.config()[:version] || "0.0.0" @schema [ endpoint: [ type: :string, required: true, doc: "TerminusDB server URL, e.g. `http://localhost:6363`." ], user: [ type: :string, default: "admin", doc: "User name for HTTP Basic auth. Ignored when `:token` is set." ], key: [ type: :string, default: "root", doc: "API key / password for HTTP Basic auth. Ignored when `:token` is set." ], token: [ type: :string, doc: "Bearer token. When set, takes precedence over Basic auth (`:user`/`:key`)." ], organization: [ type: :string, default: "admin", doc: "Organization (team) that owns the database." ], database: [ type: :string, doc: "Current database name. Set with `with_database/2`." ], branch: [ type: :string, default: "main", doc: "Current branch. Set with `with_branch/2`." ], repo: [ type: :string, default: "local", doc: "Repository: `local` or a remote name." ], ref: [ type: :string, doc: "A commit reference for time-travel queries." ], headers: [ type: {:map, :string, :string}, default: %{}, doc: "Extra HTTP headers merged into every request." ], receive_timeout: [ type: :pos_integer, default: 15_000, doc: "Socket receive timeout in milliseconds." ], telemetry: [ type: :boolean, default: true, doc: "Whether to emit `:telemetry` events for operations." ], adapter: [ type: :any, doc: """ A Req adapter function used in place of the network. Intended for testing: `adapter: fn req -> {req, Req.Response.new(status: 200, body: %{})} end`. """ ], user_agent: [ type: :string, default: "terminusdb_ex/#{@version}", doc: "Value of the `user-agent` request header." ] ] # Like @schema but without `required: true` on :endpoint, so `merge/2` can # validate a partial set of overrides against an already-valid config. @merge_schema Keyword.update!(@schema, :endpoint, &Keyword.delete(&1, :required)) @moduledoc """ Immutable connection and resource context for a TerminusDB server. A `Config` carries everything needed to address and authenticate a request: the server `:endpoint`, credentials, and the current resource scope (`:organization`, `:database`, `:branch`, `:repo`, `:ref`). Unlike the official Python client — which holds mutable connection state on an instance — `TerminusDB.Config` is **immutable data**. Scoping operations return *derived* configs (`with_database/2`, `with_branch/2`, …) rather than mutating, which is safe under concurrency and composes naturally with pipelines. ## Options #{NimbleOptions.docs(@schema)} ## Examples iex> config = TerminusDB.Config.new(endpoint: "http://localhost:6363") iex> config.endpoint "http://localhost:6363" iex> config.organization "admin" iex> config = TerminusDB.Config.new(endpoint: "http://localhost:6363", token: "tok_123") iex> TerminusDB.Config.auth(config) {:bearer, "tok_123"} iex> config = TerminusDB.Config.new(endpoint: "http://localhost:6363") iex> TerminusDB.Config.auth(config) {:basic, "admin:root"} """ @type auth :: {:basic, String.t()} | {:bearer, String.t()} | nil @enforce_keys [:endpoint] defstruct [ :endpoint, :token, :user, :key, :organization, :database, :branch, :repo, :ref, :adapter, headers: %{}, receive_timeout: 15_000, telemetry: true, user_agent: "terminusdb_ex/#{@version}" ] @type t :: %__MODULE__{ endpoint: String.t(), token: String.t() | nil, user: String.t(), key: String.t(), organization: String.t(), database: String.t() | nil, branch: String.t(), repo: String.t(), ref: String.t() | nil, adapter: (Req.Request.t() -> {Req.Request.t(), Req.Response.t()}) | nil, headers: %{String.t() => String.t()}, receive_timeout: pos_integer(), telemetry: boolean(), user_agent: String.t() } @doc """ The NimbleOptions schema used to validate `new/1` options. """ @spec schema() :: keyword() def schema, do: @schema @doc """ Builds a new, validated `TerminusDB.Config`. ## Options See the module documentation or `schema/0` for the accepted options. ## Examples iex> %TerminusDB.Config{endpoint: "http://localhost:6363"} = ...> TerminusDB.Config.new(endpoint: "http://localhost:6363") iex> TerminusDB.Config.new(endpoint: "http://localhost:6363", database: "foo").database "foo" Raises `NimbleOptions.ValidationError` on invalid options. iex> TerminusDB.Config.new(endpoint: 123) ** (NimbleOptions.ValidationError) invalid value for :endpoint option: expected string, got: 123 """ @spec new(keyword()) :: t() def new(opts) when is_list(opts) do validated = NimbleOptions.validate!(opts, @schema) struct!(__MODULE__, validated) end @doc """ Returns a copy of `config` with the given fields updated. iex> config = TerminusDB.Config.new(endpoint: "http://localhost:6363") iex> TerminusDB.Config.merge(config, database: "mydb").database "mydb" """ @spec merge(t(), keyword()) :: t() def merge(%__MODULE__{} = config, opts) when is_list(opts) do validated = NimbleOptions.validate!(opts, @merge_schema) struct!(config, validated) end @doc """ Scopes `config` to the given organization. iex> config = TerminusDB.Config.new(endpoint: "http://localhost:6363") iex> TerminusDB.Config.with_organization(config, "acme").organization "acme" """ @spec with_organization(t(), String.t()) :: t() def with_organization(%__MODULE__{} = config, organization) when is_binary(organization) do %{config | organization: organization} end @doc """ Scopes `config` to the given database. iex> config = TerminusDB.Config.new(endpoint: "http://localhost:6363") iex> TerminusDB.Config.with_database(config, "mydb").database "mydb" """ @spec with_database(t(), String.t()) :: t() def with_database(%__MODULE__{} = config, database) when is_binary(database) do %{config | database: database} end @doc """ Scopes `config` to the given branch. iex> config = TerminusDB.Config.new(endpoint: "http://localhost:6363") iex> TerminusDB.Config.with_branch(config, "feature").branch "feature" """ @spec with_branch(t(), String.t()) :: t() def with_branch(%__MODULE__{} = config, branch) when is_binary(branch) do %{config | branch: branch} end @doc """ Scopes `config` to the given repository (`local` or a remote name). """ @spec with_repo(t(), String.t()) :: t() def with_repo(%__MODULE__{} = config, repo) when is_binary(repo) do %{config | repo: repo} end @doc """ Pins `config` to a commit reference for time-travel queries. """ @spec with_ref(t(), String.t()) :: t() def with_ref(%__MODULE__{} = config, ref) when is_binary(ref) do %{config | ref: ref} end @doc """ Returns the Req auth tuple derived from `config`. A `:token` takes precedence (Bearer); otherwise Basic auth is built from `:user` and `:key`. Returns `nil` only if no credentials are usable. ## Examples iex> config = TerminusDB.Config.new(endpoint: "http://localhost:6363", token: "abc") iex> TerminusDB.Config.auth(config) {:bearer, "abc"} iex> config = TerminusDB.Config.new(endpoint: "http://localhost:6363", user: "admin", key: "root") iex> TerminusDB.Config.auth(config) {:basic, "admin:root"} """ @spec auth(t()) :: auth() def auth(%__MODULE__{token: token}) when is_binary(token) and token != "", do: {:bearer, token} def auth(%__MODULE__{user: user, key: key}) when is_binary(user) and is_binary(key), do: {:basic, "#{user}:#{key}"} def auth(%__MODULE__{}), do: nil @doc """ Returns `config` with sensitive fields redacted, suitable for telemetry metadata and logging. Replaces `:key` and `:token` with `"[redacted]"`. iex> config = TerminusDB.Config.new(endpoint: "http://localhost:6363", key: "secret") iex> redacted = TerminusDB.Config.redact(config) iex> redacted.key "[redacted]" iex> redacted.endpoint "http://localhost:6363" """ @spec redact(t()) :: t() def redact(%__MODULE__{key: key, token: token} = config) do %{config | key: redact_value(key), token: redact_value(token)} end defp redact_value(nil), do: nil defp redact_value(_), do: "[redacted]" end