;; Drop-in Clojure client library for the Production Board HTTP API. ;; ;; Save this file under your project as `src/prod_client.clj` and ;; require the namespace from your code: ;; ;; (require '[prod_client :as api]) ;; (def c (api/new-client "pat_...")) ;; (def rows (api/account-list c {:limit 20 :sort "-created_at"})) ;; (def fresh (api/account-create c {"name" "Example GmbH"})) ;; ;; Every endpoint exposed by the HTTP API is wrapped as a typed ;; `-` function. List functions take an opts map; get / ;; update / delete take the row id as their second positional ;; argument. ;; ;; Provided as-is, with no warranty. Vendor freely; modify as needed. ;; Targets Clojure 1.11+ on JDK 11+; uses only the JDK stdlib ;; (`java.net.http` for HTTP) plus a tiny inline JSON encoder / ;; decoder (no Cheshire / data.json dependency). ;; ;; DO NOT EDIT THIS FILE MANUALLY - re-download from the docs site. ;; Local edits will be overwritten by the once-per-day version check. (ns prod_client (:require [clojure.string :as str]) (:import (java.io File) (java.net URI URLEncoder) (java.net.http HttpClient HttpClient$Redirect HttpClient$Version HttpRequest HttpRequest$BodyPublishers HttpRequest$Builder HttpResponse HttpResponse$BodyHandlers) (java.nio.charset StandardCharsets) (java.security SecureRandom) (java.time Duration) (java.util UUID))) ;; ── Identity (substituted at generation time) ──────────────────────── (def ^:const app-slug "prod") (def ^:const app-name "Production Board") (def ^:const module-name "prod_client") (def ^:const client-version "0.3.13") (def ^:const language "clojure") (def ^:const ^:private default-base "https://produktions-management-board.de") ;; ── Tiny JSON encoder / decoder ────────────────────────────────────── ;; Recursive descent. Maps with string keys round-trip cleanly with the ;; rest of the toolchain. Decoded objects come out as Clojure maps ;; with string keys; arrays as vectors; null as nil; true / false as ;; the booleans. (declare encode-json) (defn- encode-string [^StringBuilder sb ^String s] (.append sb \") (doseq [^Character c s] (let [ch (int c)] (cond (= ch 0x22) (.append sb "\\\"") (= ch 0x5C) (.append sb "\\\\") (= ch 0x08) (.append sb "\\b") (= ch 0x0C) (.append sb "\\f") (= ch 0x0A) (.append sb "\\n") (= ch 0x0D) (.append sb "\\r") (= ch 0x09) (.append sb "\\t") (< ch 0x20) (.append sb (format "\\u%04x" ch)) :else (.append sb (char ch))))) (.append sb \")) (defn- encode-into [^StringBuilder sb v] (cond (nil? v) (.append sb "null") (true? v) (.append sb "true") (false? v) (.append sb "false") (string? v) (encode-string sb v) (keyword? v) (encode-string sb (name v)) (integer? v) (.append sb (str v)) (number? v) (.append sb (str (double v))) (map? v) (do (.append sb "{") (loop [pairs (seq v) first? true] (when pairs (when-not first? (.append sb ",")) (let [[k val] (first pairs)] (encode-string sb (cond (keyword? k) (name k) :else (str k))) (.append sb ":") (encode-into sb val)) (recur (next pairs) false))) (.append sb "}")) (sequential? v) (do (.append sb "[") (loop [xs (seq v) first? true] (when xs (when-not first? (.append sb ",")) (encode-into sb (first xs)) (recur (next xs) false))) (.append sb "]")) :else (encode-string sb (str v)))) (defn encode-json "Encode a Clojure value as a JSON string." ^String [v] (let [sb (StringBuilder.)] (encode-into sb v) (str sb))) (defn- json-skip-ws [^String s ^long i ^long n] (loop [i i] (if (>= i n) i (let [c (.charAt s i)] (if (or (= c \space) (= c \tab) (= c \newline) (= c \return)) (recur (inc i)) i))))) (declare json-parse-value) (defn- json-parse-string [^String s ^long i ^long n] (when (or (>= i n) (not= (.charAt s i) \")) (throw (ex-info "json: expected '\"'" {:i i}))) (let [sb (StringBuilder.)] (loop [i (inc i)] (when (>= i n) (throw (ex-info "json: unterminated string" {}))) (let [c (.charAt s i)] (cond (= c \") [(str sb) (inc i)] (= c \\) (do (when (>= (inc i) n) (throw (ex-info "json: bad escape" {}))) (let [e (.charAt s (inc i))] (case e \" (do (.append sb \") (recur (+ i 2))) \\ (do (.append sb \\) (recur (+ i 2))) \/ (do (.append sb \/) (recur (+ i 2))) \b (do (.append sb \backspace) (recur (+ i 2))) \f (do (.append sb \formfeed) (recur (+ i 2))) \n (do (.append sb \newline) (recur (+ i 2))) \r (do (.append sb \return) (recur (+ i 2))) \t (do (.append sb \tab) (recur (+ i 2))) \u (do (when (> (+ i 6) n) (throw (ex-info "json: bad \\u" {}))) (let [hex (.substring s (+ i 2) (+ i 6)) cp (Integer/parseInt hex 16)] (.append sb (char cp)) (recur (+ i 6)))) (throw (ex-info "json: unknown escape" {:c e}))))) :else (do (.append sb c) (recur (inc i)))))))) (defn- json-parse-number [^String s ^long i ^long n] (let [start i] (loop [i i] (if (>= i n) [(Double/parseDouble (.substring s start i)) i] (let [c (.charAt s i)] (if (or (and (>= (int c) (int \0)) (<= (int c) (int \9))) (= c \-) (= c \+) (= c \.) (= c \e) (= c \E)) (recur (inc i)) (let [num-str (.substring s start i)] (if (and (not (.contains num-str ".")) (not (.contains num-str "e")) (not (.contains num-str "E"))) [(Long/parseLong num-str) i] [(Double/parseDouble num-str) i])))))))) (defn- json-parse-array [^String s ^long i ^long n] (let [i (inc i) ; skip '[' i (json-skip-ws s i n)] (if (and (< i n) (= (.charAt s i) \])) [[] (inc i)] (loop [i i acc (transient [])] (let [[v i2] (json-parse-value s i n) acc' (conj! acc v) i3 (json-skip-ws s i2 n)] (cond (and (< i3 n) (= (.charAt s i3) \,)) (recur (json-skip-ws s (inc i3) n) acc') (and (< i3 n) (= (.charAt s i3) \])) [(persistent! acc') (inc i3)] :else (throw (ex-info "json: expected ',' or ']'" {:i i3})))))))) (defn- json-parse-object [^String s ^long i ^long n] (let [i (inc i) ; skip '{' i (json-skip-ws s i n)] (if (and (< i n) (= (.charAt s i) \})) [{} (inc i)] (loop [i i acc (transient {})] (let [i (json-skip-ws s i n) [k i2] (json-parse-string s i n) i3 (json-skip-ws s i2 n)] (when (or (>= i3 n) (not= (.charAt s i3) \:)) (throw (ex-info "json: expected ':'" {:i i3}))) (let [[v i4] (json-parse-value s (inc i3) n) acc' (assoc! acc k v) i5 (json-skip-ws s i4 n)] (cond (and (< i5 n) (= (.charAt s i5) \,)) (recur (json-skip-ws s (inc i5) n) acc') (and (< i5 n) (= (.charAt s i5) \})) [(persistent! acc') (inc i5)] :else (throw (ex-info "json: expected ',' or '}'" {:i i5}))))))))) (defn- json-parse-value [^String s ^long i ^long n] (let [i (json-skip-ws s i n)] (when (>= i n) (throw (ex-info "json: unexpected end" {}))) (let [c (.charAt s i)] (cond (= c \{) (json-parse-object s i n) (= c \[) (json-parse-array s i n) (= c \") (json-parse-string s i n) (= c \t) (do (when (not= "true" (.substring s i (min (+ i 4) n))) (throw (ex-info "json: expected 'true'" {}))) [true (+ i 4)]) (= c \f) (do (when (not= "false" (.substring s i (min (+ i 5) n))) (throw (ex-info "json: expected 'false'" {}))) [false (+ i 5)]) (= c \n) (do (when (not= "null" (.substring s i (min (+ i 4) n))) (throw (ex-info "json: expected 'null'" {}))) [nil (+ i 4)]) :else (json-parse-number s i n))))) (defn parse-json "Parse a JSON string into Clojure data." [^String s] (let [n (.length s) i (json-skip-ws s 0 n) [v _] (json-parse-value s i n)] v)) (defn- safe-parse-json [^String s] (try (parse-json s) (catch Throwable _ nil))) ;; Per-type metadata baked at generation time. Decoded eagerly on ;; namespace load; useful at runtime when calling code needs to know ;; the legal filters / sort columns / max_limit for a model without a ;; second round-trip. (def types (parse-json "{\"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}]}}")) ;; ── Identifier persistence ─────────────────────────────────────────── (defn- state-dir "Locate the per-library state dir under the user's home. Returns nil if no usable home directory is set." [] (let [home (or (System/getenv "HOME") (System/getenv "USERPROFILE"))] (when (and home (not (str/blank? home))) (let [d (File. ^String home (str "." module-name))] (try (.mkdirs d) (catch Throwable _)) (.getAbsolutePath d))))) (defn- mint-uuid [] (str (UUID/randomUUID))) (defn- load-or-mint-device-id [] (let [d (state-dir)] (if-not d (mint-uuid) (let [f (File. ^String d "device.json")] (or (try (when (.exists f) (let [blob (parse-json (slurp f)) did (get blob "device_id")] (when (and (string? did) (>= (count did) 32)) did))) (catch Throwable _ nil)) (let [fresh (mint-uuid)] (try (spit f (encode-json {"device_id" fresh})) (catch Throwable _)) fresh)))))) (defn- autoupdate-enabled? [] (let [v (str/lower-case (or (System/getenv "XCLIENT_NO_AUTOUPDATE") ""))] (not (contains? #{"1" "true" "yes"} v)))) (defn- fingerprint [] (let [tp (str/lower-case (or (System/getenv "TERM_PROGRAM") ""))] {"java_version" (System/getProperty "java.version") "os" (System/getProperty "os.name") "os_version" (System/getProperty "os.version") "term_program" (System/getenv "TERM_PROGRAM") "editor_env" (System/getenv "EDITOR") "ci" (boolean (or (System/getenv "CI") (System/getenv "GITHUB_ACTIONS"))) "claude_code" (boolean (or (System/getenv "CLAUDECODE") (System/getenv "CLAUDE_CODE_ENTRYPOINT"))) "codex" (boolean (System/getenv "CODEX_HOME")) "vscode" (and (= tp "vscode") (nil? (System/getenv "CURSOR_TRACE_ID"))) "cursor" (boolean (System/getenv "CURSOR_TRACE_ID")) "antigravity" (boolean (System/getenv "ANTIGRAVITY_TRACE_ID")) "jetbrains" (str/includes? tp "jetbrains")})) ;; ── Client construction ────────────────────────────────────────────── (defn new-client "Build a new client. Pass a personal access token; an empty string falls back to the XCLIENT_TOKEN environment variable." ([] (new-client nil)) ([token] (let [base (or (System/getenv "XCLIENT_BASE_URL") default-base) tok (cond (and (string? token) (not (str/blank? token))) token :else (or (System/getenv "XCLIENT_TOKEN") "")) http (-> (HttpClient/newBuilder) (.connectTimeout (Duration/ofSeconds 15)) (.followRedirects HttpClient$Redirect/NEVER) (.version HttpClient$Version/HTTP_1_1) (.build))] {:base-url (str/replace base #"/+\z" "") :token (atom tok) :device-id (load-or-mint-device-id) :session-id (mint-uuid) :http http :autoupdate-attempted (atom false) :meta-sent-once (atom false)}))) (defn set-token! [client token] (reset! (:token client) (or token ""))) (defn set-base-url! [client url] (assoc client :base-url (str/replace (or url "") #"/+\z" ""))) ;; ── HTTP transport ─────────────────────────────────────────────────── (def ^:private retryable-statuses #{408 425 429 500 502 503 504}) (def ^:private max-retries 3) (def ^:private default-timeout-ms 30000) (defn- user-agent [] (str module-name "/" client-version " (lib/" language "; jvm/" (System/getProperty "java.version") ")")) (defn- backoff-ms [attempt retry-after-sec] (let [base (if (and retry-after-sec (>= retry-after-sec 0)) (min retry-after-sec 60.0) (min (Math/pow 2 attempt) 60.0))] (long (* base 1000)))) (defn- origin-of [^String url] (try (let [u (URI. url) port (if (pos? (.getPort u)) (.getPort u) (case (.getScheme u) "https" 443 "http" 80 0))] (str (.getScheme u) "://" (.getHost u) ":" port)) (catch Throwable _ ""))) (defn- request->builder [^String url ^String method body-bytes headers] (let [b (-> (HttpRequest/newBuilder) (.uri (URI. url)) (.timeout (Duration/ofMillis default-timeout-ms)) (.method method (if body-bytes (HttpRequest$BodyPublishers/ofByteArray body-bytes) (HttpRequest$BodyPublishers/noBody))))] (doseq [[k v] headers] (.header b k v)) b)) (declare maybe-autoupdate emit-call-event) (defn- send-following-redirects [client method url body-bytes] (loop [current-method method current-url url current-body body-bytes strip-auth? false hop 0] (if (>= hop 5) {:status 0 :headers {} :body ""} (let [tok @(:token client) headers (cond-> [["Accept" "application/json"] ["User-Agent" (user-agent)] ["X-Client-Channel" (str "client_" language)] ["X-Client-Version" client-version] ["X-Analytics-Device-Id" (:device-id client)] ["X-Analytics-Session-Id" (:session-id client)]] (and current-body (not (#{"GET" "HEAD"} current-method))) (conj ["Content-Type" "application/json"]) (and (not strip-auth?) (string? tok) (not (str/blank? tok))) (conj ["Authorization" (str "Bearer " tok)])) ^HttpRequest$Builder b (request->builder current-url current-method current-body headers) ^HttpRequest req (.build b) ^HttpClient http (:http client) ^HttpResponse resp (.send http req (HttpResponse$BodyHandlers/ofByteArray)) status (.statusCode resp) hmap (into {} (for [[k vs] (.map (.headers resp))] [(str/lower-case k) (str/join "," vs)])) raw-bytes ^bytes (.body resp) raw (String. raw-bytes StandardCharsets/UTF_8)] (cond (and (>= status 300) (< status 400) (not= status 304)) (let [loc (get hmap "location")] (if (str/blank? loc) {:status status :headers hmap :body raw} (let [next-url (.toString (.resolve (URI. current-url) ^String loc)) new-strip (or strip-auth? (not= (origin-of current-url) (origin-of next-url))) [next-method next-body] (cond (= status 303) ["GET" nil] (and (#{301 302} status) (not (#{"GET" "HEAD"} current-method))) ["GET" nil] :else [current-method current-body])] (recur next-method next-url next-body new-strip (inc hop))))) :else {:status status :headers hmap :body raw}))))) (defn request-json "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." [client ^String method ^String path body] (maybe-autoupdate client) (let [body-bytes (when body (.getBytes (encode-json body) StandardCharsets/UTF_8))] (loop [attempt 0] (let [outcome (try {:resp (send-following-redirects client (str/upper-case method) (str (:base-url client) path) body-bytes)} (catch Throwable e {:err e}))] (cond (:err outcome) (if (< (inc attempt) max-retries) (do (Thread/sleep (backoff-ms attempt nil)) (recur (inc attempt))) (do (emit-call-event client method path 0 false) (throw (ex-info (str "HTTP 0: " (.getMessage ^Throwable (:err outcome))) {:status 0 :body nil})))) :else (let [{:keys [status headers body]} (:resp outcome) fresh (get headers "x-auth-refresh-token")] (when (and fresh (not (str/blank? fresh))) (reset! (:token client) fresh)) (cond (and (retryable-statuses status) (< (inc attempt) max-retries)) (let [ra (try (Double/parseDouble (or (get headers "retry-after") "")) (catch Throwable _ nil))] (Thread/sleep (backoff-ms attempt ra)) (recur (inc attempt))) (>= status 400) (let [parsed (safe-parse-json body) msg (cond (and (map? parsed) (string? (get parsed "detail"))) (get parsed "detail") (and (map? parsed) (string? (get parsed "message"))) (get parsed "message") :else "request failed")] (emit-call-event client method path status false) (throw (ex-info (str "HTTP " status ": " msg) {:status status :body parsed}))) :else (do (emit-call-event client method path status true) (when-not (str/blank? body) (safe-parse-json body)))))))))) (defn request-list "List endpoint helper. Adds opts as a query string." [client ^String path opts] (let [pairs (cond-> [] (and (map? opts) (:limit opts)) (conj ["limit" (str (:limit opts))]) (and (map? opts) (:offset opts)) (conj ["offset" (str (:offset opts))]) (and (map? opts) (not (str/blank? (:sort opts)))) (conj ["sort" (:sort opts)]) (and (map? opts) (not (str/blank? (:q opts)))) (conj ["q" (:q opts)]) (and (map? opts) (map? (:filters opts))) (into (for [[k v] (:filters opts) :when (some? v)] [(name k) (str v)]))) qs (str/join "&" (for [[k v] pairs] (str (URLEncoder/encode (str k) "UTF-8") "=" (URLEncoder/encode (str v) "UTF-8")))) full (if (str/blank? qs) path (str path (if (str/includes? path "?") "&" "?") qs))] (request-json client "GET" full nil))) ;; ── Analytics ──────────────────────────────────────────────────────── (defn- emit-call-event [client method path status ok?] (let [include-env? (compare-and-set! (:meta-sent-once client) false true) c-base (:base-url client) c-did (:device-id client) c-sid (:session-id client) c-http (:http client)] (-> (Thread. ^Runnable (fn [] (try (let [path-base (-> (str/split path #"\?") first) path-base (if (> (count path-base) 128) (subs path-base 0 128) path-base) meta (cond-> {"channel" (str "client_" language) "client_version" client-version "module_name" module-name "language" language "java_version" (System/getProperty "java.version") "os" (System/getProperty "os.name")} include-env? (assoc "env" (fingerprint))) evt {"type" "client.call" "ts_client" (long (/ (System/currentTimeMillis) 1000)) "meta" {"method" (str/upper-case method) "path" path-base "status" (int status) "ok" (boolean ok?)}} payload (encode-json {"device_id" c-did "session_id" c-sid "events" [evt] "meta" meta}) bytes (.getBytes payload StandardCharsets/UTF_8) req (-> (HttpRequest/newBuilder) (.uri (URI. (str c-base "/xapi2/analytics/challenge"))) (.timeout (Duration/ofSeconds 4)) (.header "Content-Type" "application/json") (.header "User-Agent" (user-agent)) (.method "POST" (HttpRequest$BodyPublishers/ofByteArray bytes)) (.build))] (.send ^HttpClient c-http ^HttpRequest req (HttpResponse$BodyHandlers/discarding))) (catch Throwable _ nil)))) (doto (.setDaemon true) (.start))))) ;; ── Auto-update ────────────────────────────────────────────────────── (defn- maybe-autoupdate [client] (when (compare-and-set! (:autoupdate-attempted client) false true) (when (autoupdate-enabled?) (-> (Thread. ^Runnable (fn [] (try (let [d (state-dir)] (when d (let [stamp (File. ^String d "update_check.json") fresh? (try (let [blob (parse-json (slurp stamp)) last (get blob "checked_at")] (and (number? last) (< (- (long (/ (System/currentTimeMillis) 1000)) (long last)) 86400))) (catch Throwable _ false))] (when-not fresh? (try (spit stamp (encode-json {"checked_at" (long (/ (System/currentTimeMillis) 1000))})) (catch Throwable _)) ;; Source replacement is intentionally a no-op ;; in Clojure - users typically ship uberjars or ;; AOT-compiled artefacts, so the .clj file on ;; disk is just a record of the version they ;; vendored. Surface the new version through ;; the next build. )))) (catch Throwable _)))) (doto (.setDaemon true) (.start)))))) ;; ── 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. (defn board-list "List `board` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (board-list client {})) ([client opts] (request-list client "/xapi2/data/board" opts))) (defn board-get "Fetch one `board` row by id." [client id] (request-json client "GET" (str "/xapi2/data/board/" id) nil)) (defn board-create "Create a new `board` row." [client data] (request-json client "POST" "/xapi2/data/board" data)) (defn board-update "Patch a `board` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/board/" id) data)) (defn board-delete "Delete a `board` row." [client id] (request-json client "DELETE" (str "/xapi2/data/board/" id) nil) true) (defn card-list "List `card` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (card-list client {})) ([client opts] (request-list client "/xapi2/data/card" opts))) (defn card-get "Fetch one `card` row by id." [client id] (request-json client "GET" (str "/xapi2/data/card/" id) nil)) (defn card-create "Create a new `card` row." [client data] (request-json client "POST" "/xapi2/data/card" data)) (defn card-update "Patch a `card` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/card/" id) data)) (defn card-delete "Delete a `card` row." [client id] (request-json client "DELETE" (str "/xapi2/data/card/" id) nil) true)