Files
Steve Cliff b07572fed5 Released v1
2026-02-12 10:37:19 +00:00

165 lines
4.0 KiB
Go

package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"git.franklin.lab/steve.cliff/pcli/model"
)
type Client struct {
BaseURL string
APIKey string
HTTPClient *http.Client
Logger *slog.Logger
}
func NewClient(baseURL, apiKey string, logger *slog.Logger) *Client {
transport := &http.Transport{
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 30 * time.Second,
}
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
},
Logger: logger,
}
}
func (c *Client) Do(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
start := time.Now()
url := c.BaseURL + path
var reqBody io.Reader
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewReader(jsonData)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("x-api-key", c.APIKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
c.Logger.Debug("HTTP request",
slog.String("method", method),
slog.String("path", path),
)
resp, err := c.HTTPClient.Do(req)
if err != nil {
c.Logger.Warn("HTTP request failed",
slog.String("method", method),
slog.String("path", path),
slog.String("error", err.Error()),
)
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
duration := time.Since(start)
c.Logger.Debug("HTTP response",
slog.String("method", method),
slog.String("path", path),
slog.Int("status", resp.StatusCode),
slog.Duration("duration", duration),
)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
contentType := resp.Header.Get("Content-Type")
if len(respBody) > 0 && !isJSONContentType(contentType) && respBody[0] == '<' {
c.Logger.Warn("Non-JSON response received",
slog.String("method", method),
slog.String("path", path),
slog.Int("status", resp.StatusCode),
slog.String("content_type", contentType),
)
return nil, &model.APIError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("unexpected non-JSON response (content-type: %s) — endpoint may not exist", contentType),
}
}
if resp.StatusCode >= 400 {
apiErr := &model.APIError{
StatusCode: resp.StatusCode,
Message: string(respBody),
}
var errResp map[string]any
if json.Unmarshal(respBody, &errResp) == nil {
if msg, ok := errResp["message"].(string); ok {
apiErr.Message = msg
} else if errMsg, ok := errResp["error"].(string); ok {
apiErr.Message = errMsg
}
}
c.Logger.Warn("API error",
slog.String("method", method),
slog.String("path", path),
slog.Int("status", resp.StatusCode),
slog.String("message", apiErr.Message),
)
return nil, apiErr
}
return json.RawMessage(respBody), nil
}
func (c *Client) DoNoBody(ctx context.Context, method, path string) (json.RawMessage, error) {
return c.Do(ctx, method, path, nil)
}
func (c *Client) DoWithFallback(ctx context.Context, method, primary, fallback string, body any) (json.RawMessage, error) {
data, err := c.Do(ctx, method, primary, body)
if err != nil {
if apiErr, ok := err.(*model.APIError); ok && apiErr.StatusCode == 404 {
c.Logger.Debug("Primary path failed, trying fallback",
slog.String("primary", primary),
slog.String("fallback", fallback),
)
return c.Do(ctx, method, fallback, body)
}
return nil, err
}
return data, nil
}
func (c *Client) DoNoBodyWithFallback(ctx context.Context, method, primary, fallback string) (json.RawMessage, error) {
return c.DoWithFallback(ctx, method, primary, fallback, nil)
}
func isJSONContentType(contentType string) bool {
ct := strings.ToLower(strings.TrimSpace(contentType))
return strings.HasPrefix(ct, "application/json") ||
strings.HasPrefix(ct, "text/json")
}