Pull the private `client`/`postJSON` from `internal/ai` into a standalone `internal/httpclient` package so both the AI providers and the upcoming webhook senders can share the same bounded-reader, timeout-guarded JSON HTTP client without introducing a dependency cycle. Changes: - internal/httpclient: Client struct with PostJSON(ctx, url, headers, payload, out), functional options WithTimeout/WithTransport, a 4 MiB response cap, and a 60s default timeout - internal/ai: three providers (OpenAI, Ollama, Anthropic) now embed an `*httpclient.Client` (field renamed from `client` to `http`); the old `client.go` is deleted - All 11 test packages pass (ai tests are byte-for-byte unaffected) This zero-behaviour refactor unblocks the webhook package distributed in M5, which needs the exact same JSON-post-and-decode helper.
91 lines
2.5 KiB
Go
91 lines
2.5 KiB
Go
// 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 err := json.Unmarshal(data, out); err != nil {
|
|
return fmt.Errorf("httpclient: decode response: %w", err)
|
|
}
|
|
return nil
|
|
}
|