177 lines
4.5 KiB
Go
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
|
|
}
|