gitFlow/internal/httpclient/client.go
dimitar b83a4baebd refactor: M1 — extract shared HTTP client from AI package
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.
2026-08-02 09:04:12 +02:00

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
}