network/preseem/client.go
2026-07-19 14:42:02 -05:00

177 lines
4.5 KiB
Go

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
}