router-plugin/httpclient/client.go (222 lines of code) (raw):
package httpclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/hashicorp/go-retryablehttp"
)
// Client is a wrapper around http.Client with additional functionality
type Client struct {
client *http.Client
baseURL string
headers map[string]string
timeout time.Duration
middlewares []Middleware
retryOptions RetryOptions
}
// ClientOption is a function that configures a Client
type ClientOption func(*Client)
// Middleware is a function that wraps an HTTP request
type Middleware func(req *http.Request) (*http.Request, error)
// Response is a wrapper around http.Response with additional functionality
type Response struct {
StatusCode int
Headers http.Header
Body []byte
}
// New creates a new HTTP client with the given options
func New(options ...ClientOption) *Client {
c := &Client{
client: http.DefaultClient,
headers: make(map[string]string),
timeout: 30 * time.Second,
middlewares: []Middleware{},
retryOptions: DefaultRetryOptions(),
}
for _, option := range options {
option(c)
}
c.client.Timeout = c.timeout
return c
}
// WithBaseURL sets the base URL for the client
func WithBaseURL(url string) ClientOption {
return func(c *Client) {
c.baseURL = url
}
}
// WithTimeout sets the timeout for the client
func WithTimeout(timeout time.Duration) ClientOption {
return func(c *Client) {
c.timeout = timeout
}
}
// WithHeader adds a header to all requests
func WithHeader(key, value string) ClientOption {
return func(c *Client) {
c.headers[key] = value
}
}
// WithHeaders adds multiple headers to all requests
func WithHeaders(headers map[string]string) ClientOption {
return func(c *Client) {
for key, value := range headers {
c.headers[key] = value
}
}
}
// WithMiddleware adds a middleware to the client
func WithMiddleware(middleware Middleware) ClientOption {
return func(c *Client) {
c.middlewares = append(c.middlewares, middleware)
}
}
// Get sends a GET request and returns the response
func (c *Client) Get(ctx context.Context, path string, options ...RequestOption) (*Response, error) {
return c.Request(ctx, http.MethodGet, path, nil, options...)
}
// Post sends a POST request and returns the response
func (c *Client) Post(ctx context.Context, path string, body interface{}, options ...RequestOption) (*Response, error) {
return c.Request(ctx, http.MethodPost, path, body, options...)
}
// Put sends a PUT request and returns the response
func (c *Client) Put(ctx context.Context, path string, body interface{}, options ...RequestOption) (*Response, error) {
return c.Request(ctx, http.MethodPut, path, body, options...)
}
// Delete sends a DELETE request and returns the response
func (c *Client) Delete(ctx context.Context, path string, options ...RequestOption) (*Response, error) {
return c.Request(ctx, http.MethodDelete, path, nil, options...)
}
// Patch sends a PATCH request and returns the response
func (c *Client) Patch(ctx context.Context, path string, body interface{}, options ...RequestOption) (*Response, error) {
return c.Request(ctx, http.MethodPatch, path, body, options...)
}
// Request sends an HTTP request and returns the response
func (c *Client) Request(ctx context.Context, method, path string, body interface{}, options ...RequestOption) (*Response, error) {
var reqBody io.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("error marshaling request body: %w", err)
}
reqBody = bytes.NewBuffer(jsonBody)
}
url := path
if c.baseURL != "" {
url = c.baseURL + path
}
// Use the retryable client if enabled
if c.retryOptions.Enabled {
return c.doRequestWithRetry(ctx, method, url, reqBody, body != nil, options...)
}
// Otherwise use the standard client
return c.doRequest(ctx, method, url, reqBody, body != nil, options...)
}
// doRequest performs the HTTP request without retries
func (c *Client) doRequest(ctx context.Context, method, url string, body io.Reader, hasBody bool, options ...RequestOption) (*Response, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
// Add default headers
for key, value := range c.headers {
req.Header.Set(key, value)
}
// Apply request options
reqOpts := &requestOptions{}
for _, option := range options {
option(reqOpts)
}
// Add request-specific headers
for key, value := range reqOpts.headers {
req.Header.Set(key, value)
}
// Set default Content-Type if body exists and Content-Type is not set
if hasBody && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
// Apply middlewares
for _, middleware := range c.middlewares {
req, err = middleware(req)
if err != nil {
return nil, fmt.Errorf("middleware error: %w", err)
}
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("error executing request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
return &Response{
StatusCode: resp.StatusCode,
Headers: resp.Header,
Body: respBody,
}, nil
}
// doRequestWithRetry performs the HTTP request with retry capability
func (c *Client) doRequestWithRetry(ctx context.Context, method, url string, body io.Reader, hasBody bool, options ...RequestOption) (*Response, error) {
// Create a retryable client
retryClient := createRetryableClient(c.client, c.retryOptions)
// Create a retryable request
var retryReq *retryablehttp.Request
var err error
if body != nil {
// For requests with body, we need to handle the body rewind
if readSeeker, ok := body.(io.ReadSeeker); ok {
// If body is a ReadSeeker, use it directly
retryReq, err = retryablehttp.NewRequest(method, url, readSeeker)
} else {
// Otherwise, read the body into memory
bodyBytes, readErr := io.ReadAll(body)
if readErr != nil {
return nil, fmt.Errorf("error reading request body: %w", readErr)
}
retryReq, err = retryablehttp.NewRequest(method, url, bodyBytes)
}
} else {
retryReq, err = retryablehttp.NewRequest(method, url, nil)
}
if err != nil {
return nil, fmt.Errorf("error creating retryable request: %w", err)
}
// Set context
retryReq = retryReq.WithContext(ctx)
// Add default headers
for key, value := range c.headers {
retryReq.Header.Set(key, value)
}
// Apply request options
reqOpts := &requestOptions{}
for _, option := range options {
option(reqOpts)
}
// Add request-specific headers
for key, value := range reqOpts.headers {
retryReq.Header.Set(key, value)
}
// Set default Content-Type if body exists and Content-Type is not set
if hasBody && retryReq.Header.Get("Content-Type") == "" {
retryReq.Header.Set("Content-Type", "application/json")
}
// Apply middlewares to the underlying request
httpReq := retryReq.Request
for _, middleware := range c.middlewares {
httpReq, err = middleware(httpReq)
if err != nil {
return nil, fmt.Errorf("middleware error: %w", err)
}
}
// Replace the request with the modified one
retryReq.Request = httpReq
// Execute the request
resp, err := retryClient.Do(retryReq)
if err != nil {
return nil, fmt.Errorf("error executing request: %w", err)
}
defer resp.Body.Close()
// Read the response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
return &Response{
StatusCode: resp.StatusCode,
Headers: resp.Header,
Body: respBody,
}, nil
}
// RequestOption is a function that configures a request
type RequestOption func(*requestOptions)
type requestOptions struct {
headers map[string]string
}
// WithRequestHeader adds a header to a specific request
func WithRequestHeader(key, value string) RequestOption {
return func(opts *requestOptions) {
if opts.headers == nil {
opts.headers = make(map[string]string)
}
opts.headers[key] = value
}
}
// Unmarshal decodes the response body into the given value
func (r *Response) Unmarshal(v interface{}) error {
return json.Unmarshal(r.Body, v)
}
// UnmarshalTo is a generic helper to decode the response into a struct
func UnmarshalTo[T any](response *Response) (T, error) {
var result T
err := response.Unmarshal(&result)
return result, err
}
// String returns the response body as a string
func (r *Response) String() string {
return string(r.Body)
}
// IsSuccess returns true if the response status code is in the 2xx range
func (r *Response) IsSuccess() bool {
return r.StatusCode >= 200 && r.StatusCode < 300
}