mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
349 lines
9.6 KiB
Go
349 lines
9.6 KiB
Go
// Package api is the CLI's HTTP client for the public Warmbly REST API.
|
|
//
|
|
// It exists so every command speaks to the API the same way: one place that
|
|
// knows the /v1 prefix, the bearer header, the idempotency header, the error
|
|
// envelope and how to walk a cursor. Nothing here is Warmbly-specific beyond
|
|
// those; the typed commands are a table on top of it.
|
|
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Client is one host's API surface.
|
|
type Client struct {
|
|
BaseURL string
|
|
Token string
|
|
UserAgent string
|
|
HTTP *http.Client
|
|
// Debug prints the request line to stderr, which is the first thing
|
|
// anyone wants when a call goes somewhere unexpected.
|
|
Debug io.Writer
|
|
}
|
|
|
|
func New(baseURL, token, userAgent string) *Client {
|
|
return &Client{
|
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
Token: token,
|
|
UserAgent: userAgent,
|
|
HTTP: &http.Client{Timeout: 120 * time.Second},
|
|
}
|
|
}
|
|
|
|
// Error is a non-2xx response. Every field comes from the API's stable
|
|
// envelope, so a script can branch on Code without reading the prose.
|
|
type Error struct {
|
|
Status int
|
|
Code string
|
|
Message string
|
|
RequestID string
|
|
RetryAfter string
|
|
Method string
|
|
Path string
|
|
Body string
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
msg := e.Message
|
|
if msg == "" {
|
|
msg = strings.TrimSpace(e.Body)
|
|
}
|
|
if msg == "" {
|
|
msg = http.StatusText(e.Status)
|
|
}
|
|
out := fmt.Sprintf("%s %s failed (HTTP %d", e.Method, e.Path, e.Status)
|
|
if e.Code != "" {
|
|
out += " " + e.Code
|
|
}
|
|
out += "): " + msg
|
|
if e.RequestID != "" {
|
|
out += " (request " + e.RequestID + ")"
|
|
}
|
|
if e.Status == http.StatusTooManyRequests && e.RetryAfter != "" {
|
|
out += ". Rate limited; retry after " + e.RetryAfter + "s."
|
|
}
|
|
return out
|
|
}
|
|
|
|
// IsNotFound and IsUnauthorized are what command code branches on.
|
|
func (e *Error) IsNotFound() bool { return e.Status == http.StatusNotFound }
|
|
func (e *Error) IsUnauthorized() bool { return e.Status == http.StatusUnauthorized }
|
|
|
|
// StatusOf returns the HTTP status of an API error, or 0.
|
|
func StatusOf(err error) int {
|
|
var apiErr *Error
|
|
if errors.As(err, &apiErr) {
|
|
return apiErr.Status
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// Request is one call. Path is relative to /v1 unless it already names a
|
|
// version, which is what makes `warmbly api get /campaigns` work.
|
|
type Request struct {
|
|
Method string
|
|
Path string
|
|
Query url.Values
|
|
Body []byte
|
|
Headers map[string]string
|
|
// IdempotencyKey rides the documented header, for retryable writes.
|
|
IdempotencyKey string
|
|
// Anonymous skips the bearer header. Only the sign-in handshake uses it:
|
|
// the CLI has no credential yet, which is the whole point of the flow.
|
|
Anonymous bool
|
|
}
|
|
|
|
// Response is a completed call. Body is the raw payload: JSON for every
|
|
// documented endpoint, but a few stream files, so it is not parsed here.
|
|
type Response struct {
|
|
Status int
|
|
Header http.Header
|
|
Body []byte
|
|
Request *Request
|
|
}
|
|
|
|
// NormalizePath applies the /v1 rule. Exported because `warmbly api` prints
|
|
// the path it is about to call.
|
|
func NormalizePath(path string) string {
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
if strings.HasPrefix(path, "/v") && len(path) > 2 && path[2] >= '0' && path[2] <= '9' {
|
|
return path
|
|
}
|
|
return "/v1" + path
|
|
}
|
|
|
|
func (c *Client) Do(ctx context.Context, req Request) (*Response, error) {
|
|
if c.Token == "" && !req.Anonymous {
|
|
return nil, errors.New("no API token. Run `warmbly auth login`, or set WARMBLY_TOKEN.")
|
|
}
|
|
path := NormalizePath(req.Path)
|
|
full := c.BaseURL + path
|
|
if len(req.Query) > 0 {
|
|
full += "?" + req.Query.Encode()
|
|
}
|
|
|
|
var reader io.Reader
|
|
if len(req.Body) > 0 {
|
|
reader = bytes.NewReader(req.Body)
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, strings.ToUpper(req.Method), full, reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !req.Anonymous {
|
|
httpReq.Header.Set("Authorization", "Bearer "+c.Token)
|
|
}
|
|
httpReq.Header.Set("Accept", "application/json")
|
|
if c.UserAgent != "" {
|
|
httpReq.Header.Set("User-Agent", c.UserAgent)
|
|
}
|
|
if len(req.Body) > 0 && httpReq.Header.Get("Content-Type") == "" {
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if req.IdempotencyKey != "" {
|
|
httpReq.Header.Set("Idempotency-Key", req.IdempotencyKey)
|
|
}
|
|
for k, v := range req.Headers {
|
|
httpReq.Header.Set(k, v)
|
|
}
|
|
|
|
if c.Debug != nil {
|
|
fmt.Fprintf(c.Debug, "* %s %s\n", httpReq.Method, full)
|
|
}
|
|
|
|
resp, err := c.HTTP.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not reach the API at %s: %w\nCheck the host with `warmbly auth status`, or set WARMBLY_API_URL.", c.BaseURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
payload, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading the API response: %w", err)
|
|
}
|
|
if c.Debug != nil {
|
|
fmt.Fprintf(c.Debug, "* HTTP %d (%d bytes)\n", resp.StatusCode, len(payload))
|
|
}
|
|
|
|
out := &Response{Status: resp.StatusCode, Header: resp.Header, Body: payload, Request: &req}
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return out, nil
|
|
}
|
|
|
|
apiErr := &Error{
|
|
Status: resp.StatusCode,
|
|
Method: strings.ToUpper(req.Method),
|
|
Path: path,
|
|
Body: string(payload),
|
|
RetryAfter: resp.Header.Get("Retry-After"),
|
|
}
|
|
var envelope struct {
|
|
Error string `json:"error"`
|
|
Message string `json:"message"`
|
|
Code string `json:"code"`
|
|
RequestID string `json:"request_id"`
|
|
}
|
|
if json.Unmarshal(payload, &envelope) == nil {
|
|
apiErr.Code = envelope.Code
|
|
apiErr.RequestID = envelope.RequestID
|
|
apiErr.Message = envelope.Message
|
|
if apiErr.Message == "" {
|
|
apiErr.Message = envelope.Error
|
|
}
|
|
}
|
|
return out, apiErr
|
|
}
|
|
|
|
// JSON runs a request and decodes the body into v.
|
|
func (c *Client) JSON(ctx context.Context, req Request, v any) error {
|
|
resp, err := c.Do(ctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if v == nil || len(bytes.TrimSpace(resp.Body)) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(resp.Body, v); err != nil {
|
|
return fmt.Errorf("the API returned something that is not JSON: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// paginateRetries is how many rate-limited pages one walk will wait out
|
|
// before giving up. Three covers a walk that crosses a minute boundary or two;
|
|
// beyond that the budget is the problem, not the timing.
|
|
const paginateRetries = 3
|
|
|
|
// listEnvelope is the documented list shape: data plus pagination.
|
|
type listEnvelope struct {
|
|
Data json.RawMessage `json:"data"`
|
|
Pagination struct {
|
|
NextCursor *string `json:"next_cursor"`
|
|
HasMore bool `json:"has_more"`
|
|
} `json:"pagination"`
|
|
}
|
|
|
|
// Paginate walks every page of a list endpoint and returns one merged
|
|
// envelope: data holds every row, pagination reports no more pages. Endpoints
|
|
// that do not use the cursor envelope come back unchanged after one call.
|
|
func (c *Client) Paginate(ctx context.Context, req Request, maxPages int) ([]byte, error) {
|
|
if maxPages <= 0 {
|
|
maxPages = 100
|
|
}
|
|
var merged []json.RawMessage
|
|
// The cursor the walk stopped on, empty when it reached the end. It is
|
|
// what tells the caller a --max-pages cut the list short rather than the
|
|
// data running out.
|
|
nextCursor := ""
|
|
query := url.Values{}
|
|
for k, v := range req.Query {
|
|
query[k] = v
|
|
}
|
|
|
|
// Retries are budgeted across the whole walk, not per page: a limit that
|
|
// never clears has to end as an error rather than looping until maxPages.
|
|
retriesLeft := paginateRetries
|
|
|
|
for page := 0; page < maxPages; page++ {
|
|
req.Query = query
|
|
resp, err := c.Do(ctx, req)
|
|
if err != nil {
|
|
// A long walk will meet the per-key minute budget. The response
|
|
// says how long to wait, so waiting is strictly better than
|
|
// handing back a partial list the caller cannot tell from a
|
|
// complete one.
|
|
wait, ok := retryAfter(err)
|
|
if !ok || retriesLeft == 0 {
|
|
return nil, err
|
|
}
|
|
retriesLeft--
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(wait):
|
|
}
|
|
page--
|
|
continue
|
|
}
|
|
var env listEnvelope
|
|
if jerr := json.Unmarshal(resp.Body, &env); jerr != nil || env.Data == nil {
|
|
// Not a cursor list. One page is the whole answer.
|
|
if page == 0 {
|
|
return resp.Body, nil
|
|
}
|
|
break
|
|
}
|
|
var rows []json.RawMessage
|
|
if err := json.Unmarshal(env.Data, &rows); err != nil {
|
|
if page == 0 {
|
|
return resp.Body, nil
|
|
}
|
|
break
|
|
}
|
|
merged = append(merged, rows...)
|
|
if !env.Pagination.HasMore || env.Pagination.NextCursor == nil || *env.Pagination.NextCursor == "" {
|
|
nextCursor = ""
|
|
break
|
|
}
|
|
nextCursor = *env.Pagination.NextCursor
|
|
query = cloneValues(query)
|
|
query.Set("cursor", *env.Pagination.NextCursor)
|
|
}
|
|
|
|
envelope := struct {
|
|
Data []json.RawMessage `json:"data"`
|
|
Pagination struct {
|
|
Total int `json:"total"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
HasMore bool `json:"has_more"`
|
|
} `json:"pagination"`
|
|
}{Data: merged}
|
|
if envelope.Data == nil {
|
|
envelope.Data = []json.RawMessage{}
|
|
}
|
|
envelope.Pagination.Total = len(merged)
|
|
if nextCursor != "" {
|
|
envelope.Pagination.HasMore = true
|
|
envelope.Pagination.NextCursor = &nextCursor
|
|
}
|
|
return json.Marshal(envelope)
|
|
}
|
|
|
|
// retryAfter reports how long a rate-limited response asked the caller to
|
|
// wait. The wait is capped so a hostile or misconfigured Retry-After cannot
|
|
// park the CLI for an hour.
|
|
func retryAfter(err error) (time.Duration, bool) {
|
|
var apiErr *Error
|
|
if !errors.As(err, &apiErr) || apiErr.Status != http.StatusTooManyRequests {
|
|
return 0, false
|
|
}
|
|
seconds, perr := strconv.Atoi(strings.TrimSpace(apiErr.RetryAfter))
|
|
if perr != nil || seconds <= 0 {
|
|
seconds = 5
|
|
}
|
|
if seconds > 120 {
|
|
seconds = 120
|
|
}
|
|
return time.Duration(seconds) * time.Second, true
|
|
}
|
|
|
|
func cloneValues(in url.Values) url.Values {
|
|
out := url.Values{}
|
|
for k, v := range in {
|
|
out[k] = append([]string(nil), v...)
|
|
}
|
|
return out
|
|
}
|