slack5-1770603014/pkg/svc/client.go
jordan e66ecd00bf
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-09 02:10:15 +00:00

184 lines
5.2 KiB
Go

package svc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"git.threesix.ai/jordan/slack5-1770603014/pkg/httpclient"
)
// Client provides HTTP communication with a sibling service.
// It wraps httpclient.Client with circuit breaker protection and
// automatic service URL resolution.
type Client struct {
serviceName string
baseURL string
httpClient *httpclient.Client
}
// ClientConfig configures the service client.
type ClientConfig struct {
// Timeout for individual requests (default: 10s)
Timeout time.Duration
// MaxRetries for transient failures (default: 3)
MaxRetries int
// CircuitBreaker config (uses defaults if nil)
CircuitBreaker *httpclient.CircuitBreakerConfig
}
// DefaultClientConfig returns sensible defaults for service clients.
func DefaultClientConfig() ClientConfig {
return ClientConfig{
Timeout: 10 * time.Second,
MaxRetries: 3,
}
}
// NewClient creates a new client for communicating with a sibling service.
// The serviceName should match the component name (e.g., "auth-svc").
//
// Returns an error if the service URL is not configured (env var not set).
//
// Example:
//
// client, err := svc.NewClient("auth-svc")
// if err != nil {
// log.Fatal("auth service not available:", err)
// }
func NewClient(serviceName string) (*Client, error) {
return NewClientWithConfig(serviceName, DefaultClientConfig())
}
// NewClientWithConfig creates a new service client with custom configuration.
func NewClientWithConfig(serviceName string, config ClientConfig) (*Client, error) {
url := ServiceURL(serviceName)
if url == "" {
return nil, fmt.Errorf("service %s not configured (missing %s_URL env var)", serviceName, toEnvKey(serviceName))
}
// Apply defaults
if config.Timeout == 0 {
config.Timeout = 10 * time.Second
}
if config.MaxRetries == 0 {
config.MaxRetries = 3
}
// Build circuit breaker config
var cbConfig httpclient.CircuitBreakerConfig
if config.CircuitBreaker != nil {
cbConfig = *config.CircuitBreaker
} else {
cbConfig = httpclient.DefaultCircuitBreakerConfig()
}
httpClient := httpclient.New(httpclient.Config{
Timeout: config.Timeout,
MaxRetries: config.MaxRetries,
CircuitBreaker: httpclient.NewCircuitBreaker(cbConfig),
})
return &Client{
serviceName: serviceName,
baseURL: url,
httpClient: httpClient,
}, nil
}
// Get performs a GET request to the service.
func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) {
return c.Do(ctx, http.MethodGet, path, nil)
}
// Post performs a POST request with JSON body.
func (c *Client) Post(ctx context.Context, path string, body any) (*http.Response, error) {
return c.DoJSON(ctx, http.MethodPost, path, body)
}
// Put performs a PUT request with JSON body.
func (c *Client) Put(ctx context.Context, path string, body any) (*http.Response, error) {
return c.DoJSON(ctx, http.MethodPut, path, body)
}
// Delete performs a DELETE request.
func (c *Client) Delete(ctx context.Context, path string) (*http.Response, error) {
return c.Do(ctx, http.MethodDelete, path, nil)
}
// Do performs an HTTP request to the service.
func (c *Client) Do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
url := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
return c.httpClient.Do(req)
}
// DoJSON performs an HTTP request with a JSON body.
func (c *Client) DoJSON(ctx context.Context, method, path string, body any) (*http.Response, error) {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
url := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
return c.httpClient.Do(req)
}
// ServiceName returns the name of the service this client connects to.
func (c *Client) ServiceName() string {
return c.serviceName
}
// BaseURL returns the base URL of the service.
func (c *Client) BaseURL() string {
return c.baseURL
}
// DecodeResponse decodes a JSON response body into the given target.
// It closes the response body and returns an error if the status code indicates failure.
//
// Returns an error for non-2xx status codes, including the response body in the error message.
func DecodeResponse[T any](resp *http.Response) (T, error) {
var result T
defer resp.Body.Close()
// Check for error status codes before attempting to decode
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return result, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 200))
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return result, fmt.Errorf("decode response: %w", err)
}
return result, nil
}
// truncate limits a string to maxLen characters, adding "..." if truncated.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}