package svc import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" "git.threesix.ai/jordan/slack5-1770541397/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] + "..." }