// Package client talks to Hammerhead's public HTTP API with an API key. package client import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // Client is a Hammerhead API client. Org is the default organization for org-scoped calls. type Client struct { BaseURL string Token string Org string HTTP *http.Client } // New returns a client for the server at baseURL authenticating with an hhk_ API key. func New(baseURL, token, org string) *Client { return &Client{BaseURL: strings.TrimRight(baseURL, "/"), Token: token, Org: org, HTTP: &http.Client{Timeout: 30 * time.Second}} } // APIError is an error response from the server. type APIError struct { Status int Message string } func (e *APIError) Error() string { return fmt.Sprintf("hammerhead: %s (HTTP %d)", e.Message, e.Status) } // IsNotFound reports whether err is a 404 from the server. func IsNotFound(err error) bool { e, ok := err.(*APIError) return ok && e.Status == http.StatusNotFound } func (c *Client) do(method, path string, in, out any) error { var body io.Reader if in != nil { b, err := json.Marshal(in) if err != nil { return err } body = bytes.NewReader(b) } req, err := http.NewRequest(method, c.BaseURL+path, body) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.Token) if in != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.HTTP.Do(req) if err != nil { return err } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) if err != nil { return err } if resp.StatusCode >= 300 { var e struct { Error string `json:"error"` } msg := strings.TrimSpace(string(raw)) if json.Unmarshal(raw, &e) == nil && e.Error != "" { msg = e.Error } return &APIError{resp.StatusCode, msg} } if out != nil && len(raw) > 0 { return json.Unmarshal(raw, out) } return nil } func (c *Client) orgPath(format string, args ...any) (string, error) { if c.Org == "" { return "", fmt.Errorf("no organization set (use --org, HAMMERHEAD_ORG, or an organization key)") } return "/api/orgs/" + url.PathEscape(c.Org) + fmt.Sprintf(format, args...), nil } func esc(id string) string { return url.PathEscape(id) } // ResolveOrg fills in Org when the key can see exactly one organization. func (c *Client) ResolveOrg() error { if c.Org != "" { return nil } orgs, err := c.Orgs() if err != nil { return err } if len(orgs) != 1 { return fmt.Errorf("the key can reach %d organizations; choose one with --org", len(orgs)) } c.Org = orgs[0].ID return nil }