184 lines
5.2 KiB
Go
184 lines
5.2 KiB
Go
package svc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"git.threesix.ai/jordan/slack-auth-1770276413/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] + "..."
|
|
}
|