Released v1
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user