165 lines
4.0 KiB
Go
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")
|
|
}
|