# Drop-in Elixir client library for the Production Board HTTP API. # # Save this file under your project as `lib/prod_client.ex` and use # the ProdClient module: # # client = ProdClient.new("pat_...") # {:ok, rows} = ProdClient.account_list(client, sort: "-created_at") # {:ok, fresh} = ProdClient.account_create(client, %{"name" => "Example GmbH"}) # # Every endpoint exposed by the HTTP API is wrapped as a typed # `_/N` function on ProdClient. List functions take an # optional keyword list; get/update/delete take the row id as the # second argument (after the client). # # Provided as-is, with no warranty. Vendor freely; modify as needed. # Targets Elixir 1.18+ + OTP 26+; uses only stdlib (`:httpc`, `:inets`, # `:ssl`, `:rand`, `JSON`). # # DO NOT EDIT THIS FILE MANUALLY - re-download from the docs site. # Local edits will be overwritten by the once-per-day version check. defmodule ProdClient do @moduledoc """ Drop-in HTTP client for the Production Board API. """ import Bitwise @app_slug "prod" @app_name "Production Board" @module_name "prod_client" @client_version "0.3.12" @language "elixir" @default_base "https://qtssystem.com" # Per-type metadata baked at generation time. Decoded lazily by # `types/0`; useful at runtime when calling code needs to know the # legal filters / sort columns / max_limit for a model without a # second round-trip. @types_json ~S""" {"board":{"ops":["list","read","create","update","delete"],"create_fields":["name","description","accent","settings","tags","columns"],"update_fields":["name","description","accent","settings","tags","columns"],"allowed_filters":["data__name","data__accent","data__tags","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__name"],"default_sort":"created_at","max_limit":50,"fields":[{"name":"name","type":"string","max_len":200},{"name":"tags","type":"tags"},{"name":"accent","type":"enum","values":["slate","gray","blue","indigo","violet","fuchsia","amber","orange","emerald","green","rose","red"]},{"name":"settings","type":"dict"},{"name":"description","type":"string","max_len":2000}]},"card":{"ops":["list","read","create","update","delete"],"create_fields":["title","description","status","position","priority","tags","assignee","due_date","board_id"],"update_fields":["title","description","status","position","priority","tags","assignee","due_date","board_id"],"allowed_filters":["data__status","data__priority","data__tags","data__assignee","data__board_id","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__position","data__status","data__priority","data__due_date"],"default_sort":"data__position","max_limit":200,"fields":[{"name":"tags","type":"tags"},{"name":"title","type":"string","max_len":200},{"name":"status","type":"string","max_len":64},{"name":"assignee","type":"string","max_len":64},{"name":"board_id","type":"string","max_len":64,"ref":{"type":"board","owned":true,"optional":true}},{"name":"due_date","type":"string","max_len":32},{"name":"position","type":"number"},{"name":"priority","type":"enum","values":["low","medium","high","critical"]},{"name":"description","type":"string","max_len":4000}]}} """ @retryable_statuses [408, 425, 429, 500, 502, 503, 504] @max_retries 3 @default_timeout 30_000 defmodule ApiError do defexception [:status, :message, :body] @impl true def message(%__MODULE__{status: s, message: m}), do: "HTTP #{s}: #{m}" end defstruct [ :base_url, :token, :device_id, :session_id ] @type t :: %__MODULE__{ base_url: String.t(), token: String.t(), device_id: String.t(), session_id: String.t() } @doc "Decode the per-type metadata baked at generation time." def types do JSON.decode!(String.trim(@types_json)) end @doc "Tenant slug this library was generated against." def app_slug, do: @app_slug @doc "Human-readable app name this library was generated against." def app_name, do: @app_name @doc """ Build a new client. Pass a personal access token; an empty string falls back to the `XCLIENT_TOKEN` environment variable. """ @spec new(String.t() | nil) :: t() def new(token \\ nil) do {:ok, _} = :application.ensure_all_started(:inets) {:ok, _} = :application.ensure_all_started(:ssl) base = (System.get_env("XCLIENT_BASE_URL") || @default_base) |> trim_right_slash() tok = cond do is_binary(token) and token != "" -> token true -> System.get_env("XCLIENT_TOKEN") || "" end %__MODULE__{ base_url: base, token: tok, device_id: load_or_mint_device_id(), session_id: mint_uuid() } end @doc "Override the bearer token on an existing client." def set_token(%__MODULE__{} = c, token), do: %{c | token: token || ""} @doc "Override the base URL on an existing client." def set_base_url(%__MODULE__{} = c, url), do: %{c | base_url: trim_right_slash(url || "")} # ── Identifier persistence ─────────────────────────────────────────── defp state_dir do home = System.get_env("HOME") || System.get_env("USERPROFILE") cond do is_nil(home) or home == "" -> nil true -> d = Path.join(home, "." <> @module_name) case File.mkdir_p(d) do :ok -> d _ -> nil end end end defp mint_uuid do <> = :crypto.strong_rand_bytes(16) c2 = (c &&& 0x0FFF) ||| 0x4000 d2 = (d &&& 0x3FFF) ||| 0x8000 :io_lib.format("~8.16.0b-~4.16.0b-~4.16.0b-~4.16.0b-~12.16.0b", [a, b, c2, d2, e]) |> IO.iodata_to_binary() end defp load_or_mint_device_id do case state_dir() do nil -> mint_uuid() d -> f = Path.join(d, "device.json") case File.read(f) do {:ok, raw} -> case safe_decode(raw) do %{"device_id" => did} when is_binary(did) and byte_size(did) >= 32 -> did _ -> persist_fresh_device_id(f) end _ -> persist_fresh_device_id(f) end end end defp persist_fresh_device_id(f) do fresh = mint_uuid() _ = File.write(f, JSON.encode!(%{"device_id" => fresh})) fresh end defp safe_decode(raw) do try do JSON.decode!(raw) rescue _ -> nil end end defp autoupdate_enabled? do String.downcase(System.get_env("XCLIENT_NO_AUTOUPDATE") || "") not in ["1", "true", "yes"] end # ── Editor / runtime fingerprint ───────────────────────────────────── defp fingerprint do env = System.get_env() tp = String.downcase(env["TERM_PROGRAM"] || "") %{ "elixir_version" => System.version(), "otp_version" => :erlang.system_info(:otp_release) |> List.to_string(), "os" => to_string(:erlang.system_info(:system_architecture)), "term_program" => env["TERM_PROGRAM"], "editor_env" => env["EDITOR"], "ci" => not is_nil(env["CI"]) or not is_nil(env["GITHUB_ACTIONS"]), "claude_code" => not is_nil(env["CLAUDECODE"]) or not is_nil(env["CLAUDE_CODE_ENTRYPOINT"]), "codex" => not is_nil(env["CODEX_HOME"]), "vscode" => tp == "vscode" and is_nil(env["CURSOR_TRACE_ID"]), "cursor" => not is_nil(env["CURSOR_TRACE_ID"]), "antigravity" => not is_nil(env["ANTIGRAVITY_TRACE_ID"]), "jetbrains" => String.contains?(tp, "jetbrains") } end # ── HTTP transport ─────────────────────────────────────────────────── defp user_agent do "#{@module_name}/#{@client_version} (lib/#{@language}; elixir/#{System.version()})" end defp backoff_seconds(attempt, retry_after) do cond do is_number(retry_after) and retry_after >= 0 -> min(retry_after, 60.0) true -> min(:math.pow(2, attempt), 60.0) end end @doc """ Generic transport. Per-type wrappers forward through here. JSON in / JSON out; pass `nil` body for read-only verbs. Retries on 408/425/429/5xx + transport errors with exponential backoff. """ def request_json(%__MODULE__{} = c, method, path, body) do maybe_autoupdate(c) do_request(c, method, path, body, 0) end @doc "List wrapper. Adds the keyword opts as query string." def request_list(%__MODULE__{} = c, path, opts) when is_list(opts) or is_map(opts) do qs = build_qs(opts) sep = if String.contains?(path, "?"), do: "&", else: "?" full = if qs == "", do: path, else: path <> sep <> qs request_json(c, "GET", full, nil) end defp build_qs(opts) do opts |> Enum.flat_map(fn {:filters, m} when is_map(m) -> Enum.map(m, fn {fk, fv} -> encode_pair(to_string(fk), value_to_string(fv)) end) {k, v} -> [encode_pair(to_string(k), value_to_string(v))] end) |> Enum.reject(&is_nil/1) |> Enum.join("&") end defp encode_pair(_k, nil), do: nil defp encode_pair(k, v), do: URI.encode_www_form(k) <> "=" <> URI.encode_www_form(v) defp value_to_string(nil), do: nil defp value_to_string(v) when is_binary(v), do: v defp value_to_string(v) when is_atom(v), do: Atom.to_string(v) defp value_to_string(v) when is_integer(v), do: Integer.to_string(v) defp value_to_string(v) when is_float(v), do: Float.to_string(v) defp value_to_string(true), do: "true" defp value_to_string(false), do: "false" defp value_to_string(v), do: inspect(v) defp do_request(c, method, path, body, attempt) do url = c.base_url <> path json_body = if body == nil, do: nil, else: JSON.encode!(body) case send_following_redirects(c, method, url, json_body, false, 0) do {:ok, status, headers, _raw} when status in @retryable_statuses and attempt + 1 < @max_retries -> ra = parse_retry_after(headers) :timer.sleep(round(backoff_seconds(attempt, ra) * 1000)) do_request(c, method, path, body, attempt + 1) {:ok, status, _headers, raw} when status >= 400 -> parsed = safe_decode(raw) msg = case parsed do %{"detail" => d} when is_binary(d) -> d %{"message" => m} when is_binary(m) -> m _ -> "request failed" end emit_call_event(c, method, path, status, false) raise ApiError, status: status, message: msg, body: parsed {:ok, status, _headers, ""} -> emit_call_event(c, method, path, status, true) {:ok, nil} {:ok, status, _headers, raw} -> parsed = safe_decode(raw) emit_call_event(c, method, path, status, true) {:ok, parsed} {:error, _reason} when attempt + 1 < @max_retries -> :timer.sleep(round(backoff_seconds(attempt, nil) * 1000)) do_request(c, method, path, body, attempt + 1) {:error, reason} -> emit_call_event(c, method, path, 0, false) raise ApiError, status: 0, message: inspect(reason), body: nil end end defp parse_retry_after(headers) do case List.keyfind(headers, ~c"retry-after", 0) do {_, v} -> case Float.parse(List.to_string(v)) do {f, _} -> f _ -> nil end _ -> nil end end # Walk the redirect chain manually so Authorization can be dropped on # cross-origin hops. Caps at 5 hops; mirrors RFC 7231 method-rewrite # semantics. defp send_following_redirects(_c, _m, _u, _b, _strip, hop) when hop >= 5 do {:ok, 0, [], ""} end defp send_following_redirects(c, method, url, body, strip_auth, hop) do headers = build_headers(c, strip_auth) request = cond do body == nil -> {String.to_charlist(url), headers} true -> {String.to_charlist(url), headers, ~c"application/json", body} end method_atom = case String.upcase(method) do "GET" -> :get "POST" -> :post "PATCH" -> :patch "PUT" -> :put "DELETE" -> :delete "HEAD" -> :head _ -> :get end http_options = [timeout: @default_timeout, connect_timeout: 15_000, autoredirect: false] options = [body_format: :binary] case :httpc.request(method_atom, request, http_options, options) do {:ok, {{_, status, _}, hdrs, raw}} -> hmap = Enum.map(hdrs, fn {k, v} -> {k, v} end) cond do status >= 300 and status < 400 and status != 304 -> case List.keyfind(hmap, ~c"location", 0) do {_, loc} -> next_url = List.to_string(loc) strip = strip_auth or origin_of(next_url) != origin_of(url) {next_method, next_body} = cond do status == 303 -> {"GET", nil} status in [301, 302] and method not in ["GET", "HEAD"] -> {"GET", nil} true -> {method, body} end send_following_redirects(c, next_method, next_url, next_body, strip, hop + 1) _ -> {:ok, status, hmap, IO.iodata_to_binary(raw)} end true -> {:ok, status, hmap, IO.iodata_to_binary(raw)} end {:error, reason} -> {:error, reason} end end defp build_headers(c, strip_auth) do base = [ {~c"accept", ~c"application/json"}, {~c"user-agent", String.to_charlist(user_agent())}, {~c"x-client-channel", String.to_charlist("client_" <> @language)}, {~c"x-client-version", String.to_charlist(@client_version)}, {~c"x-analytics-device-id", String.to_charlist(c.device_id)}, {~c"x-analytics-session-id", String.to_charlist(c.session_id)} ] if strip_auth or c.token == "" do base else [{~c"authorization", String.to_charlist("Bearer " <> c.token)} | base] end end defp origin_of(url) do uri = URI.parse(url) "#{uri.scheme}://#{uri.host}:#{uri.port || default_port(uri.scheme)}" end defp default_port("https"), do: 443 defp default_port("http"), do: 80 defp default_port(_), do: 0 # ── Analytics ──────────────────────────────────────────────────────── defp emit_call_event(c, method, path, status, ok) do include_env = process_flag_once(:meta_sent_once) Task.start(fn -> try do path_base = path |> String.split("?") |> List.first() path_base = if String.length(path_base) > 128, do: binary_part(path_base, 0, 128), else: path_base meta = %{ "channel" => "client_" <> @language, "client_version" => @client_version, "module_name" => @module_name, "language" => @language, "elixir_version" => System.version(), "otp_version" => List.to_string(:erlang.system_info(:otp_release)) } meta = if include_env, do: Map.put(meta, "env", fingerprint()), else: meta evt = %{ "type" => "client.call", "ts_client" => System.system_time(:second), "meta" => %{ "method" => String.upcase(method), "path" => path_base, "status" => status, "ok" => ok } } body = JSON.encode!(%{ "device_id" => c.device_id, "session_id" => c.session_id, "events" => [evt], "meta" => meta }) url = String.to_charlist(c.base_url <> "/xapi2/analytics/track") headers = [ {~c"user-agent", String.to_charlist(user_agent())} ] :httpc.request(:post, {url, headers, ~c"application/json", body}, [timeout: 4_000, connect_timeout: 2_000], [body_format: :binary]) rescue _ -> :ok catch _, _ -> :ok end end) end # Returns true the first time it's called per-process for a given key. defp process_flag_once(key) do case Process.get({__MODULE__, key}) do true -> false _ -> Process.put({__MODULE__, key}, true) true end end # ── Auto-update ────────────────────────────────────────────────────── defp maybe_autoupdate(c) do case process_flag_once(:autoupdate_attempted) do false -> :ok true -> if autoupdate_enabled?() do Task.start(fn -> run_autoupdate(c) end) end :ok end end defp run_autoupdate(c) do try do do_run_autoupdate(c) rescue _ -> :ok catch _, _ -> :ok end end defp do_run_autoupdate(c) do case state_dir() do nil -> :ok d -> stamp = Path.join(d, "update_check.json") if check_due?(stamp) do _ = File.write(stamp, JSON.encode!(%{"checked_at" => System.system_time(:second)})) probe_and_maybe_replace(c) end end end defp check_due?(stamp) do case File.read(stamp) do {:ok, raw} -> case safe_decode(raw) do %{"checked_at" => last} when is_integer(last) -> System.system_time(:second) - last >= 86_400 _ -> true end _ -> true end end defp probe_and_maybe_replace(c) do url = String.to_charlist(c.base_url <> "/xapi2/clients/version") case :httpc.request(:get, {url, []}, [timeout: 4_000, connect_timeout: 2_000], [body_format: :binary]) do {:ok, {{_, 200, _}, _, raw}} -> case safe_decode(IO.iodata_to_binary(raw)) do %{"version" => latest} when is_binary(latest) and latest != @client_version -> fetch_and_replace(c, latest) _ -> :ok end _ -> :ok end end defp fetch_and_replace(c, _latest) do url = String.to_charlist(c.base_url <> "/xapi2/clients/script." <> @language) case :httpc.request(:get, {url, []}, [timeout: 10_000, connect_timeout: 2_000], [body_format: :binary]) do {:ok, {{_, 200, _}, _, raw}} -> body = IO.iodata_to_binary(raw) if looks_like_valid_client?(body) do target = __ENV__.file if is_binary(target) and File.exists?(target) do tmp = target <> ".tmp." <> Integer.to_string(System.system_time(:nanosecond)) case File.write(tmp, body) do :ok -> case File.rename(tmp, target) do :ok -> :ok _ -> _ = File.rm(tmp); :ok end _ -> :ok end end end _ -> :ok end end defp looks_like_valid_client?(body) when is_binary(body) and byte_size(body) > 2_000 do Enum.all?(["@module_name", "@client_version", "@app_slug", "request_json"], fn m -> String.contains?(body, m) end) end defp looks_like_valid_client?(_), do: false defp trim_right_slash(s) do s |> String.trim_trailing("/") |> String.trim_trailing("/") end # ── Generated per-type wrapper functions ───────────────────────────── # Every model that exposes an op gets one `_` function # below. The runtime above does the heavy lifting; these wrappers # just pin the URL + HTTP verb. @doc "List `board` rows. Pass any allowed filter as a keyword." def board_list(client, opts \\ []) do request_list(client, "/xapi2/data/board", opts) end @doc "Fetch one `board` row by id." def board_get(client, id) do request_json(client, "GET", "/xapi2/data/board/" <> id, nil) end @doc "Create a new `board` row." def board_create(client, data) do request_json(client, "POST", "/xapi2/data/board", data) end @doc "Patch a `board` row." def board_update(client, id, data) do request_json(client, "PATCH", "/xapi2/data/board/" <> id, data) end @doc "Delete a `board` row." def board_delete(client, id) do _ = request_json(client, "DELETE", "/xapi2/data/board/" <> id, nil) true end @doc "List `card` rows. Pass any allowed filter as a keyword." def card_list(client, opts \\ []) do request_list(client, "/xapi2/data/card", opts) end @doc "Fetch one `card` row by id." def card_get(client, id) do request_json(client, "GET", "/xapi2/data/card/" <> id, nil) end @doc "Create a new `card` row." def card_create(client, data) do request_json(client, "POST", "/xapi2/data/card", data) end @doc "Patch a `card` row." def card_update(client, id, data) do request_json(client, "PATCH", "/xapi2/data/card/" <> id, data) end @doc "Delete a `card` row." def card_delete(client, id) do _ = request_json(client, "DELETE", "/xapi2/data/card/" <> id, nil) true end end