package main import ( "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) const ( defaultBaseURL = "https://api.preseem.com" apiPrefix = "/model/v1" minLimit = 100 maxLimit = 1000 ) type Client struct { BaseURL string Key string HTTP *http.Client } func NewClient() (*Client, error) { key := os.Getenv("PRESEEM_KEY") if key == "" { return nil, errors.New("PRESEEM_KEY env var is not set") } base := os.Getenv("PRESEEM_URL") if base == "" { base = defaultBaseURL } return &Client{ BaseURL: strings.TrimRight(base, "/"), Key: key, HTTP: &http.Client{Timeout: 60 * time.Second}, }, nil } // get performs GET against an API path (must start with /model/v1/...). // The path should already be URL-encoded — callers building paths with // user-supplied IDs must use pathID(). // // If raw is non-nil it receives the raw response body. If out is non-nil // it is JSON-decoded. func (c *Client) get(path string, out any, raw *[]byte) error { u := c.BaseURL + path req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return err } req.Header.Set("Authorization", "Basic "+basicAuth(c.Key)) req.Header.Set("Accept", "application/json") resp, err := c.HTTP.Do(req) if err != nil { return err } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { return fmt.Errorf("GET %s: %s: %s", u, resp.Status, strings.TrimSpace(truncate(string(data), 400))) } if raw != nil { *raw = data } if out != nil && len(data) > 0 { if err := json.Unmarshal(data, out); err != nil { return fmt.Errorf("decode %s: %w (body: %s)", u, err, truncate(string(data), 200)) } } return nil } // del performs DELETE against an API path. func (c *Client) del(path string) error { u := c.BaseURL + path req, err := http.NewRequest(http.MethodDelete, u, nil) if err != nil { return err } req.Header.Set("Authorization", "Basic "+basicAuth(c.Key)) resp, err := c.HTTP.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { data, _ := io.ReadAll(resp.Body) return fmt.Errorf("DELETE %s: %s: %s", u, resp.Status, strings.TrimSpace(truncate(string(data), 400))) } return nil } // listPaged fetches a single page of a list endpoint. base is e.g. // "/accounts" (no /model/v1 prefix, no leading query). page/limit are // added as query params; if limit<=0 the server default is used. func (c *Client) listPaged(base string, page, limit int, out any, raw *[]byte) error { q := url.Values{} if page > 0 { q.Set("page", strconv.Itoa(page)) } if limit > 0 { q.Set("limit", strconv.Itoa(limit)) } path := apiPrefix + base if s := q.Encode(); s != "" { path += "?" + s } return c.get(path, out, raw) } // listAll auto-paginates a list endpoint into one merged result. It // repeatedly invokes fetchPage(page) until the returned paginator says // we've passed page_count. The collector appends each page's data // items; the final paginator is returned for synthetic display. func (c *Client) listAll(base string, limit int, decode func([]byte) ([]json.RawMessage, Paginator, error)) ([]json.RawMessage, Paginator, error) { var all []json.RawMessage var last Paginator page := 1 for { var raw []byte if err := c.listPaged(base, page, limit, nil, &raw); err != nil { return nil, Paginator{}, err } items, p, err := decode(raw) if err != nil { return nil, Paginator{}, fmt.Errorf("decode page %d: %w", page, err) } all = append(all, items...) last = p // Stop if server reports no further pages, or returned empty. if len(items) == 0 || p.PageCount == 0 || page >= p.PageCount { break } page++ } // Synthetic paginator for display: collapsed to one logical page. last.Page = 1 last.PageCount = 1 last.Limit = len(all) last.TotalCount = len(all) return all, last, nil } func basicAuth(key string) string { // Username = key, password empty, per Preseem spec. return base64.StdEncoding.EncodeToString([]byte(key + ":")) } // pathID URL-encodes an ID for inclusion in a path segment. Per spec, // IDs may contain '/', which must become '%2F'. func pathID(id string) string { return url.PathEscape(id) } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." } func validateLimit(limit int) error { if limit == 0 { return nil } if limit < minLimit || limit > maxLimit { return fmt.Errorf("--limit must be between %d and %d (got %d)", minLimit, maxLimit, limit) } return nil }