// Drop-in C# / .NET client library for the Sourdough Tracker HTTP API. // // Save this file under your project as `SourdoughClient.cs`, in a // directory matching `namespace sourdough_client;`, then call the // SourdoughClient class: // // using sourdough_client; // var c = new SourdoughClient("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 SourdoughClient. 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 sourdough_client; /// API client wrapper for Sourdough Tracker. public sealed class SourdoughClient { public const string AppSlug = "sourdough"; public const string AppName = "Sourdough Tracker"; public const string ModuleName = "sourdough_client"; public const string ClientVersion = "0.3.13"; public const string Language = "csharp"; private const string DefaultBase = "https://sourdoughtracker.com"; /// 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 = "{\"log_entry\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"kind\",\"title\",\"body\",\"occurred_at\",\"rise_pct\",\"aroma_score\",\"bubble_activity\",\"hydration_pct\",\"flour_used\",\"water_temp_c\",\"ambient_temp_c\",\"feeding_ratio\",\"bake_recipe\",\"bake_outcome\",\"loaf_count\",\"tags\"],\"update_fields\":[\"kind\",\"title\",\"body\",\"occurred_at\",\"rise_pct\",\"aroma_score\",\"bubble_activity\",\"hydration_pct\",\"flour_used\",\"water_temp_c\",\"ambient_temp_c\",\"feeding_ratio\",\"bake_recipe\",\"bake_outcome\",\"loaf_count\",\"tags\"],\"allowed_filters\":[\"data__parent_id\",\"data__kind\",\"data__bake_outcome\",\"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\":8000},{\"name\":\"kind\",\"type\":\"enum\",\"values\":[\"feed\",\"bake\",\"observation\",\"milestone\",\"photo\"]},{\"name\":\"tags\",\"type\":\"tags\"},{\"name\":\"title\",\"type\":\"string\",\"max_len\":200},{\"name\":\"rise_pct\",\"type\":\"number\"},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"sourdough\",\"owned\":true,\"optional\":false}},{\"name\":\"flour_used\",\"type\":\"string\",\"max_len\":200},{\"name\":\"loaf_count\",\"type\":\"number\"},{\"name\":\"aroma_score\",\"type\":\"number\"},{\"name\":\"bake_recipe\",\"type\":\"string\",\"max_len\":4000},{\"name\":\"occurred_at\",\"type\":\"string\",\"max_len\":32},{\"name\":\"bake_outcome\",\"type\":\"enum\",\"values\":[\"amazing\",\"great\",\"ok\",\"dense\",\"flat\",\"undercooked\",\"overcooked\"]},{\"name\":\"water_temp_c\",\"type\":\"number\"},{\"name\":\"feeding_ratio\",\"type\":\"string\",\"max_len\":32},{\"name\":\"hydration_pct\",\"type\":\"number\"},{\"name\":\"ambient_temp_c\",\"type\":\"number\"},{\"name\":\"bubble_activity\",\"type\":\"number\"}]},\"sourdough\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"name\",\"slug\",\"source\",\"started_at\",\"flour_type\",\"hydration_pct\",\"feeding_ratio\",\"feeding_freq_hours\",\"ambient_temp_c\",\"last_fed_at\",\"retired\",\"favorite\",\"tags\",\"color\",\"notes\"],\"update_fields\":[\"name\",\"slug\",\"source\",\"started_at\",\"flour_type\",\"hydration_pct\",\"feeding_ratio\",\"feeding_freq_hours\",\"ambient_temp_c\",\"last_fed_at\",\"retired\",\"favorite\",\"tags\",\"color\",\"notes\"],\"allowed_filters\":[\"data__name\",\"data__slug\",\"data__flour_type\",\"data__retired\",\"data__favorite\",\"data__tags\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"updated_at\",\"data__name\",\"data__started_at\",\"data__last_fed_at\"],\"default_sort\":\"data__name\",\"max_limit\":200,\"fields\":[{\"name\":\"mode\",\"type\":\"enum\",\"values\":[\"establishing\",\"maintenance\",\"activating\",\"baking\"]},{\"name\":\"name\",\"type\":\"string\",\"max_len\":120},{\"name\":\"slug\",\"type\":\"string\",\"max_len\":120},{\"name\":\"tags\",\"type\":\"tags\"},{\"name\":\"color\",\"type\":\"string\",\"max_len\":24},{\"name\":\"notes\",\"type\":\"string\",\"max_len\":8000},{\"name\":\"source\",\"type\":\"string\",\"max_len\":200},{\"name\":\"retired\",\"type\":\"bool\"},{\"name\":\"favorite\",\"type\":\"bool\"},{\"name\":\"flour_type\",\"type\":\"enum\",\"values\":[\"white\",\"whole_wheat\",\"rye\",\"spelt\",\"einkorn\",\"khorasan\",\"mixed\",\"other\"]},{\"name\":\"started_at\",\"type\":\"string\",\"max_len\":32},{\"name\":\"last_fed_at\",\"type\":\"string\",\"max_len\":32},{\"name\":\"establish_day\",\"type\":\"number\"},{\"name\":\"feeding_ratio\",\"type\":\"string\",\"max_len\":32},{\"name\":\"hydration_pct\",\"type\":\"number\"},{\"name\":\"ambient_temp_c\",\"type\":\"number\"},{\"name\":\"computed_state\",\"type\":\"string\",\"max_len\":64},{\"name\":\"bake_target_date\",\"type\":\"string\",\"max_len\":32},{\"name\":\"feeding_freq_hours\",\"type\":\"number\"},{\"name\":\"establish_started_at\",\"type\":\"string\",\"max_len\":32},{\"name\":\"establish_step_done_at\",\"type\":\"string\",\"max_len\":64},{\"name\":\"establish_next_remind_at\",\"type\":\"string\",\"max_len\":64}]}}"; 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 SourdoughClient(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?> LogEntryListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/log_entry", opts, ct); public Task?> LogEntryGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/log_entry/" + id, null, ct); public Task?> LogEntryCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/log_entry", data, ct); public Task?> LogEntryUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/log_entry/" + id, data, ct); public async Task LogEntryDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/log_entry/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> SourdoughListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/sourdough", opts, ct); public Task?> SourdoughGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/sourdough/" + id, null, ct); public Task?> SourdoughCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/sourdough", data, ct); public Task?> SourdoughUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/sourdough/" + id, data, ct); public async Task SourdoughDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/sourdough/" + id, null, ct).ConfigureAwait(false); return true; } }