// Package apiclient is a thin HTTP client for the Control API's /api/v1 // surface, shared by the validator-agent and prober binaries. Neither // binary talks to the database directly — this is their only channel to // shared state, keeping both genuinely stateless. package apiclient import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" ) type Client struct { BaseURL string HTTPClient *http.Client // Token, when non-empty, is sent as "Authorization: Bearer " on // every request to the Control API. Token string } func New(baseURL string, timeout time.Duration) *Client { return &Client{BaseURL: baseURL, HTTPClient: &http.Client{Timeout: timeout}} } // Do issues a JSON request and decodes a JSON response into out (if // non-nil). A 204 response is treated as "no content" and out is left // untouched, with ok=false — used for the assignment poll's empty case. func (c *Client) Do(ctx context.Context, method, path string, body, out interface{}) (ok bool, err error) { var reader io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return false, fmt.Errorf("marshal request: %w", err) } reader = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader) if err != nil { return false, fmt.Errorf("build request: %w", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } resp, err := c.HTTPClient.Do(req) if err != nil { return false, fmt.Errorf("%s %s: %w", method, path, err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNoContent { return false, nil } respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return false, fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, string(respBody)) } if out != nil && len(respBody) > 0 { if err := json.Unmarshal(respBody, out); err != nil { return false, fmt.Errorf("%s %s: decode response: %w", method, path, err) } } return true, nil }