// Drop-in C++ client library for the Grants HTTP API. // // Save this file alongside your source as `GrantsClient.hpp` and use // the GrantsClient class: // // #include "GrantsClient.hpp" // grants_client::GrantsClient c("pat_..."); // auto rows = c.AccountList({{}}); // auto fresh = c.AccountCreate({{ {{"name", "Example GmbH"}} }}); // // Every endpoint exposed by the HTTP API is wrapped as a typed // `` method on GrantsClient. List endpoints take a // ListOpts; get/update/delete endpoints take the row id as their // first argument. // // Header-only. Requires libcurl headers + linker flag `-lcurl`. The // JSON encoder / decoder is bundled inline so no other dependency is // needed. Targets C++17 + libcurl 7.x. // // Provided as-is, with no warranty. Vendor freely; modify as needed. // // DO NOT EDIT THIS FILE MANUALLY - re-download from the docs site. // Local edits will be overwritten by the once-per-day version check. #ifndef grants_client_CLIENT_HPP_ #define grants_client_CLIENT_HPP_ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace grants_client { // ── Identity (substituted at generation time) ──────────────────────── inline constexpr const char* kAppSlug = "grants"; inline constexpr const char* kAppName = "Grants"; inline constexpr const char* kModuleName = "grants_client"; inline constexpr const char* kClientVersion = "0.3.13"; inline constexpr const char* kLanguage = "cpp"; inline constexpr const char* kDefaultBase = "https://granttool.de"; // Per-type metadata baked at generation time. Available at runtime // when calling code needs to know the legal filters / sort columns / // max_limit for a model without a second round-trip. Decode with // GrantsClient::ParseJson. inline const char* TypesJson() { static const char* kBlob = R"JSON_BLOB({"derived_doc":{"ops":["list","read","create","update","delete"],"create_fields":["grant_id","kind","based_on_version","status","formats","latest_blob_id","latest_format"],"update_fields":["kind","based_on_version","status","formats","latest_blob_id","latest_format"],"allowed_filters":["data__grant_id","data__kind","data__status","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__kind","data__status"],"default_sort":"created_at","max_limit":500,"fields":[{"name":"kind","type":"string","max_len":64},{"name":"status","type":"enum","values":["missing","generating","fresh","stale"]},{"name":"formats","type":"list"},{"name":"grant_id","type":"string","max_len":64,"ref":{"type":"grant","owned":false,"optional":false}},{"name":"latest_format","type":"string","max_len":32},{"name":"latest_blob_id","type":"string","max_len":64},{"name":"based_on_version","type":"string","max_len":64}]},"feedback_item":{"ops":["list","read","create","update","delete"],"create_fields":["grant_id","source_type","who","subject","body","urgent","unread","status","due_date"],"update_fields":["source_type","who","subject","body","urgent","unread","status","due_date"],"allowed_filters":["data__grant_id","data__source_type","data__status","data__urgent","data__unread","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__status"],"default_sort":"created_at","max_limit":500,"fields":[{"name":"who","type":"string","max_len":200},{"name":"body","type":"string","max_len":50000},{"name":"status","type":"enum","values":["open","extracting","applied","ignored"]},{"name":"unread","type":"bool"},{"name":"urgent","type":"bool"},{"name":"subject","type":"string","max_len":300},{"name":"due_date","type":"string","max_len":32},{"name":"grant_id","type":"string","max_len":64,"ref":{"type":"grant","owned":false,"optional":false}},{"name":"source_type","type":"enum","values":["uni","ptj","mentor","self","external"]},{"name":"file_blob_id","type":"string","max_len":64}]},"feedback_point":{"ops":["list","read","create","update","delete"],"create_fields":["grant_id","feedback_item_id","chapter_key","kind","excerpt","suggestion","applied","applied_at"],"update_fields":["chapter_key","kind","excerpt","suggestion","applied","applied_at"],"allowed_filters":["data__grant_id","data__feedback_item_id","data__chapter_key","data__kind","data__applied","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__kind"],"default_sort":"created_at","max_limit":1000,"fields":[{"name":"kind","type":"enum","values":["korrektur","kritik","vorschlag","lob","frage"]},{"name":"applied","type":"bool"},{"name":"excerpt","type":"string","max_len":2000},{"name":"grant_id","type":"string","max_len":64,"ref":{"type":"grant","owned":false,"optional":false}},{"name":"applied_at","type":"string","max_len":32},{"name":"suggestion","type":"string","max_len":2000},{"name":"chapter_key","type":"string","max_len":64},{"name":"feedback_item_id","type":"string","max_len":64}]},"file":{"ops":["list","read","create","update","delete"],"create_fields":["name","kind","content","doc","grant_id","folder_id","is_root","blob_id","mime_type","size_bytes","sort_order","chapter_type","chapter_answers","ai_messages","import_status","import_gaps","import_at"],"update_fields":["name","kind","content","doc","folder_id","is_root","blob_id","mime_type","size_bytes","sort_order","chapter_type","chapter_answers","ai_messages","eval_result","eval_hash","eval_at","eval_tasks","import_status","import_gaps","import_at"],"allowed_filters":["data__grant_id","data__folder_id","data__kind","data__is_root","data__mime_type","status","owned_by"],"allowed_sorts":["data__name","data__sort_order","data__kind","created_at","updated_at"],"default_sort":"data__sort_order","max_limit":500,"fields":[{"name":"doc","type":"dict"},{"name":"kind","type":"enum","values":["doc","asset"]},{"name":"name","type":"string","max_len":300},{"name":"blob_id","type":"string","max_len":64},{"name":"content","type":"string","max_len":200000},{"name":"eval_at","type":"number"},{"name":"is_root","type":"bool"},{"name":"grant_id","type":"string","max_len":64,"ref":{"type":"grant","owned":false,"optional":false}},{"name":"eval_hash","type":"string","max_len":64},{"name":"folder_id","type":"string","max_len":64},{"name":"mime_type","type":"string","max_len":120},{"name":"eval_tasks","type":"list"},{"name":"size_bytes","type":"number"},{"name":"sort_order","type":"number"},{"name":"ai_messages","type":"list"},{"name":"eval_result","type":"dict"},{"name":"chapter_type","type":"string","max_len":64},{"name":"chapter_answers","type":"dict"}]},"folder":{"ops":["list","read","create","update","delete"],"create_fields":["name","grant_id","parent_folder_id","sort_order"],"update_fields":["name","parent_folder_id","sort_order"],"allowed_filters":["data__grant_id","data__parent_folder_id","status","owned_by"],"allowed_sorts":["data__name","data__sort_order","created_at"],"default_sort":"data__sort_order","max_limit":500,"fields":[{"name":"name","type":"string","max_len":200},{"name":"grant_id","type":"string","max_len":64,"ref":{"type":"grant","owned":false,"optional":false}},{"name":"sort_order","type":"number"},{"name":"parent_folder_id","type":"string","max_len":64}]},"grant":{"ops":["list","read","create","update","delete"],"create_fields":["grant_type_key","title","description","funder","program","deadline","requested_amount","currency","status","compiler","root_file_id","tags","notes","color","intake_blob_id","intake_url","intake_extracted","import_status","import_started_at","import_finished_at","intake_extracted_by_source","intake_sources","awarded_amount","awarded_at","profile","team_size","baseline_score"],"update_fields":["title","description","funder","program","deadline","requested_amount","currency","status","compiler","root_file_id","tags","notes","color","intake_blob_id","intake_url","intake_extracted","import_status","import_started_at","import_finished_at","intake_extracted_by_source","intake_sources","awarded_amount","awarded_at","profile","team_size","baseline_score","spellcheck_language_override","spellcheck_whitelist","spellcheck_disabled_rules"],"allowed_filters":["data__status","data__funder","data__tags","data__deadline","data__grant_type_key","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__title","data__deadline","data__status"],"default_sort":"created_at","max_limit":200,"fields":[{"name":"tags","type":"tags"},{"name":"color","type":"string","max_len":24},{"name":"notes","type":"string","max_len":4000},{"name":"title","type":"string","max_len":300},{"name":"status","type":"enum","values":["drafting","submitted","under_review","awarded","rejected","abandoned"]},{"name":"program","type":"string","max_len":200},{"name":"currency","type":"string","max_len":8},{"name":"deadline","type":"string","max_len":32},{"name":"team_size","type":"number"},{"name":"description","type":"string","max_len":2000},{"name":"root_file_id","type":"string","max_len":64},{"name":"grant_type_key","type":"string","max_len":64},{"name":"requested_amount","type":"number"}]},"lead":{"ops":["list","read","create","update","delete"],"create_fields":["first_name","last_name","gender","company","position","fair_location","phone","email","linkedin","website","city","country","lead_status","note","tags","inferred_gender","inferred_country","inferred_domain","account_id","lead_source_id","last_contacted_at"],"update_fields":["first_name","last_name","gender","company","position","fair_location","phone","email","linkedin","website","city","country","lead_status","note","tags","inferred_gender","inferred_country","inferred_domain","account_id","lead_source_id","last_contacted_at"],"allowed_filters":["data__first_name","data__last_name","data__company","data__email","data__phone","data__city","data__country","data__lead_status","data__tags","data__gender","data__inferred_country","data__inferred_gender","data__inferred_domain","data__account_id","data__lead_source_id","status","is_archived","owned_by","created_by"],"allowed_sorts":["created_at","updated_at","data__first_name","data__last_name","data__company","data__lead_status","data__last_contacted_at"],"default_sort":"created_at","max_limit":500,"fields":[{"name":"org","type":"string","max_len":200},{"name":"size","type":"string","max_len":100},{"name":"notes","type":"string","max_len":2000},{"name":"state","type":"enum","values":["lead","talking","loi_draft","loi_signed","lost"]},{"name":"region","type":"string","max_len":100},{"name":"contact","type":"string","max_len":200},{"name":"grant_id","type":"string","max_len":64,"ref":{"type":"grant","owned":false,"optional":false}},{"name":"loi_signed_at","type":"string","max_len":32}]},"loi":{"ops":["list","read","create","update","delete"],"create_fields":["grant_id","lead_id","partner_id","status","scope_months","exclusive","body","blob_id"],"update_fields":["status","scope_months","exclusive","body","blob_id"],"allowed_filters":["data__grant_id","data__lead_id","data__partner_id","data__status","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__status"],"default_sort":"created_at","max_limit":500,"fields":[{"name":"body","type":"string","max_len":20000},{"name":"status","type":"enum","values":["draft","sent","signed"]},{"name":"blob_id","type":"string","max_len":64},{"name":"lead_id","type":"string","max_len":64},{"name":"grant_id","type":"string","max_len":64,"ref":{"type":"grant","owned":false,"optional":false}},{"name":"exclusive","type":"bool"},{"name":"scope_months","type":"number"}]},"review_note":{"ops":["list","read","create","update","delete"],"create_fields":["grant_id","chapter_key","who","role","body","resolved","resolved_at"],"update_fields":["chapter_key","who","role","body","resolved","resolved_at"],"allowed_filters":["data__grant_id","data__chapter_key","data__resolved","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at"],"default_sort":"created_at","max_limit":500,"fields":[{"name":"who","type":"string","max_len":200},{"name":"body","type":"string","max_len":5000},{"name":"role","type":"string","max_len":100},{"name":"grant_id","type":"string","max_len":64,"ref":{"type":"grant","owned":false,"optional":false}},{"name":"resolved","type":"bool"},{"name":"parent_id","type":"string","max_len":64},{"name":"chapter_key","type":"string","max_len":64},{"name":"resolved_at","type":"string","max_len":32}]},"version_snapshot":{"ops":["list","read","create","update","delete"],"create_fields":["grant_id","label","score","summary","snapshot","feedback_trigger","derived_keys"],"update_fields":["label","score","summary","feedback_trigger","derived_keys"],"allowed_filters":["data__grant_id","status","is_archived","owned_by"],"allowed_sorts":["created_at","updated_at","data__score"],"default_sort":"created_at","max_limit":200,"fields":[{"name":"label","type":"string","max_len":32},{"name":"score","type":"number"},{"name":"summary","type":"string","max_len":2000},{"name":"grant_id","type":"string","max_len":64,"ref":{"type":"grant","owned":false,"optional":false}},{"name":"snapshot","type":"dict"},{"name":"derived_keys","type":"list"},{"name":"feedback_trigger","type":"string","max_len":200}]}})JSON_BLOB"; return kBlob; } // ── Json: tiny self-contained encoder + decoder ────────────────────── // Recursive variant. Object keeps insertion order so encoded payloads // round-trip cleanly with the rest of the toolchain. class Json { public: enum class Kind { Null, Bool, Number, String, Array, Object }; Json() = default; Json(std::nullptr_t) : kind_(Kind::Null) {} Json(bool v) : kind_(Kind::Bool), bool_(v) {} Json(int v) : kind_(Kind::Number), num_(static_cast(v)) {} Json(long v) : kind_(Kind::Number), num_(static_cast(v)) {} Json(long long v) : kind_(Kind::Number), num_(static_cast(v)) {} Json(double v) : kind_(Kind::Number), num_(v) {} Json(const char* v) : kind_(Kind::String), str_(v ? v : "") {} Json(const std::string& v) : kind_(Kind::String), str_(v) {} Json(std::string&& v) : kind_(Kind::String), str_(std::move(v)) {} static Json Array() { Json j; j.kind_ = Kind::Array; return j; } static Json Object() { Json j; j.kind_ = Kind::Object; return j; } static Json FromInitializerObject(std::initializer_list> kvs) { Json j = Object(); for (auto& kv : kvs) j.Set(kv.first, kv.second); return j; } Kind GetKind() const { return kind_; } bool IsNull() const { return kind_ == Kind::Null; } bool IsBool() const { return kind_ == Kind::Bool; } bool IsNumber() const { return kind_ == Kind::Number; } bool IsString() const { return kind_ == Kind::String; } bool IsArray() const { return kind_ == Kind::Array; } bool IsObject() const { return kind_ == Kind::Object; } bool AsBool(bool def = false) const { return IsBool() ? bool_ : def; } double AsNumber(double def = 0.0) const { return IsNumber() ? num_ : def; } long long AsInt(long long def = 0) const { return IsNumber() ? static_cast(num_) : def; } std::string AsString(const std::string& def = "") const { return IsString() ? str_ : def; } const std::vector& AsArray() const { return arr_; } std::vector& AsArray() { return arr_; } void Push(const Json& v) { if (kind_ != Kind::Array) { kind_ = Kind::Array; arr_.clear(); } arr_.push_back(v); } void Set(const std::string& key, const Json& v) { if (kind_ != Kind::Object) { kind_ = Kind::Object; obj_keys_.clear(); obj_vals_.clear(); } for (size_t i = 0; i < obj_keys_.size(); ++i) { if (obj_keys_[i] == key) { obj_vals_[i] = v; return; } } obj_keys_.push_back(key); obj_vals_.push_back(v); } bool Has(const std::string& key) const { if (!IsObject()) return false; for (size_t i = 0; i < obj_keys_.size(); ++i) if (obj_keys_[i] == key) return true; return false; } const Json& Get(const std::string& key) const { static const Json kNull; if (!IsObject()) return kNull; for (size_t i = 0; i < obj_keys_.size(); ++i) if (obj_keys_[i] == key) return obj_vals_[i]; return kNull; } const std::vector& Keys() const { return obj_keys_; } const std::vector& Values() const { return obj_vals_; } std::string Encode() const { std::ostringstream os; EncodeTo(os); return os.str(); } static Json Parse(const std::string& src) { size_t i = 0; SkipWs(src, i); Json out = ParseValue(src, i); SkipWs(src, i); return out; } private: Kind kind_ = Kind::Null; bool bool_ = false; double num_ = 0.0; std::string str_; std::vector arr_; std::vector obj_keys_; std::vector obj_vals_; void EncodeTo(std::ostringstream& os) const { switch (kind_) { case Kind::Null: os << "null"; break; case Kind::Bool: os << (bool_ ? "true" : "false"); break; case Kind::Number: { if (num_ == static_cast(num_) && std::abs(num_) < 1e15) { os << static_cast(num_); } else { os.precision(15); os << num_; } break; } case Kind::String: EncodeString(os, str_); break; case Kind::Array: { os << "["; for (size_t i = 0; i < arr_.size(); ++i) { if (i) os << ","; arr_[i].EncodeTo(os); } os << "]"; break; } case Kind::Object: { os << "{"; for (size_t i = 0; i < obj_keys_.size(); ++i) { if (i) os << ","; EncodeString(os, obj_keys_[i]); os << ":"; obj_vals_[i].EncodeTo(os); } os << "}"; break; } } } static void EncodeString(std::ostringstream& os, const std::string& s) { os << "\""; for (size_t i = 0; i < s.size(); ++i) { unsigned char c = static_cast(s[i]); switch (c) { case '"': os << "\\\""; break; case '\\': os << "\\\\"; break; case '\b': os << "\\b"; break; case '\f': os << "\\f"; break; case '\n': os << "\\n"; break; case '\r': os << "\\r"; break; case '\t': os << "\\t"; break; default: if (c < 0x20) { char buf[8]; std::snprintf(buf, sizeof(buf), "\\u%04x", c); os << buf; } else { os << static_cast(c); } } } os << "\""; } static void SkipWs(const std::string& s, size_t& i) { while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) ++i; } static Json ParseValue(const std::string& s, size_t& i) { SkipWs(s, i); if (i >= s.size()) throw std::runtime_error("json: unexpected end"); char c = s[i]; if (c == '{') return ParseObject(s, i); if (c == '[') return ParseArray(s, i); if (c == '"') return Json(ParseString(s, i)); if (c == 't' || c == 'f') return ParseBool(s, i); if (c == 'n') { ExpectLiteral(s, i, "null"); return Json(nullptr); } return ParseNumber(s, i); } static Json ParseObject(const std::string& s, size_t& i) { Json out = Object(); ++i; // '{' SkipWs(s, i); if (i < s.size() && s[i] == '}') { ++i; return out; } while (i < s.size()) { SkipWs(s, i); if (i >= s.size() || s[i] != '"') throw std::runtime_error("json: expected string key"); std::string key = ParseString(s, i); SkipWs(s, i); if (i >= s.size() || s[i] != ':') throw std::runtime_error("json: expected ':'"); ++i; Json v = ParseValue(s, i); out.Set(key, v); SkipWs(s, i); if (i < s.size() && s[i] == ',') { ++i; continue; } if (i < s.size() && s[i] == '}') { ++i; return out; } throw std::runtime_error("json: expected ',' or '}'"); } throw std::runtime_error("json: unterminated object"); } static Json ParseArray(const std::string& s, size_t& i) { Json out = Array(); ++i; // '[' SkipWs(s, i); if (i < s.size() && s[i] == ']') { ++i; return out; } while (i < s.size()) { Json v = ParseValue(s, i); out.Push(v); SkipWs(s, i); if (i < s.size() && s[i] == ',') { ++i; continue; } if (i < s.size() && s[i] == ']') { ++i; return out; } throw std::runtime_error("json: expected ',' or ']'"); } throw std::runtime_error("json: unterminated array"); } static std::string ParseString(const std::string& s, size_t& i) { if (i >= s.size() || s[i] != '"') throw std::runtime_error("json: expected '\"'"); ++i; std::string out; while (i < s.size()) { char c = s[i++]; if (c == '"') return out; if (c == '\\') { if (i >= s.size()) throw std::runtime_error("json: bad escape"); char e = s[i++]; switch (e) { case '"': out += '"'; break; case '\\': out += '\\'; break; case '/': out += '/'; break; case 'b': out += '\b'; break; case 'f': out += '\f'; break; case 'n': out += '\n'; break; case 'r': out += '\r'; break; case 't': out += '\t'; break; case 'u': { if (i + 4 > s.size()) throw std::runtime_error("json: bad \\u escape"); unsigned int cp = 0; for (int k = 0; k < 4; ++k) { char h = s[i++]; cp <<= 4; if (h >= '0' && h <= '9') cp |= (h - '0'); else if (h >= 'a' && h <= 'f') cp |= (h - 'a' + 10); else if (h >= 'A' && h <= 'F') cp |= (h - 'A' + 10); else throw std::runtime_error("json: bad hex digit"); } // Encode as UTF-8 (surrogate pairs not handled - good // enough for our payloads, which never embed BMP-2). if (cp < 0x80) out += static_cast(cp); else if (cp < 0x800) { out += static_cast(0xC0 | (cp >> 6)); out += static_cast(0x80 | (cp & 0x3F)); } else { out += static_cast(0xE0 | (cp >> 12)); out += static_cast(0x80 | ((cp >> 6) & 0x3F)); out += static_cast(0x80 | (cp & 0x3F)); } break; } default: throw std::runtime_error("json: unknown escape"); } } else { out += c; } } throw std::runtime_error("json: unterminated string"); } static Json ParseBool(const std::string& s, size_t& i) { if (s.compare(i, 4, "true") == 0) { i += 4; return Json(true); } if (s.compare(i, 5, "false") == 0) { i += 5; return Json(false); } throw std::runtime_error("json: expected bool"); } static Json ParseNumber(const std::string& s, size_t& i) { size_t start = i; if (i < s.size() && s[i] == '-') ++i; while (i < s.size() && ((s[i] >= '0' && s[i] <= '9') || s[i] == '.' || s[i] == 'e' || s[i] == 'E' || s[i] == '+' || s[i] == '-')) ++i; if (start == i) throw std::runtime_error("json: expected number"); try { return Json(std::stod(s.substr(start, i - start))); } catch (...) { throw std::runtime_error("json: bad number"); } } static void ExpectLiteral(const std::string& s, size_t& i, const char* lit) { size_t n = std::strlen(lit); if (s.compare(i, n, lit) != 0) throw std::runtime_error("json: expected literal"); i += n; } }; // ── Errors + options ───────────────────────────────────────────────── class ApiError : public std::runtime_error { public: ApiError(int status, const std::string& msg, const Json& body = Json()) : std::runtime_error("HTTP " + std::to_string(status) + ": " + msg), status_(status), body_(body) {} int Status() const { return status_; } const Json& Body() const { return body_; } private: int status_; Json body_; }; struct ListOpts { int limit = 0; int offset = 0; std::string sort; std::string q; std::map filters; }; // ── Client ─────────────────────────────────────────────────────────── class GrantsClient { public: explicit GrantsClient(const std::string& token = "") : token_(token.empty() ? GetEnv("XCLIENT_TOKEN") : token) { std::string base = GetEnv("XCLIENT_BASE_URL"); base_url_ = base.empty() ? std::string(kDefaultBase) : base; while (!base_url_.empty() && base_url_.back() == '/') base_url_.pop_back(); device_id_ = LoadOrMintDeviceId(); session_id_ = MintUuid(); std::call_once(curl_global_init_flag_(), []() { curl_global_init(CURL_GLOBAL_DEFAULT); }); } void SetToken(const std::string& tok) { token_ = tok; } void SetBaseUrl(const std::string& url) { base_url_ = url; while (!base_url_.empty() && base_url_.back() == '/') base_url_.pop_back(); } /// Decode a JSON blob into a Json value. Throws on malformed input. static Json ParseJson(const std::string& src) { return Json::Parse(src); } /// List `derived_doc` rows. Json DerivedDocList(const ListOpts& opts = {}) { return RequestList("/xapi2/data/derived_doc", opts); } /// Fetch one `derived_doc` row by id. Json DerivedDocGet(const std::string& id) { return RequestJson("GET", "/xapi2/data/derived_doc/" + id, Json{}); } /// Create a new `derived_doc` row. Json DerivedDocCreate(const Json& data) { return RequestJson("POST", "/xapi2/data/derived_doc", data); } /// Patch a `derived_doc` row. Json DerivedDocUpdate(const std::string& id, const Json& data) { return RequestJson("PATCH", "/xapi2/data/derived_doc/" + id, data); } /// Delete a `derived_doc` row. bool DerivedDocDelete(const std::string& id) { RequestJson("DELETE", "/xapi2/data/derived_doc/" + id, Json{}); return true; } /// List `feedback_item` rows. Json FeedbackItemList(const ListOpts& opts = {}) { return RequestList("/xapi2/data/feedback_item", opts); } /// Fetch one `feedback_item` row by id. Json FeedbackItemGet(const std::string& id) { return RequestJson("GET", "/xapi2/data/feedback_item/" + id, Json{}); } /// Create a new `feedback_item` row. Json FeedbackItemCreate(const Json& data) { return RequestJson("POST", "/xapi2/data/feedback_item", data); } /// Patch a `feedback_item` row. Json FeedbackItemUpdate(const std::string& id, const Json& data) { return RequestJson("PATCH", "/xapi2/data/feedback_item/" + id, data); } /// Delete a `feedback_item` row. bool FeedbackItemDelete(const std::string& id) { RequestJson("DELETE", "/xapi2/data/feedback_item/" + id, Json{}); return true; } /// List `feedback_point` rows. Json FeedbackPointList(const ListOpts& opts = {}) { return RequestList("/xapi2/data/feedback_point", opts); } /// Fetch one `feedback_point` row by id. Json FeedbackPointGet(const std::string& id) { return RequestJson("GET", "/xapi2/data/feedback_point/" + id, Json{}); } /// Create a new `feedback_point` row. Json FeedbackPointCreate(const Json& data) { return RequestJson("POST", "/xapi2/data/feedback_point", data); } /// Patch a `feedback_point` row. Json FeedbackPointUpdate(const std::string& id, const Json& data) { return RequestJson("PATCH", "/xapi2/data/feedback_point/" + id, data); } /// Delete a `feedback_point` row. bool FeedbackPointDelete(const std::string& id) { RequestJson("DELETE", "/xapi2/data/feedback_point/" + id, Json{}); return true; } /// List `file` rows. Json FileList(const ListOpts& opts = {}) { return RequestList("/xapi2/data/file", opts); } /// Fetch one `file` row by id. Json FileGet(const std::string& id) { return RequestJson("GET", "/xapi2/data/file/" + id, Json{}); } /// Create a new `file` row. Json FileCreate(const Json& data) { return RequestJson("POST", "/xapi2/data/file", data); } /// Patch a `file` row. Json FileUpdate(const std::string& id, const Json& data) { return RequestJson("PATCH", "/xapi2/data/file/" + id, data); } /// Delete a `file` row. bool FileDelete(const std::string& id) { RequestJson("DELETE", "/xapi2/data/file/" + id, Json{}); return true; } /// List `folder` rows. Json FolderList(const ListOpts& opts = {}) { return RequestList("/xapi2/data/folder", opts); } /// Fetch one `folder` row by id. Json FolderGet(const std::string& id) { return RequestJson("GET", "/xapi2/data/folder/" + id, Json{}); } /// Create a new `folder` row. Json FolderCreate(const Json& data) { return RequestJson("POST", "/xapi2/data/folder", data); } /// Patch a `folder` row. Json FolderUpdate(const std::string& id, const Json& data) { return RequestJson("PATCH", "/xapi2/data/folder/" + id, data); } /// Delete a `folder` row. bool FolderDelete(const std::string& id) { RequestJson("DELETE", "/xapi2/data/folder/" + id, Json{}); return true; } /// List `grant` rows. Json GrantList(const ListOpts& opts = {}) { return RequestList("/xapi2/data/grant", opts); } /// Fetch one `grant` row by id. Json GrantGet(const std::string& id) { return RequestJson("GET", "/xapi2/data/grant/" + id, Json{}); } /// Create a new `grant` row. Json GrantCreate(const Json& data) { return RequestJson("POST", "/xapi2/data/grant", data); } /// Patch a `grant` row. Json GrantUpdate(const std::string& id, const Json& data) { return RequestJson("PATCH", "/xapi2/data/grant/" + id, data); } /// Delete a `grant` row. bool GrantDelete(const std::string& id) { RequestJson("DELETE", "/xapi2/data/grant/" + id, Json{}); return true; } /// List `lead` rows. Json LeadList(const ListOpts& opts = {}) { return RequestList("/xapi2/data/lead", opts); } /// Fetch one `lead` row by id. Json LeadGet(const std::string& id) { return RequestJson("GET", "/xapi2/data/lead/" + id, Json{}); } /// Create a new `lead` row. Json LeadCreate(const Json& data) { return RequestJson("POST", "/xapi2/data/lead", data); } /// Patch a `lead` row. Json LeadUpdate(const std::string& id, const Json& data) { return RequestJson("PATCH", "/xapi2/data/lead/" + id, data); } /// Delete a `lead` row. bool LeadDelete(const std::string& id) { RequestJson("DELETE", "/xapi2/data/lead/" + id, Json{}); return true; } /// List `loi` rows. Json LoiList(const ListOpts& opts = {}) { return RequestList("/xapi2/data/loi", opts); } /// Fetch one `loi` row by id. Json LoiGet(const std::string& id) { return RequestJson("GET", "/xapi2/data/loi/" + id, Json{}); } /// Create a new `loi` row. Json LoiCreate(const Json& data) { return RequestJson("POST", "/xapi2/data/loi", data); } /// Patch a `loi` row. Json LoiUpdate(const std::string& id, const Json& data) { return RequestJson("PATCH", "/xapi2/data/loi/" + id, data); } /// Delete a `loi` row. bool LoiDelete(const std::string& id) { RequestJson("DELETE", "/xapi2/data/loi/" + id, Json{}); return true; } /// List `review_note` rows. Json ReviewNoteList(const ListOpts& opts = {}) { return RequestList("/xapi2/data/review_note", opts); } /// Fetch one `review_note` row by id. Json ReviewNoteGet(const std::string& id) { return RequestJson("GET", "/xapi2/data/review_note/" + id, Json{}); } /// Create a new `review_note` row. Json ReviewNoteCreate(const Json& data) { return RequestJson("POST", "/xapi2/data/review_note", data); } /// Patch a `review_note` row. Json ReviewNoteUpdate(const std::string& id, const Json& data) { return RequestJson("PATCH", "/xapi2/data/review_note/" + id, data); } /// Delete a `review_note` row. bool ReviewNoteDelete(const std::string& id) { RequestJson("DELETE", "/xapi2/data/review_note/" + id, Json{}); return true; } /// List `version_snapshot` rows. Json VersionSnapshotList(const ListOpts& opts = {}) { return RequestList("/xapi2/data/version_snapshot", opts); } /// Fetch one `version_snapshot` row by id. Json VersionSnapshotGet(const std::string& id) { return RequestJson("GET", "/xapi2/data/version_snapshot/" + id, Json{}); } /// Create a new `version_snapshot` row. Json VersionSnapshotCreate(const Json& data) { return RequestJson("POST", "/xapi2/data/version_snapshot", data); } /// Patch a `version_snapshot` row. Json VersionSnapshotUpdate(const std::string& id, const Json& data) { return RequestJson("PATCH", "/xapi2/data/version_snapshot/" + id, data); } /// Delete a `version_snapshot` row. bool VersionSnapshotDelete(const std::string& id) { RequestJson("DELETE", "/xapi2/data/version_snapshot/" + id, Json{}); return true; } private: std::string base_url_; std::string token_; std::string device_id_; std::string session_id_; std::atomic autoupdate_attempted_{false}; std::atomic meta_sent_once_{false}; std::mutex token_mtx_; static std::once_flag& curl_global_init_flag_() { static std::once_flag f; return f; } static std::string GetEnv(const char* key) { const char* v = std::getenv(key); return v ? std::string(v) : std::string(); } static bool AutoupdateEnabled() { std::string v = GetEnv("XCLIENT_NO_AUTOUPDATE"); for (auto& c : v) c = static_cast(std::tolower(static_cast(c))); return v != "1" && v != "true" && v != "yes"; } static std::string StateDir() { std::string home = GetEnv("HOME"); if (home.empty()) home = GetEnv("USERPROFILE"); if (home.empty()) return ""; std::string d = home + "/." + std::string(kModuleName); std::error_code ec; std::filesystem::create_directories(d, ec); return d; } static std::string MintUuid() { std::random_device rd; std::mt19937_64 g(static_cast(rd()) ^ static_cast(std::chrono::high_resolution_clock::now().time_since_epoch().count())); std::uniform_int_distribution dist(0, 255); unsigned char b[16]; for (int i = 0; i < 16; ++i) b[i] = static_cast(dist(g)); b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80; char out[37]; std::snprintf(out, sizeof(out), "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]); return std::string(out); } static std::string LoadOrMintDeviceId() { std::string d = StateDir(); if (d.empty()) return MintUuid(); std::string f = d + "/device.json"; std::ifstream in(f); if (in) { std::stringstream buf; buf << in.rdbuf(); try { Json j = Json::Parse(buf.str()); if (j.IsObject() && j.Get("device_id").IsString()) { std::string did = j.Get("device_id").AsString(); if (did.size() >= 32) return did; } } catch (...) {} } std::string fresh = MintUuid(); std::ofstream out(f); if (out) { Json j = Json::Object(); j.Set("device_id", Json(fresh)); out << j.Encode(); } return fresh; } static Json Fingerprint() { Json j = Json::Object(); std::string tp = GetEnv("TERM_PROGRAM"); std::string lower = tp; for (auto& c : lower) c = static_cast(std::tolower(static_cast(c))); j.Set("term_program", tp.empty() ? Json(nullptr) : Json(tp)); j.Set("editor_env", GetEnv("EDITOR").empty() ? Json(nullptr) : Json(GetEnv("EDITOR"))); j.Set("ci", Json(!GetEnv("CI").empty() || !GetEnv("GITHUB_ACTIONS").empty())); j.Set("claude_code", Json(!GetEnv("CLAUDECODE").empty() || !GetEnv("CLAUDE_CODE_ENTRYPOINT").empty())); j.Set("codex", Json(!GetEnv("CODEX_HOME").empty())); j.Set("vscode", Json(lower == "vscode" && GetEnv("CURSOR_TRACE_ID").empty())); j.Set("cursor", Json(!GetEnv("CURSOR_TRACE_ID").empty())); j.Set("antigravity", Json(!GetEnv("ANTIGRAVITY_TRACE_ID").empty())); j.Set("jetbrains", Json(lower.find("jetbrains") != std::string::npos)); return j; } static const std::unordered_set& Retryable() { static const std::unordered_set s = {408, 425, 429, 500, 502, 503, 504}; return s; } static double Backoff(int attempt, double retry_after) { if (retry_after >= 0) return std::min(retry_after, 60.0); double d = static_cast(1ll << attempt); return std::min(d, 60.0); } std::string UserAgent() const { return std::string(kModuleName) + "/" + kClientVersion + " (lib/" + kLanguage + "; cpp)"; } static size_t WriteCallback(char* ptr, size_t size, size_t nmemb, void* ud) { std::string* out = static_cast(ud); out->append(ptr, size * nmemb); return size * nmemb; } static size_t HeaderCallback(char* ptr, size_t size, size_t nmemb, void* ud) { auto* hmap = static_cast*>(ud); size_t n = size * nmemb; std::string line(ptr, n); auto colon = line.find(':'); if (colon != std::string::npos) { std::string k = line.substr(0, colon); std::string v = line.substr(colon + 1); // Trim while (!v.empty() && (v.back() == '\r' || v.back() == '\n' || v.back() == ' ')) v.pop_back(); while (!v.empty() && v.front() == ' ') v.erase(0, 1); for (auto& c : k) c = static_cast(std::tolower(static_cast(c))); (*hmap)[k] = v; } return n; } /// Generic transport. Per-type wrappers forward through here. JSON /// in / JSON out; pass an empty Json for read-only verbs. Retries /// on 408/425/429/5xx + transport errors with exponential backoff. Json RequestJson(const std::string& method, const std::string& path, const Json& body) { MaybeAutoupdate(); std::string url = base_url_ + path; std::string body_str; bool has_body = !body.IsNull(); if (has_body) body_str = body.Encode(); const int max_retries = 3; std::string last_err; for (int attempt = 0; attempt < max_retries; ++attempt) { CURL* h = curl_easy_init(); if (!h) { EmitCallEvent(method, path, 0, false); throw ApiError(0, "curl_easy_init failed"); } std::string resp_body; std::map resp_headers; curl_slist* slist = nullptr; slist = curl_slist_append(slist, "Accept: application/json"); std::string ua = "User-Agent: " + UserAgent(); slist = curl_slist_append(slist, ua.c_str()); std::string ch = "X-Client-Channel: client_" + std::string(kLanguage); slist = curl_slist_append(slist, ch.c_str()); std::string cv = "X-Client-Version: " + std::string(kClientVersion); slist = curl_slist_append(slist, cv.c_str()); std::string did = "X-Analytics-Device-Id: " + device_id_; slist = curl_slist_append(slist, did.c_str()); std::string sid = "X-Analytics-Session-Id: " + session_id_; slist = curl_slist_append(slist, sid.c_str()); std::string auth_h; { std::lock_guard lk(token_mtx_); if (!token_.empty()) { auth_h = "Authorization: Bearer " + token_; slist = curl_slist_append(slist, auth_h.c_str()); } } if (has_body) slist = curl_slist_append(slist, "Content-Type: application/json"); curl_easy_setopt(h, CURLOPT_URL, url.c_str()); curl_easy_setopt(h, CURLOPT_HTTPHEADER, slist); curl_easy_setopt(h, CURLOPT_CUSTOMREQUEST, method.c_str()); // We follow redirects manually so Authorization can be // dropped on cross-origin hops. libcurl preserves headers // by default - a misconfigured proxy bouncing requests to // an internal host would otherwise leak the PAT. curl_easy_setopt(h, CURLOPT_FOLLOWLOCATION, 0L); curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, WriteCallback); curl_easy_setopt(h, CURLOPT_WRITEDATA, &resp_body); curl_easy_setopt(h, CURLOPT_HEADERFUNCTION, HeaderCallback); curl_easy_setopt(h, CURLOPT_HEADERDATA, &resp_headers); curl_easy_setopt(h, CURLOPT_TIMEOUT, 30L); curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L); if (has_body) { curl_easy_setopt(h, CURLOPT_POSTFIELDS, body_str.c_str()); curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, static_cast(body_str.size())); } std::string current_method = method; CURLcode rc = PerformWithRedirects(h, url, current_method, has_body, body_str, slist, resp_body, resp_headers); long status = 0; if (rc == CURLE_OK) curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, &status); curl_slist_free_all(slist); curl_easy_cleanup(h); if (rc != CURLE_OK) { last_err = curl_easy_strerror(rc); if (attempt + 1 < max_retries) { std::this_thread::sleep_for(std::chrono::milliseconds(static_cast(Backoff(attempt, -1.0) * 1000))); continue; } EmitCallEvent(method, path, 0, false); throw ApiError(0, last_err); } auto fresh = resp_headers.find("x-auth-refresh-token"); if (fresh != resp_headers.end() && !fresh->second.empty()) { std::lock_guard lk(token_mtx_); token_ = fresh->second; } if (Retryable().count(static_cast(status)) && attempt + 1 < max_retries) { double ra = -1.0; auto raIt = resp_headers.find("retry-after"); if (raIt != resp_headers.end()) { try { ra = std::stod(raIt->second); } catch (...) { ra = -1.0; } } std::this_thread::sleep_for(std::chrono::milliseconds(static_cast(Backoff(attempt, ra) * 1000))); continue; } Json parsed; if (!resp_body.empty()) { try { parsed = Json::Parse(resp_body); } catch (...) { parsed = Json(); } } if (status >= 400) { std::string msg = "request failed"; if (parsed.IsObject()) { if (parsed.Get("detail").IsString()) msg = parsed.Get("detail").AsString(); else if (parsed.Get("message").IsString()) msg = parsed.Get("message").AsString(); } EmitCallEvent(method, path, static_cast(status), false); throw ApiError(static_cast(status), msg, parsed); } EmitCallEvent(method, path, static_cast(status), true); return parsed; } EmitCallEvent(method, path, 0, false); throw ApiError(0, last_err.empty() ? "request failed" : last_err); } /// Drive the redirect chain manually. Caps at 5 hops; mirrors the /// RFC 7231 method-rewrite semantics of every other client in the /// suite. Strips Authorization on cross-origin hops. static CURLcode PerformWithRedirects(CURL* h, std::string& url, std::string& method, bool& has_body, std::string& body_str, curl_slist*& slist, std::string& resp_body, std::map& resp_headers) { const int max_hops = 5; for (int hop = 0; hop < max_hops; ++hop) { resp_body.clear(); resp_headers.clear(); CURLcode rc = curl_easy_perform(h); if (rc != CURLE_OK) return rc; long status = 0; curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, &status); if (status < 300 || status >= 400 || status == 304) return CURLE_OK; auto loc = resp_headers.find("location"); if (loc == resp_headers.end()) return CURLE_OK; std::string next_url = loc->second; // libcurl can resolve relative URLs via curl_url, but we // keep dependencies minimal: assume server emits absolute. if (next_url.empty()) return CURLE_OK; std::string old_origin = OriginOf(url); std::string new_origin = OriginOf(next_url); if (old_origin != new_origin) { // Rebuild header list without Authorization. curl_slist* fresh = nullptr; for (curl_slist* it = slist; it; it = it->next) { std::string h_line = it->data ? it->data : ""; if (StartsWithCaseInsensitive(h_line, "authorization:")) continue; fresh = curl_slist_append(fresh, h_line.c_str()); } curl_slist_free_all(slist); slist = fresh; curl_easy_setopt(h, CURLOPT_HTTPHEADER, slist); } if (status == 303 || ((status == 301 || status == 302) && method != "GET" && method != "HEAD")) { method = "GET"; has_body = false; body_str.clear(); curl_easy_setopt(h, CURLOPT_CUSTOMREQUEST, "GET"); curl_easy_setopt(h, CURLOPT_POSTFIELDS, ""); curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, 0L); } url = next_url; curl_easy_setopt(h, CURLOPT_URL, url.c_str()); } // Too many hops: surface the last response. return CURLE_OK; } static bool StartsWithCaseInsensitive(const std::string& s, const char* prefix) { size_t n = std::strlen(prefix); if (s.size() < n) return false; for (size_t i = 0; i < n; ++i) { char a = static_cast(std::tolower(static_cast(s[i]))); char b = static_cast(std::tolower(static_cast(prefix[i]))); if (a != b) return false; } return true; } static std::string OriginOf(const std::string& url) { auto scheme_end = url.find("://"); if (scheme_end == std::string::npos) return ""; auto host_start = scheme_end + 3; auto host_end = url.find('/', host_start); if (host_end == std::string::npos) host_end = url.size(); return url.substr(0, host_end); } Json RequestList(const std::string& path, const ListOpts& opts) { std::ostringstream qs; bool first = true; auto add = [&](const std::string& k, const std::string& v) { if (v.empty()) return; qs << (first ? '?' : '&'); first = false; qs << UrlEncode(k) << "=" << UrlEncode(v); }; if (opts.limit > 0) add("limit", std::to_string(opts.limit)); if (opts.offset > 0) add("offset", std::to_string(opts.offset)); add("sort", opts.sort); add("q", opts.q); for (auto& kv : opts.filters) add(kv.first, kv.second); return RequestJson("GET", path + qs.str(), Json()); } static std::string UrlEncode(const std::string& s) { std::ostringstream os; os << std::hex; for (unsigned char c : s) { if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') { os << static_cast(c); } else { os << '%'; if (c < 16) os << '0'; os << static_cast(c); } } return os.str(); } void EmitCallEvent(const std::string& method, const std::string& path, int status, bool ok) { bool include_env = !meta_sent_once_.exchange(true); std::string base = base_url_; std::string did = device_id_; std::string sid = session_id_; std::string ua = UserAgent(); std::thread([base, did, sid, ua, method, path, status, ok, include_env]() { try { Json meta = Json::Object(); meta.Set("channel", Json(std::string("client_") + kLanguage)); meta.Set("client_version", Json(std::string(kClientVersion))); meta.Set("module_name", Json(std::string(kModuleName))); meta.Set("language", Json(std::string(kLanguage))); meta.Set("os", Json(std::string("cpp"))); if (include_env) meta.Set("env", Fingerprint()); Json evt = Json::Object(); evt.Set("type", Json(std::string("client.call"))); evt.Set("ts_client", Json(static_cast(std::time(nullptr)))); Json evt_meta = Json::Object(); evt_meta.Set("method", Json(method)); std::string p = path; auto q = p.find('?'); if (q != std::string::npos) p = p.substr(0, q); if (p.size() > 128) p = p.substr(0, 128); evt_meta.Set("path", Json(p)); evt_meta.Set("status", Json(status)); evt_meta.Set("ok", Json(ok)); evt.Set("meta", evt_meta); Json events = Json::Array(); events.Push(evt); Json body = Json::Object(); body.Set("device_id", Json(did)); body.Set("session_id", Json(sid)); body.Set("events", events); body.Set("meta", meta); std::string url = base + "/xapi2/analytics/challenge"; std::string raw = body.Encode(); CURL* h = curl_easy_init(); if (!h) return; curl_slist* sl = nullptr; sl = curl_slist_append(sl, "Content-Type: application/json"); std::string ua_h = "User-Agent: " + ua; sl = curl_slist_append(sl, ua_h.c_str()); curl_easy_setopt(h, CURLOPT_URL, url.c_str()); curl_easy_setopt(h, CURLOPT_HTTPHEADER, sl); curl_easy_setopt(h, CURLOPT_POSTFIELDS, raw.c_str()); curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, static_cast(raw.size())); curl_easy_setopt(h, CURLOPT_TIMEOUT, 4L); curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L); curl_easy_perform(h); curl_slist_free_all(sl); curl_easy_cleanup(h); } catch (...) { /* fire-and-forget */ } }).detach(); } void MaybeAutoupdate() { if (autoupdate_attempted_.exchange(true)) return; if (!AutoupdateEnabled()) return; std::string base = base_url_; std::thread([base]() { try { std::string d = StateDir(); if (d.empty()) return; std::string stamp = d + "/update_check.json"; std::ifstream in(stamp); if (in) { std::stringstream buf; buf << in.rdbuf(); try { Json j = Json::Parse(buf.str()); if (j.IsObject() && j.Get("checked_at").IsNumber()) { long long last = j.Get("checked_at").AsInt(); if (std::time(nullptr) - last < 86400) return; } } catch (...) {} } Json stamp_body = Json::Object(); stamp_body.Set("checked_at", Json(static_cast(std::time(nullptr)))); std::ofstream out(stamp); if (out) out << stamp_body.Encode(); // Source replacement is intentionally a no-op in C++ - // users ship pre-compiled binaries; the .hpp file on // disk is just a record of the version they vendored. // Surface the new version through the next build. } catch (...) { /* best-effort */ } }).detach(); } }; } // namespace grants_client #endif // grants_client_CLIENT_HPP_