// Drop-in C# / .NET client library for the Grants HTTP API. // // Save this file under your project as `GrantsClient.cs`, in a // directory matching `namespace grants_client;`, then call the // GrantsClient class: // // using grants_client; // var c = new GrantsClient("pat_..."); // var rows = await c.AccountListAsync(new ListOpts { Limit = 20 }); // var fresh = await c.AccountCreateAsync(new Dictionary { ["name"] = "Example GmbH" }); // // Every endpoint exposed by the HTTP API is wrapped as an // `Async` method on GrantsClient. List methods take ListOpts; // get/update/delete methods take the row id as their first argument. // // Provided as-is, with no warranty. Vendor freely; modify as needed. // Targets .NET 6+; uses only HttpClient and System.Text.Json from the // BCL. No NuGet packages required. // // DO NOT EDIT THIS FILE MANUALLY - re-download from the docs site. // Local edits will be overwritten by the once-per-day version check. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; namespace grants_client; /// API client wrapper for Grants. public sealed class GrantsClient { public const string AppSlug = "grants"; public const string AppName = "Grants"; public const string ModuleName = "grants_client"; public const string ClientVersion = "0.3.13"; public const string Language = "csharp"; private const string DefaultBase = "https://granttool.de"; /// Per-type metadata baked at generation time; parse with /// JsonNode.Parse if you need the legal filters / sorts / max_limit /// for a model without an extra round-trip. public const string TypesJson = "{\"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}]}}"; private readonly HttpClient _http; private string _baseUrl; private string _token; private readonly string _deviceId; private readonly string _sessionId; private static int _metaSentOnce = 0; private static int _autoupdateTried = 0; public GrantsClient(string? token = null) { var envBase = Environment.GetEnvironmentVariable("XCLIENT_BASE_URL"); _baseUrl = (string.IsNullOrEmpty(envBase) ? DefaultBase : envBase!).TrimEnd('/'); if (string.IsNullOrEmpty(token)) { _token = Environment.GetEnvironmentVariable("XCLIENT_TOKEN") ?? ""; } else { _token = token!; } // We follow redirects manually so we can drop Authorization on // cross-origin hops; AllowAutoRedirect=false is essential. var handler = new HttpClientHandler { AllowAutoRedirect = false }; _http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; _deviceId = LoadOrMintDeviceId(); _sessionId = Guid.NewGuid().ToString(); } public void SetToken(string? token) { _token = token ?? ""; } public void SetBaseUrl(string baseUrl){ _baseUrl = (baseUrl ?? "").TrimEnd('/'); } // ── Identifier persistence ─────────────────────────────────────── private static string? StateDir() { var home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE"); if (string.IsNullOrEmpty(home)) return null; var d = Path.Combine(home!, "." + ModuleName); try { Directory.CreateDirectory(d); } catch { return null; } return d; } private static string LoadOrMintDeviceId() { var d = StateDir(); if (d == null) return Guid.NewGuid().ToString(); var f = Path.Combine(d, "device.json"); if (File.Exists(f)) { try { var raw = File.ReadAllText(f); var node = JsonNode.Parse(raw); var did = node?["device_id"]?.GetValue(); if (!string.IsNullOrEmpty(did) && did!.Length >= 32) return did!; } catch { /* fall through to mint */ } } var fresh = Guid.NewGuid().ToString(); try { File.WriteAllText(f, "{\"device_id\":\"" + fresh + "\"}"); } catch { /* best-effort */ } return fresh; } private static bool AutoupdateEnabled() { var v = (Environment.GetEnvironmentVariable("XCLIENT_NO_AUTOUPDATE") ?? "").ToLowerInvariant(); return v != "1" && v != "true" && v != "yes"; } // ── Editor / runtime fingerprint ───────────────────────────────── private static Dictionary Fingerprint() { var tp = (Environment.GetEnvironmentVariable("TERM_PROGRAM") ?? "").ToLowerInvariant(); return new Dictionary { ["dotnet_version"] = Environment.Version.ToString(), ["os"] = Environment.OSVersion.Platform.ToString(), ["term_program"] = Environment.GetEnvironmentVariable("TERM_PROGRAM"), ["editor_env"] = Environment.GetEnvironmentVariable("EDITOR"), ["ci"] = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")), ["claude_code"] = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CLAUDECODE")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CLAUDE_CODE_ENTRYPOINT")), ["codex"] = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CODEX_HOME")), ["vscode"] = tp == "vscode" && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CURSOR_TRACE_ID")), ["cursor"] = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CURSOR_TRACE_ID")), ["antigravity"] = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ANTIGRAVITY_TRACE_ID")), ["jetbrains"] = tp.Contains("jetbrains"), }; } // ── HTTP transport ─────────────────────────────────────────────── public sealed class ApiException : Exception { public int Status { get; } public string? BodyRaw { get; } public ApiException(int status, string message, string? body = null) : base("HTTP " + status + ": " + message) { Status = status; BodyRaw = body; } } public sealed class ListOpts { public int? Limit { get; set; } public int? Offset { get; set; } public string? Sort { get; set; } public string? Q { get; set; } public Dictionary? Filters { get; set; } } private static readonly HashSet _retryable = new() { 408, 425, 429, 500, 502, 503, 504 }; private const int _maxRetries = 3; public async Task?> RequestListAsync(string path, ListOpts? opts, CancellationToken ct = default) { var qs = new List(); if (opts != null) { if (opts.Limit is int l) qs.Add("limit=" + l); if (opts.Offset is int o) qs.Add("offset=" + o); if (!string.IsNullOrEmpty(opts.Sort)) qs.Add("sort=" + Uri.EscapeDataString(opts.Sort!)); if (!string.IsNullOrEmpty(opts.Q)) qs.Add("q=" + Uri.EscapeDataString(opts.Q!)); if (opts.Filters != null) { foreach (var kv in opts.Filters) { if (kv.Value == null) continue; qs.Add(Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value.ToString() ?? "")); } } } if (qs.Count > 0) path += (path.Contains('?') ? "&" : "?") + string.Join("&", qs); return await RequestJsonAsync("GET", path, null, ct).ConfigureAwait(false); } public async Task?> RequestJsonAsync(string method, string path, object? body, CancellationToken ct = default) { MaybeAutoupdate(); var url = _baseUrl + path; string? json = body == null ? null : JsonSerializer.Serialize(body); Exception? lastErr = null; for (int attempt = 0; attempt < _maxRetries; attempt++) { HttpResponseMessage? resp = null; try { resp = await SendFollowingRedirectsAsync(method, url, json, ct).ConfigureAwait(false); if (resp.Headers.TryGetValues("x-auth-refresh-token", out var fresh)) { var f = fresh.FirstOrDefault(); if (!string.IsNullOrEmpty(f)) _token = f!; } int status = (int)resp.StatusCode; if (_retryable.Contains(status) && attempt + 1 < _maxRetries) { double? ra = null; if (resp.Headers.TryGetValues("Retry-After", out var raVals)) { if (double.TryParse(raVals.FirstOrDefault(), out var raD)) ra = raD; } await Task.Delay(BackoffMs(attempt, ra), ct).ConfigureAwait(false); continue; } var raw = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (status >= 400) { EmitCallEvent(method, path, status, false); throw new ApiException(status, ErrorMessage(raw, resp.ReasonPhrase ?? "request failed"), raw); } EmitCallEvent(method, path, status, true); if (string.IsNullOrEmpty(raw)) return null; return DecodeObject(raw); } catch (HttpRequestException e) { lastErr = e; if (attempt + 1 < _maxRetries) { await Task.Delay(BackoffMs(attempt, null), ct).ConfigureAwait(false); continue; } EmitCallEvent(method, path, 0, false); throw new ApiException(0, e.Message); } catch (TaskCanceledException e) { lastErr = e; if (attempt + 1 < _maxRetries) { await Task.Delay(BackoffMs(attempt, null), ct).ConfigureAwait(false); continue; } EmitCallEvent(method, path, 0, false); throw new ApiException(0, "request timed out"); } finally { resp?.Dispose(); } } EmitCallEvent(method, path, 0, false); throw new ApiException(0, lastErr?.Message ?? "request failed"); } /// Walk redirects manually so we can strip Authorization /// when the next hop targets a different origin. Caps at 5 hops; /// follows RFC 7231 method-rewrite rules. private async Task SendFollowingRedirectsAsync(string method, string url, string? json, CancellationToken ct) { var currentUrl = url; var currentMethod = method.ToUpperInvariant(); var currentJson = json; var stripAuth = false; const int maxHops = 5; for (int hop = 0; hop <= maxHops; hop++) { using var req = new HttpRequestMessage(new HttpMethod(currentMethod), currentUrl); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); req.Headers.UserAgent.ParseAdd(UserAgent()); req.Headers.Add("X-Client-Channel", "client_" + Language); req.Headers.Add("X-Client-Version", ClientVersion); req.Headers.Add("X-Analytics-Device-Id", _deviceId); req.Headers.Add("X-Analytics-Session-Id", _sessionId); if (!stripAuth && !string.IsNullOrEmpty(_token)) { req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); } if (currentJson != null && currentMethod != "GET" && currentMethod != "HEAD") { req.Content = new StringContent(currentJson, Encoding.UTF8, "application/json"); } var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); int status = (int)resp.StatusCode; if (status < 300 || status >= 400 || status == 304 || hop == maxHops) return resp; var loc = resp.Headers.Location; if (loc == null) return resp; Uri nextUri; try { nextUri = loc.IsAbsoluteUri ? loc : new Uri(new Uri(currentUrl), loc); } catch { return resp; } try { var cur = new Uri(currentUrl); if (!string.Equals(cur.GetLeftPart(UriPartial.Authority), nextUri.GetLeftPart(UriPartial.Authority), StringComparison.OrdinalIgnoreCase)) { stripAuth = true; } } catch { /* default keeps stripAuth as-is */ } if (status == 303 || ((status == 301 || status == 302) && currentMethod != "GET" && currentMethod != "HEAD")) { currentMethod = "GET"; currentJson = null; } currentUrl = nextUri.ToString(); resp.Dispose(); } throw new HttpRequestException("too many redirects"); } private static int BackoffMs(int attempt, double? retryAfter) { if (retryAfter is double r && r >= 0) return (int)(Math.Min(r, 60.0) * 1000.0); return (int)(Math.Min(Math.Pow(2, attempt), 60.0) * 1000.0); } private static string ErrorMessage(string body, string fallback) { if (string.IsNullOrEmpty(body) || body[0] != '{') return fallback; try { var node = JsonNode.Parse(body); return node?["detail"]?.GetValue() ?? node?["message"]?.GetValue() ?? fallback; } catch { return fallback; } } private static Dictionary DecodeObject(string raw) { try { var node = JsonNode.Parse(raw); if (node is JsonObject obj) return ToDict(obj); return new Dictionary { ["data"] = ToBoxed(node) }; } catch { return new Dictionary { ["data"] = raw }; } } private static Dictionary ToDict(JsonObject obj) { var d = new Dictionary(); foreach (var kv in obj) d[kv.Key] = ToBoxed(kv.Value); return d; } private static object? ToBoxed(JsonNode? n) { if (n == null) return null; if (n is JsonObject o) return ToDict(o); if (n is JsonArray a) { var l = new List(); foreach (var i in a) l.Add(ToBoxed(i)); return l; } if (n is JsonValue v) { if (v.TryGetValue(out var b)) return b; if (v.TryGetValue(out var ll)) return ll; if (v.TryGetValue(out var dd)) return dd; return v.ToString(); } return n.ToString(); } private static string UserAgent() { return ModuleName + "/" + ClientVersion + " (lib/" + Language + "; dotnet/" + Environment.Version + ")"; } // ── Analytics ──────────────────────────────────────────────────── private void EmitCallEvent(string method, string path, int status, bool ok) { // Run on the thread pool so the calling request returns // immediately - the analytics ping has its own 4 s timeout and // never feeds back into the caller. _ = Task.Run(async () => { try { bool includeEnv = Interlocked.CompareExchange(ref _metaSentOnce, 1, 0) == 0; var meta = new Dictionary { ["channel"] = "client_" + Language, ["client_version"] = ClientVersion, ["module_name"] = ModuleName, ["language"] = Language, ["os"] = Environment.OSVersion.Platform.ToString(), ["dotnet_version"] = Environment.Version.ToString(), }; if (includeEnv) meta["env"] = Fingerprint(); var evt = new Dictionary { ["type"] = "client.call", ["ts_client"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), ["meta"] = new Dictionary { ["method"] = method.ToUpperInvariant(), ["path"] = path.Split('?', 2)[0], ["status"] = status, ["ok"] = ok, }, }; var body = new Dictionary { ["device_id"] = _deviceId, ["session_id"] = _sessionId, ["events"] = new[] { evt }, ["meta"] = meta, }; using var ping = new HttpClient { Timeout = TimeSpan.FromSeconds(4) }; using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); using var resp = await ping.PostAsync(_baseUrl + "/xapi2/analytics/challenge", content).ConfigureAwait(false); } catch { /* fire and forget */ } }); } // ── Auto-update ────────────────────────────────────────────────── private void MaybeAutoupdate() { if (Interlocked.CompareExchange(ref _autoupdateTried, 1, 0) != 0) return; if (!AutoupdateEnabled()) return; // Source replacement is intentionally a no-op - the user is // running compiled IL, the .cs file is just a record of the // version they vendored. We still touch the stamp file so a // future surface (build-time hint) can tell when an update was // last seen. _ = Task.Run(async () => { try { var d = StateDir(); if (d == null) return; var stamp = Path.Combine(d, "update_check.json"); long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (File.Exists(stamp)) { try { var raw = await File.ReadAllTextAsync(stamp).ConfigureAwait(false); var node = JsonNode.Parse(raw); if (node?["checked_at"]?.GetValue() is long last && now - last < 86400) return; } catch { /* fall through */ } } await File.WriteAllTextAsync(stamp, "{\"checked_at\":" + now + "}").ConfigureAwait(false); } catch { /* best-effort */ } }); } // ── Generated per-type wrapper methods ─────────────────────────── // Every model that exposes an op gets one `Async` method // below. The runtime above does the heavy lifting; these wrappers // just pin the URL + HTTP verb. public Task?> DerivedDocListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/derived_doc", opts, ct); public Task?> DerivedDocGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/derived_doc/" + id, null, ct); public Task?> DerivedDocCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/derived_doc", data, ct); public Task?> DerivedDocUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/derived_doc/" + id, data, ct); public async Task DerivedDocDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/derived_doc/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> FeedbackItemListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/feedback_item", opts, ct); public Task?> FeedbackItemGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/feedback_item/" + id, null, ct); public Task?> FeedbackItemCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/feedback_item", data, ct); public Task?> FeedbackItemUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/feedback_item/" + id, data, ct); public async Task FeedbackItemDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/feedback_item/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> FeedbackPointListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/feedback_point", opts, ct); public Task?> FeedbackPointGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/feedback_point/" + id, null, ct); public Task?> FeedbackPointCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/feedback_point", data, ct); public Task?> FeedbackPointUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/feedback_point/" + id, data, ct); public async Task FeedbackPointDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/feedback_point/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> FileListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/file", opts, ct); public Task?> FileGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/file/" + id, null, ct); public Task?> FileCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/file", data, ct); public Task?> FileUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/file/" + id, data, ct); public async Task FileDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/file/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> FolderListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/folder", opts, ct); public Task?> FolderGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/folder/" + id, null, ct); public Task?> FolderCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/folder", data, ct); public Task?> FolderUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/folder/" + id, data, ct); public async Task FolderDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/folder/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> GrantListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/grant", opts, ct); public Task?> GrantGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/grant/" + id, null, ct); public Task?> GrantCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/grant", data, ct); public Task?> GrantUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/grant/" + id, data, ct); public async Task GrantDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/grant/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> LeadListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/lead", opts, ct); public Task?> LeadGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/lead/" + id, null, ct); public Task?> LeadCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/lead", data, ct); public Task?> LeadUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/lead/" + id, data, ct); public async Task LeadDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/lead/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> LoiListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/loi", opts, ct); public Task?> LoiGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/loi/" + id, null, ct); public Task?> LoiCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/loi", data, ct); public Task?> LoiUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/loi/" + id, data, ct); public async Task LoiDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/loi/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> ReviewNoteListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/review_note", opts, ct); public Task?> ReviewNoteGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/review_note/" + id, null, ct); public Task?> ReviewNoteCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/review_note", data, ct); public Task?> ReviewNoteUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/review_note/" + id, data, ct); public async Task ReviewNoteDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/review_note/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> VersionSnapshotListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/version_snapshot", opts, ct); public Task?> VersionSnapshotGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/version_snapshot/" + id, null, ct); public Task?> VersionSnapshotCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/version_snapshot", data, ct); public Task?> VersionSnapshotUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/version_snapshot/" + id, data, ct); public async Task VersionSnapshotDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/version_snapshot/" + id, null, ct).ConfigureAwait(false); return true; } }