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") }