;; Drop-in Clojure client library for the Friendship Tracker HTTP API. ;; ;; Save this file under your project as `src/friendship_client.clj` and ;; require the namespace from your code: ;; ;; (require '[friendship_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 friendship_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 "friendship") (def ^:const app-name "Friendship Tracker") (def ^:const module-name "friendship_client") (def ^:const client-version "0.3.13") (def ^:const language "clojure") (def ^:const ^:private default-base "https://friendship-tracker.com") ;; ── 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 "{\"activity\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"kind\",\"summary\",\"description\",\"occurred_at\",\"location\"],\"update_fields\":[\"kind\",\"summary\",\"description\",\"occurred_at\",\"location\"],\"allowed_filters\":[\"data__parent_id\",\"data__kind\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__occurred_at\"],\"default_sort\":\"data__occurred_at\",\"max_limit\":200,\"fields\":[{\"name\":\"kind\",\"type\":\"enum\",\"values\":[\"meeting\",\"call\",\"email\",\"message\",\"event\",\"other\"]},{\"name\":\"summary\",\"type\":\"string\",\"max_len\":400},{\"name\":\"location\",\"type\":\"string\",\"max_len\":200},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"description\",\"type\":\"string\",\"max_len\":4000},{\"name\":\"occurred_at\",\"type\":\"string\",\"max_len\":32}]},\"contact\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"name\",\"nickname\",\"pronouns\",\"email\",\"phone\",\"secondary_email\",\"secondary_phone\",\"company\",\"job_title\",\"address_line\",\"city\",\"country\",\"website\",\"linkedin\",\"twitter\",\"birthday\",\"anniversary\",\"gender\",\"how_we_met\",\"food_prefs\",\"allergies\",\"last_contacted_at\",\"stay_in_touch_frequency\",\"stay_in_touch_topic\",\"notes\",\"tags\",\"favorite\",\"avatar_blob_id\",\"color\"],\"update_fields\":[\"name\",\"nickname\",\"pronouns\",\"email\",\"phone\",\"secondary_email\",\"secondary_phone\",\"company\",\"job_title\",\"address_line\",\"city\",\"country\",\"website\",\"linkedin\",\"twitter\",\"birthday\",\"anniversary\",\"gender\",\"how_we_met\",\"food_prefs\",\"allergies\",\"last_contacted_at\",\"stay_in_touch_frequency\",\"stay_in_touch_topic\",\"notes\",\"tags\",\"favorite\",\"avatar_blob_id\",\"color\"],\"allowed_filters\":[\"data__name\",\"data__email\",\"data__company\",\"data__city\",\"data__country\",\"data__favorite\",\"data__tags\",\"data__gender\",\"data__stay_in_touch_frequency\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"updated_at\",\"data__name\",\"data__company\",\"data__last_contacted_at\",\"data__birthday\",\"data__stay_in_touch_frequency\"],\"default_sort\":\"data__name\",\"max_limit\":200,\"fields\":[{\"name\":\"city\",\"type\":\"string\",\"max_len\":120},{\"name\":\"name\",\"type\":\"string\",\"max_len\":200},{\"name\":\"tags\",\"type\":\"tags\"},{\"name\":\"color\",\"type\":\"string\",\"max_len\":24},{\"name\":\"email\",\"type\":\"string\",\"max_len\":320},{\"name\":\"notes\",\"type\":\"string\",\"max_len\":8000},{\"name\":\"phone\",\"type\":\"string\",\"max_len\":64},{\"name\":\"gender\",\"type\":\"enum\",\"values\":[\"male\",\"female\",\"other\",\"unspecified\"]},{\"name\":\"company\",\"type\":\"string\",\"max_len\":200},{\"name\":\"country\",\"type\":\"string\",\"max_len\":120},{\"name\":\"twitter\",\"type\":\"url\"},{\"name\":\"website\",\"type\":\"url\"},{\"name\":\"birthday\",\"type\":\"string\",\"max_len\":32},{\"name\":\"favorite\",\"type\":\"bool\"},{\"name\":\"linkedin\",\"type\":\"url\"},{\"name\":\"nickname\",\"type\":\"string\",\"max_len\":120},{\"name\":\"pronouns\",\"type\":\"string\",\"max_len\":32},{\"name\":\"allergies\",\"type\":\"string\",\"max_len\":600},{\"name\":\"job_title\",\"type\":\"string\",\"max_len\":200},{\"name\":\"food_prefs\",\"type\":\"string\",\"max_len\":600},{\"name\":\"how_we_met\",\"type\":\"string\",\"max_len\":1000},{\"name\":\"anniversary\",\"type\":\"string\",\"max_len\":32},{\"name\":\"address_line\",\"type\":\"string\",\"max_len\":200},{\"name\":\"avatar_blob_id\",\"type\":\"string\",\"max_len\":64},{\"name\":\"secondary_email\",\"type\":\"string\",\"max_len\":320},{\"name\":\"secondary_phone\",\"type\":\"string\",\"max_len\":64},{\"name\":\"last_contacted_at\",\"type\":\"string\",\"max_len\":32},{\"name\":\"stay_in_touch_topic\",\"type\":\"string\",\"max_len\":400},{\"name\":\"stay_in_touch_frequency\",\"type\":\"enum\",\"values\":[\"never\",\"weekly\",\"biweekly\",\"monthly\",\"quarterly\",\"yearly\"]}]},\"conversation\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"channel\",\"summary\",\"content\",\"occurred_at\",\"sentiment\",\"duration_minutes\"],\"update_fields\":[\"channel\",\"summary\",\"content\",\"occurred_at\",\"sentiment\",\"duration_minutes\"],\"allowed_filters\":[\"data__parent_id\",\"data__channel\",\"data__sentiment\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__occurred_at\"],\"default_sort\":\"data__occurred_at\",\"max_limit\":200,\"fields\":[{\"name\":\"channel\",\"type\":\"enum\",\"values\":[\"call\",\"sms\",\"whatsapp\",\"email\",\"in_person\",\"video\",\"voice\",\"letter\",\"other\"]},{\"name\":\"content\",\"type\":\"string\",\"max_len\":8000},{\"name\":\"summary\",\"type\":\"string\",\"max_len\":400},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"sentiment\",\"type\":\"enum\",\"values\":[\"positive\",\"neutral\",\"negative\"]},{\"name\":\"occurred_at\",\"type\":\"string\",\"max_len\":32},{\"name\":\"duration_minutes\",\"type\":\"number\"}]},\"custom_field\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"label\",\"value\",\"kind\",\"icon\"],\"update_fields\":[\"label\",\"value\",\"kind\",\"icon\"],\"allowed_filters\":[\"data__parent_id\",\"data__kind\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__label\"],\"default_sort\":\"created_at\",\"max_limit\":200,\"fields\":[{\"name\":\"icon\",\"type\":\"string\",\"max_len\":32},{\"name\":\"kind\",\"type\":\"enum\",\"values\":[\"text\",\"number\",\"date\",\"url\",\"bool\"]},{\"name\":\"label\",\"type\":\"string\",\"max_len\":80},{\"name\":\"value\",\"type\":\"string\",\"max_len\":2000},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}}]},\"gift\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"title\",\"occasion\",\"status\",\"occurred_at\",\"price\",\"currency\",\"url\",\"notes\"],\"update_fields\":[\"title\",\"occasion\",\"status\",\"occurred_at\",\"price\",\"currency\",\"url\",\"notes\"],\"allowed_filters\":[\"data__parent_id\",\"data__status\",\"data__occasion\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__occurred_at\",\"data__title\"],\"default_sort\":\"data__occurred_at\",\"max_limit\":200,\"fields\":[{\"name\":\"url\",\"type\":\"url\"},{\"name\":\"notes\",\"type\":\"string\",\"max_len\":4000},{\"name\":\"price\",\"type\":\"number\"},{\"name\":\"title\",\"type\":\"string\",\"max_len\":200},{\"name\":\"status\",\"type\":\"enum\",\"values\":[\"idea\",\"given\",\"received\"]},{\"name\":\"currency\",\"type\":\"string\",\"max_len\":8},{\"name\":\"occasion\",\"type\":\"string\",\"max_len\":120},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"occurred_at\",\"type\":\"string\",\"max_len\":32}]},\"journal_entry\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"title\",\"body\",\"mood\",\"occurred_at\",\"tags\"],\"update_fields\":[\"title\",\"body\",\"mood\",\"occurred_at\",\"tags\"],\"allowed_filters\":[\"data__mood\",\"data__tags\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"data__occurred_at\",\"created_at\",\"updated_at\"],\"default_sort\":\"data__occurred_at\",\"max_limit\":200,\"fields\":[{\"name\":\"body\",\"type\":\"string\",\"max_len\":16000},{\"name\":\"mood\",\"type\":\"enum\",\"values\":[\"great\",\"good\",\"ok\",\"down\",\"awful\"]},{\"name\":\"tags\",\"type\":\"tags\"},{\"name\":\"title\",\"type\":\"string\",\"max_len\":200},{\"name\":\"occurred_at\",\"type\":\"string\",\"max_len\":32}]},\"life_event\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"kind\",\"title\",\"occurred_at\",\"description\",\"location\",\"recurring\"],\"update_fields\":[\"kind\",\"title\",\"occurred_at\",\"description\",\"location\",\"recurring\"],\"allowed_filters\":[\"data__parent_id\",\"data__kind\",\"data__recurring\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"data__occurred_at\",\"created_at\"],\"default_sort\":\"data__occurred_at\",\"max_limit\":200,\"fields\":[{\"name\":\"kind\",\"type\":\"enum\",\"values\":[\"birthday\",\"anniversary\",\"met\",\"graduation\",\"job_start\",\"job_end\",\"move\",\"marriage\",\"birth\",\"loss\",\"milestone\",\"custom\"]},{\"name\":\"title\",\"type\":\"string\",\"max_len\":200},{\"name\":\"location\",\"type\":\"string\",\"max_len\":200},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"recurring\",\"type\":\"bool\"},{\"name\":\"description\",\"type\":\"string\",\"max_len\":4000},{\"name\":\"occurred_at\",\"type\":\"string\",\"max_len\":32}]},\"note\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"body\",\"pinned\"],\"update_fields\":[\"body\",\"pinned\"],\"allowed_filters\":[\"data__parent_id\",\"data__pinned\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"updated_at\"],\"default_sort\":\"created_at\",\"max_limit\":200,\"fields\":[{\"name\":\"body\",\"type\":\"string\",\"max_len\":8000},{\"name\":\"pinned\",\"type\":\"bool\"},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}}]},\"pet\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"name\",\"species\",\"species_other\",\"breed\",\"born_at\",\"color\",\"notes\"],\"update_fields\":[\"name\",\"species\",\"species_other\",\"breed\",\"born_at\",\"color\",\"notes\"],\"allowed_filters\":[\"data__parent_id\",\"data__species\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__name\",\"data__born_at\"],\"default_sort\":\"data__name\",\"max_limit\":200,\"fields\":[{\"name\":\"name\",\"type\":\"string\",\"max_len\":80},{\"name\":\"breed\",\"type\":\"string\",\"max_len\":120},{\"name\":\"color\",\"type\":\"string\",\"max_len\":80},{\"name\":\"notes\",\"type\":\"string\",\"max_len\":2000},{\"name\":\"born_at\",\"type\":\"string\",\"max_len\":32},{\"name\":\"species\",\"type\":\"enum\",\"values\":[\"dog\",\"cat\",\"bird\",\"fish\",\"rabbit\",\"hamster\",\"guinea_pig\",\"reptile\",\"horse\",\"other\"]},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"species_other\",\"type\":\"string\",\"max_len\":80}]},\"relationship\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"target_id\",\"kind\",\"label\",\"since\",\"notes\"],\"update_fields\":[\"kind\",\"label\",\"since\",\"notes\"],\"allowed_filters\":[\"data__parent_id\",\"data__target_id\",\"data__kind\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__kind\"],\"default_sort\":\"data__kind\",\"max_limit\":200,\"fields\":[{\"name\":\"kind\",\"type\":\"enum\",\"values\":[\"partner\",\"spouse\",\"parent\",\"child\",\"sibling\",\"friend\",\"colleague\",\"manager\",\"report\",\"mentor\",\"mentee\",\"other\"]},{\"name\":\"label\",\"type\":\"string\",\"max_len\":80},{\"name\":\"notes\",\"type\":\"string\",\"max_len\":2000},{\"name\":\"since\",\"type\":\"string\",\"max_len\":32},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"target_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}}]},\"reminder\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"message\",\"due_date\",\"completed\"],\"update_fields\":[\"message\",\"due_date\",\"completed\"],\"allowed_filters\":[\"data__parent_id\",\"data__completed\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__due_date\"],\"default_sort\":\"data__due_date\",\"max_limit\":200,\"fields\":[{\"name\":\"message\",\"type\":\"string\",\"max_len\":400},{\"name\":\"due_date\",\"type\":\"string\",\"max_len\":32},{\"name\":\"completed\",\"type\":\"bool\"},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}}]}}")) ;; ── 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 activity-list "List `activity` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (activity-list client {})) ([client opts] (request-list client "/xapi2/data/activity" opts))) (defn activity-get "Fetch one `activity` row by id." [client id] (request-json client "GET" (str "/xapi2/data/activity/" id) nil)) (defn activity-create "Create a new `activity` row." [client data] (request-json client "POST" "/xapi2/data/activity" data)) (defn activity-update "Patch a `activity` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/activity/" id) data)) (defn activity-delete "Delete a `activity` row." [client id] (request-json client "DELETE" (str "/xapi2/data/activity/" id) nil) true) (defn contact-list "List `contact` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (contact-list client {})) ([client opts] (request-list client "/xapi2/data/contact" opts))) (defn contact-get "Fetch one `contact` row by id." [client id] (request-json client "GET" (str "/xapi2/data/contact/" id) nil)) (defn contact-create "Create a new `contact` row." [client data] (request-json client "POST" "/xapi2/data/contact" data)) (defn contact-update "Patch a `contact` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/contact/" id) data)) (defn contact-delete "Delete a `contact` row." [client id] (request-json client "DELETE" (str "/xapi2/data/contact/" id) nil) true) (defn conversation-list "List `conversation` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (conversation-list client {})) ([client opts] (request-list client "/xapi2/data/conversation" opts))) (defn conversation-get "Fetch one `conversation` row by id." [client id] (request-json client "GET" (str "/xapi2/data/conversation/" id) nil)) (defn conversation-create "Create a new `conversation` row." [client data] (request-json client "POST" "/xapi2/data/conversation" data)) (defn conversation-update "Patch a `conversation` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/conversation/" id) data)) (defn conversation-delete "Delete a `conversation` row." [client id] (request-json client "DELETE" (str "/xapi2/data/conversation/" id) nil) true) (defn custom-field-list "List `custom_field` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (custom-field-list client {})) ([client opts] (request-list client "/xapi2/data/custom_field" opts))) (defn custom-field-get "Fetch one `custom_field` row by id." [client id] (request-json client "GET" (str "/xapi2/data/custom_field/" id) nil)) (defn custom-field-create "Create a new `custom_field` row." [client data] (request-json client "POST" "/xapi2/data/custom_field" data)) (defn custom-field-update "Patch a `custom_field` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/custom_field/" id) data)) (defn custom-field-delete "Delete a `custom_field` row." [client id] (request-json client "DELETE" (str "/xapi2/data/custom_field/" id) nil) true) (defn gift-list "List `gift` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (gift-list client {})) ([client opts] (request-list client "/xapi2/data/gift" opts))) (defn gift-get "Fetch one `gift` row by id." [client id] (request-json client "GET" (str "/xapi2/data/gift/" id) nil)) (defn gift-create "Create a new `gift` row." [client data] (request-json client "POST" "/xapi2/data/gift" data)) (defn gift-update "Patch a `gift` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/gift/" id) data)) (defn gift-delete "Delete a `gift` row." [client id] (request-json client "DELETE" (str "/xapi2/data/gift/" id) nil) true) (defn journal-entry-list "List `journal_entry` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (journal-entry-list client {})) ([client opts] (request-list client "/xapi2/data/journal_entry" opts))) (defn journal-entry-get "Fetch one `journal_entry` row by id." [client id] (request-json client "GET" (str "/xapi2/data/journal_entry/" id) nil)) (defn journal-entry-create "Create a new `journal_entry` row." [client data] (request-json client "POST" "/xapi2/data/journal_entry" data)) (defn journal-entry-update "Patch a `journal_entry` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/journal_entry/" id) data)) (defn journal-entry-delete "Delete a `journal_entry` row." [client id] (request-json client "DELETE" (str "/xapi2/data/journal_entry/" id) nil) true) (defn life-event-list "List `life_event` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (life-event-list client {})) ([client opts] (request-list client "/xapi2/data/life_event" opts))) (defn life-event-get "Fetch one `life_event` row by id." [client id] (request-json client "GET" (str "/xapi2/data/life_event/" id) nil)) (defn life-event-create "Create a new `life_event` row." [client data] (request-json client "POST" "/xapi2/data/life_event" data)) (defn life-event-update "Patch a `life_event` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/life_event/" id) data)) (defn life-event-delete "Delete a `life_event` row." [client id] (request-json client "DELETE" (str "/xapi2/data/life_event/" id) nil) true) (defn note-list "List `note` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (note-list client {})) ([client opts] (request-list client "/xapi2/data/note" opts))) (defn note-get "Fetch one `note` row by id." [client id] (request-json client "GET" (str "/xapi2/data/note/" id) nil)) (defn note-create "Create a new `note` row." [client data] (request-json client "POST" "/xapi2/data/note" data)) (defn note-update "Patch a `note` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/note/" id) data)) (defn note-delete "Delete a `note` row." [client id] (request-json client "DELETE" (str "/xapi2/data/note/" id) nil) true) (defn pet-list "List `pet` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (pet-list client {})) ([client opts] (request-list client "/xapi2/data/pet" opts))) (defn pet-get "Fetch one `pet` row by id." [client id] (request-json client "GET" (str "/xapi2/data/pet/" id) nil)) (defn pet-create "Create a new `pet` row." [client data] (request-json client "POST" "/xapi2/data/pet" data)) (defn pet-update "Patch a `pet` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/pet/" id) data)) (defn pet-delete "Delete a `pet` row." [client id] (request-json client "DELETE" (str "/xapi2/data/pet/" id) nil) true) (defn relationship-list "List `relationship` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (relationship-list client {})) ([client opts] (request-list client "/xapi2/data/relationship" opts))) (defn relationship-get "Fetch one `relationship` row by id." [client id] (request-json client "GET" (str "/xapi2/data/relationship/" id) nil)) (defn relationship-create "Create a new `relationship` row." [client data] (request-json client "POST" "/xapi2/data/relationship" data)) (defn relationship-update "Patch a `relationship` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/relationship/" id) data)) (defn relationship-delete "Delete a `relationship` row." [client id] (request-json client "DELETE" (str "/xapi2/data/relationship/" id) nil) true) (defn reminder-list "List `reminder` rows. Pass an opts map: {:limit, :offset, :sort, :q, :filters}." ([client] (reminder-list client {})) ([client opts] (request-list client "/xapi2/data/reminder" opts))) (defn reminder-get "Fetch one `reminder` row by id." [client id] (request-json client "GET" (str "/xapi2/data/reminder/" id) nil)) (defn reminder-create "Create a new `reminder` row." [client data] (request-json client "POST" "/xapi2/data/reminder" data)) (defn reminder-update "Patch a `reminder` row." [client id data] (request-json client "PATCH" (str "/xapi2/data/reminder/" id) data)) (defn reminder-delete "Delete a `reminder` row." [client id] (request-json client "DELETE" (str "/xapi2/data/reminder/" id) nil) true)