// Drop-in Go client library for the Sourdough Tracker HTTP API. // // Save this file alongside your code as `sourdough_client.go` (or in its // own subpackage) and use the Client type: // // import "yourproject/sourdough_client" // // c := sourdough_client.New("pat_...") // rows, err := c.AccountList(&sourdough_client.ListOpts{Limit: 20, Sort: "-created_at"}) // // Every endpoint exposed by the HTTP API is wrapped as a // `` method on Client. List endpoints take *ListOpts; // get/update/delete endpoints take the row id as their first argument. // // Provided as-is, with no warranty. Vendor freely; modify as needed. // Targets Go 1.21+; uses only the standard library. // // DO NOT EDIT THIS FILE MANUALLY - re-download from the docs site. // Local edits will be overwritten by the once-per-day version check. package sourdough_client import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "runtime" "strconv" "strings" "sync" "time" ) // ── Identity (substituted at generation time) ─────────────────────── const ( AppSlug = "sourdough" AppName = "Sourdough Tracker" ModuleName = "sourdough_client" ClientVersion = "0.3.13" Language = "go" defaultBase = "https://sourdoughtracker.com" ) // TypesJSON is the 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. const 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}]}}` // ── Configuration ────────────────────────────────────────────────── // ListOpts mirrors the standard query parameters the list endpoints // accept. Filters carries arbitrary additional ?key=value pairs. type ListOpts struct { Limit int Offset int Sort string Q string Filters map[string]any } // Client is the per-app HTTP client. Reuse across requests; safe for // concurrent use. type Client struct { HTTPClient *http.Client BaseURL string Token string once sync.Once deviceID string sessID string } // New returns a Client wired to the host this library was generated // against. Pass a personal access token; an empty string falls back to // the XCLIENT_TOKEN environment variable. func New(token string) *Client { base := os.Getenv("XCLIENT_BASE_URL") if base == "" { base = defaultBase } if token == "" { token = os.Getenv("XCLIENT_TOKEN") } return &Client{ HTTPClient: &http.Client{Timeout: 30 * time.Second}, BaseURL: strings.TrimRight(base, "/"), Token: token, } } func (c *Client) initIDs() { c.once.Do(func() { c.deviceID = loadOrMintDeviceID() c.sessID = mintUUID() }) } // ── Identifier persistence ───────────────────────────────────────── func stateDir() string { home, err := os.UserHomeDir() if err != nil || home == "" { return "" } d := filepath.Join(home, "."+ModuleName) _ = os.MkdirAll(d, 0o700) return d } func mintUUID() string { var b [16]byte _, _ = rand.Read(b[:]) b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 h := hex.EncodeToString(b[:]) return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32] } func loadOrMintDeviceID() string { dir := stateDir() if dir == "" { return mintUUID() } f := filepath.Join(dir, "device.json") if raw, err := os.ReadFile(f); err == nil { var blob struct { DeviceID string `json:"device_id"` } if json.Unmarshal(raw, &blob) == nil && len(blob.DeviceID) >= 32 { return blob.DeviceID } } id := mintUUID() body, _ := json.Marshal(map[string]string{"device_id": id}) _ = os.WriteFile(f, body, 0o600) return id } // ── Telemetry toggles ────────────────────────────────────────────── func autoupdateEnabled() bool { v := strings.ToLower(os.Getenv("XCLIENT_NO_AUTOUPDATE")) return v != "1" && v != "true" && v != "yes" } // ── Editor / runtime fingerprint ─────────────────────────────────── func fingerprint() map[string]any { out := map[string]any{ "go_version": runtime.Version(), "os": runtime.GOOS, "arch": runtime.GOARCH, } out["term_program"] = os.Getenv("TERM_PROGRAM") out["editor_env"] = os.Getenv("EDITOR") out["ci"] = os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" out["claude_code"] = os.Getenv("CLAUDECODE") != "" || os.Getenv("CLAUDE_CODE_ENTRYPOINT") != "" out["codex"] = os.Getenv("CODEX_HOME") != "" tp := strings.ToLower(os.Getenv("TERM_PROGRAM")) out["vscode"] = tp == "vscode" && os.Getenv("CURSOR_TRACE_ID") == "" out["cursor"] = os.Getenv("CURSOR_TRACE_ID") != "" out["antigravity"] = os.Getenv("ANTIGRAVITY_TRACE_ID") != "" out["jetbrains"] = strings.Contains(tp, "jetbrains") return out } // ── HTTP transport ───────────────────────────────────────────────── // APIError wraps a non-2xx response. type APIError struct { Status int Message string Body any } func (e *APIError) Error() string { return fmt.Sprintf("HTTP %d: %s", e.Status, e.Message) } var retryableStatus = map[int]struct{}{ 408: {}, 425: {}, 429: {}, 500: {}, 502: {}, 503: {}, 504: {}, } func backoff(attempt int, retryAfterSec float64) time.Duration { if retryAfterSec >= 0 { if retryAfterSec > 60 { retryAfterSec = 60 } return time.Duration(retryAfterSec * float64(time.Second)) } delay := float64(int(1) << uint(attempt)) if delay > 60 { delay = 60 } return time.Duration(delay * float64(time.Second)) } func (c *Client) userAgent() string { return fmt.Sprintf("%s/%s (lib/%s; go/%s; %s)", ModuleName, ClientVersion, Language, runtime.Version(), runtime.GOOS) } // requestJSON fires one method+path against the API, JSON in / JSON // out. Pass nil body for read-only verbs. func (c *Client) requestJSON(method, path string, body any) (map[string]any, error) { c.maybeAutoupdate() c.initIDs() u := c.BaseURL + path var data []byte if body != nil { var err error data, err = json.Marshal(body) if err != nil { return nil, err } } const maxRetries = 3 var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { var bodyReader io.Reader if data != nil { bodyReader = bytes.NewReader(data) } req, err := http.NewRequest(method, u, bodyReader) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", c.userAgent()) req.Header.Set("X-Client-Channel", "client_"+Language) req.Header.Set("X-Client-Version", ClientVersion) req.Header.Set("X-Analytics-Device-Id", c.deviceID) req.Header.Set("X-Analytics-Session-Id", c.sessID) if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } if data != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.HTTPClient.Do(req) if err != nil { lastErr = err if attempt+1 < maxRetries { time.Sleep(backoff(attempt, -1)) continue } c.emitCallEvent(method, path, 0, false) return nil, &APIError{Status: 0, Message: err.Error()} } raw, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() c.maybePersistRefresh(resp.Header) if _, retry := retryableStatus[resp.StatusCode]; retry && attempt+1 < maxRetries { ra := -1.0 if v := resp.Header.Get("Retry-After"); v != "" { if f, err2 := strconv.ParseFloat(v, 64); err2 == nil { ra = f } } time.Sleep(backoff(attempt, ra)) continue } if resp.StatusCode >= 400 { var parsed any _ = json.Unmarshal(raw, &parsed) msg := http.StatusText(resp.StatusCode) if m, ok := parsed.(map[string]any); ok { if d, ok2 := m["detail"].(string); ok2 { msg = d } else if d, ok2 := m["message"].(string); ok2 { msg = d } } c.emitCallEvent(method, path, resp.StatusCode, false) return nil, &APIError{Status: resp.StatusCode, Message: msg, Body: parsed} } c.emitCallEvent(method, path, resp.StatusCode, true) if len(raw) == 0 { return nil, nil } var out map[string]any if err := json.Unmarshal(raw, &out); err != nil { return nil, err } return out, nil } if lastErr != nil { c.emitCallEvent(method, path, 0, false) return nil, &APIError{Status: 0, Message: lastErr.Error()} } return nil, errors.New("request failed") } // requestList wraps requestJSON for list endpoints, lifting *ListOpts // into the query string. func (c *Client) requestList(path string, opts *ListOpts) (map[string]any, error) { q := url.Values{} if opts != nil { if opts.Limit > 0 { q.Set("limit", strconv.Itoa(opts.Limit)) } if opts.Offset > 0 { q.Set("offset", strconv.Itoa(opts.Offset)) } if opts.Sort != "" { q.Set("sort", opts.Sort) } if opts.Q != "" { q.Set("q", opts.Q) } for k, v := range opts.Filters { if v == nil { continue } q.Set(k, fmt.Sprint(v)) } } if encoded := q.Encode(); encoded != "" { path = path + "?" + encoded } return c.requestJSON("GET", path, nil) } func (c *Client) maybePersistRefresh(h http.Header) { if v := h.Get("x-auth-refresh-token"); v != "" { c.Token = v } } // ── Analytics ────────────────────────────────────────────────────── var metaSentOnce sync.Once func (c *Client) emitCallEvent(method, pathStr string, status int, ok bool) { go func() { defer func() { _ = recover() }() meta := map[string]any{ "channel": "client_" + Language, "client_version": ClientVersion, "module_name": ModuleName, "language": Language, "os": runtime.GOOS, "go_version": runtime.Version(), } var addEnv bool metaSentOnce.Do(func() { addEnv = true }) if addEnv { meta["env"] = fingerprint() } evt := map[string]any{ "type": "client.call", "ts_client": time.Now().Unix(), "meta": map[string]any{ "method": strings.ToUpper(method), "path": strings.SplitN(pathStr, "?", 2)[0], "status": status, "ok": ok, }, } body := map[string]any{ "device_id": c.deviceID, "session_id": c.sessID, "events": []any{evt}, "meta": meta, } raw, _ := json.Marshal(body) req, err := http.NewRequest("POST", c.BaseURL+"/xapi2/analytics/challenge", bytes.NewReader(raw)) if err != nil { return } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", c.userAgent()) hc := &http.Client{Timeout: 4 * time.Second} resp, err := hc.Do(req) if err != nil { return } _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() }() } // ── Auto-update ──────────────────────────────────────────────────── var autoupdateOnce sync.Once func (c *Client) maybeAutoupdate() { autoupdateOnce.Do(func() { if !autoupdateEnabled() { return } go c.runAutoupdate() }) } func (c *Client) runAutoupdate() { defer func() { _ = recover() }() dir := stateDir() if dir == "" { return } stamp := filepath.Join(dir, "update_check.json") if raw, err := os.ReadFile(stamp); err == nil { var blob struct { CheckedAt int64 `json:"checked_at"` } if json.Unmarshal(raw, &blob) == nil { if time.Now().Unix()-blob.CheckedAt < 86400 { return } } } hc := &http.Client{Timeout: 6 * time.Second} resp, err := hc.Get(c.BaseURL + "/xapi2/clients/version") if err != nil { return } raw, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() var payload struct { Version string `json:"version"` } if json.Unmarshal(raw, &payload) != nil { return } stampBody, _ := json.Marshal(map[string]any{"checked_at": time.Now().Unix()}) _ = os.WriteFile(stamp, stampBody, 0o600) if payload.Version == "" || payload.Version == ClientVersion { return } // Source replacement is intentionally a no-op in Go - the user is // running a compiled binary, the .go file on disk is just a record // of the version they vendored. Surface the new version through // the next build. } // LogEntryList lists log_entry rows. Pass nil opts for defaults. func (c *Client) LogEntryList(opts *ListOpts) (map[string]any, error) { return c.requestList("/xapi2/data/log_entry", opts) } // LogEntryGet fetches one log_entry row by id. func (c *Client) LogEntryGet(id string) (map[string]any, error) { return c.requestJSON("GET", "/xapi2/data/log_entry/"+id, nil) } // LogEntryCreate creates a new log_entry row. func (c *Client) LogEntryCreate(data map[string]any) (map[string]any, error) { return c.requestJSON("POST", "/xapi2/data/log_entry", data) } // LogEntryUpdate patches an existing log_entry row. func (c *Client) LogEntryUpdate(id string, data map[string]any) (map[string]any, error) { return c.requestJSON("PATCH", "/xapi2/data/log_entry/"+id, data) } // LogEntryDelete deletes a log_entry row. func (c *Client) LogEntryDelete(id string) error { _, err := c.requestJSON("DELETE", "/xapi2/data/log_entry/"+id, nil) return err } // SourdoughList lists sourdough rows. Pass nil opts for defaults. func (c *Client) SourdoughList(opts *ListOpts) (map[string]any, error) { return c.requestList("/xapi2/data/sourdough", opts) } // SourdoughGet fetches one sourdough row by id. func (c *Client) SourdoughGet(id string) (map[string]any, error) { return c.requestJSON("GET", "/xapi2/data/sourdough/"+id, nil) } // SourdoughCreate creates a new sourdough row. func (c *Client) SourdoughCreate(data map[string]any) (map[string]any, error) { return c.requestJSON("POST", "/xapi2/data/sourdough", data) } // SourdoughUpdate patches an existing sourdough row. func (c *Client) SourdoughUpdate(id string, data map[string]any) (map[string]any, error) { return c.requestJSON("PATCH", "/xapi2/data/sourdough/"+id, data) } // SourdoughDelete deletes a sourdough row. func (c *Client) SourdoughDelete(id string) error { _, err := c.requestJSON("DELETE", "/xapi2/data/sourdough/"+id, nil) return err }