package httpx import ( "context" "fmt" "io" "net/http" "strings" "git.codelab.vc/pkg/httpx/balancer" "git.codelab.vc/pkg/httpx/circuitbreaker" "git.codelab.vc/pkg/httpx/middleware" "git.codelab.vc/pkg/httpx/retry" ) // Client is a high-level HTTP client that composes middleware for retry, // circuit breaking, load balancing, logging, and more. type Client struct { httpClient *http.Client baseURL string errorMapper ErrorMapper balancerCloser *balancer.Closer } // New creates a new Client with the given options. // // The middleware chain is assembled as (outermost → innermost): // // Logging → User Middlewares → Retry → Circuit Breaker → Balancer → Base Transport func New(opts ...Option) *Client { o := &clientOptions{ transport: http.DefaultTransport, } for _, opt := range opts { opt(o) } // Build the middleware chain from inside out. var chain []middleware.Middleware // Balancer (innermost, wraps base transport). var balancerCloser *balancer.Closer if len(o.endpoints) > 0 { var mw middleware.Middleware mw, balancerCloser = balancer.Transport(o.endpoints, o.balancerOpts...) chain = append(chain, mw) } // Circuit breaker wraps balancer. if o.enableCB { chain = append(chain, circuitbreaker.Transport(o.cbOpts...)) } // Retry wraps circuit breaker + balancer. if o.enableRetry { chain = append(chain, retry.Transport(o.retryOpts...)) } // User middlewares. for i := len(o.middlewares) - 1; i >= 0; i-- { chain = append(chain, o.middlewares[i]) } // Logging (outermost). if o.logger != nil { chain = append(chain, middleware.Logging(o.logger)) } // Assemble: chain[last] is outermost. rt := o.transport for _, mw := range chain { rt = mw(rt) } return &Client{ httpClient: &http.Client{ Transport: rt, Timeout: o.timeout, }, baseURL: o.baseURL, errorMapper: o.errorMapper, balancerCloser: balancerCloser, } } // Do executes an HTTP request. func (c *Client) Do(ctx context.Context, req *http.Request) (*Response, error) { req = req.WithContext(ctx) if err := c.resolveURL(req); err != nil { return nil, err } resp, err := c.httpClient.Do(req) if err != nil { return nil, &Error{ Op: req.Method, URL: req.URL.String(), Err: err, } } r := newResponse(resp) if c.errorMapper != nil { if mapErr := c.errorMapper(resp); mapErr != nil { return r, &Error{ Op: req.Method, URL: req.URL.String(), StatusCode: resp.StatusCode, Err: mapErr, } } } return r, nil } // Get performs a GET request to the given URL. func (c *Client) Get(ctx context.Context, url string) (*Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } return c.Do(ctx, req) } // Post performs a POST request to the given URL with the given body. func (c *Client) Post(ctx context.Context, url string, body io.Reader) (*Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) if err != nil { return nil, err } return c.Do(ctx, req) } // Put performs a PUT request to the given URL with the given body. func (c *Client) Put(ctx context.Context, url string, body io.Reader) (*Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body) if err != nil { return nil, err } return c.Do(ctx, req) } // Delete performs a DELETE request to the given URL. func (c *Client) Delete(ctx context.Context, url string) (*Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) if err != nil { return nil, err } return c.Do(ctx, req) } // Close releases resources associated with the Client, such as background // health checker goroutines. It is safe to call multiple times. func (c *Client) Close() { if c.balancerCloser != nil { c.balancerCloser.Close() } } // HTTPClient returns the underlying *http.Client for advanced use cases. // Mutating the returned client may bypass the configured middleware chain. func (c *Client) HTTPClient() *http.Client { return c.httpClient } func (c *Client) resolveURL(req *http.Request) error { if c.baseURL == "" { return nil } // Only resolve relative URLs (no scheme). if req.URL.Scheme == "" && req.URL.Host == "" { path := req.URL.String() if !strings.HasPrefix(path, "/") { path = "/" + path } base := strings.TrimRight(c.baseURL, "/") u, err := req.URL.Parse(base + path) if err != nil { return fmt.Errorf("httpx: resolving URL %q with base %q: %w", path, c.baseURL, err) } req.URL = u } return nil }