// Package httpclient provides a small JSON HTTP client shared by the AI // providers and webhook senders. It bounds response sizes and applies a // configurable timeout so a slow upstream cannot hang a scan or watch frame. package httpclient import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // DefaultTimeout is the per-request timeout when none is configured. const DefaultTimeout = 60 * time.Second // maxResponseBytes caps the body size read from an upstream. AI and webhook // responses are expected to be well under 1 MiB; 4 MiB is a safety valve. const maxResponseBytes = 4 << 20 // Client is a JSON HTTP client with a bounded response size and a timeout. type Client struct { httpc *http.Client } // Option configures a Client (functional options pattern). type Option func(*Client) // WithTimeout overrides the per-request timeout. func WithTimeout(d time.Duration) Option { return func(c *Client) { c.httpc.Timeout = d } } // WithTransport allows injecting a custom http.RoundTripper (useful for // testing with httptest or for TLS/mTLS configuration). func WithTransport(rt http.RoundTripper) Option { return func(c *Client) { c.httpc.Transport = rt } } // New builds a Client with sensible defaults. func New(opts ...Option) *Client { c := &Client{httpc: &http.Client{Timeout: DefaultTimeout}} for _, opt := range opts { opt(c) } return c } // PostJSON sends a JSON payload to url with the given headers and decodes the // response body into out. Non-2xx status codes are errors. func (c *Client) PostJSON(ctx context.Context, url string, headers map[string]string, payload, out any) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("httpclient: encode request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("httpclient: %w", err) } req.Header.Set("Content-Type", "application/json") for k, v := range headers { req.Header.Set(k, v) } resp, err := c.httpc.Do(req) if err != nil { return fmt.Errorf("httpclient: %w", err) } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { return fmt.Errorf("httpclient: read response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("httpclient: %s: %s", resp.Status, strings.TrimSpace(string(data))) } if out != nil { if err := json.Unmarshal(data, out); err != nil { return fmt.Errorf("httpclient: decode response: %w", err) } } return nil }