github.com/storacha/go-ucanto@v0.7.2/transport/http/channel.go (about)

     1  package http
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"net/http"
     7  	"net/url"
     8  
     9  	"slices"
    10  
    11  	"go.opentelemetry.io/otel"
    12  	"go.opentelemetry.io/otel/propagation"
    13  
    14  	"github.com/storacha/go-ucanto/transport"
    15  )
    16  
    17  // Option is an option configuring a HTTP channel.
    18  type Option func(cfg *chanConfig)
    19  
    20  type chanConfig struct {
    21  	client   *http.Client
    22  	method   string
    23  	statuses []int
    24  	headers  http.Header
    25  }
    26  
    27  // WithClient configures the HTTP client the channel should use to make
    28  // requests.
    29  func WithClient(c *http.Client) Option {
    30  	return func(cfg *chanConfig) {
    31  		cfg.client = c
    32  	}
    33  }
    34  
    35  // WithMethod configures the HTTP method the channel should use when making
    36  // requests.
    37  func WithMethod(method string) Option {
    38  	return func(cfg *chanConfig) {
    39  		cfg.method = method
    40  	}
    41  }
    42  
    43  // WithSuccessStatusCode configures the HTTP status code(s) that will indicate a
    44  // successful request.
    45  func WithSuccessStatusCode(codes ...int) Option {
    46  	return func(cfg *chanConfig) {
    47  		cfg.statuses = codes
    48  	}
    49  }
    50  
    51  // WithHeaders configures additional HTTP headers to send with requests.
    52  func WithHeaders(h http.Header) Option {
    53  	return func(cfg *chanConfig) {
    54  		cfg.headers = h
    55  	}
    56  }
    57  
    58  type Channel struct {
    59  	url      *url.URL
    60  	client   *http.Client
    61  	headers  http.Header
    62  	method   string
    63  	statuses []int
    64  }
    65  
    66  func (c *Channel) Request(ctx context.Context, req transport.HTTPRequest) (transport.HTTPResponse, error) {
    67  	hr, err := http.NewRequestWithContext(ctx, c.method, c.url.String(), req.Body())
    68  	if err != nil {
    69  		return nil, fmt.Errorf("creating HTTP request: %w", err)
    70  	}
    71  
    72  	addAllHeaders(hr.Header, req.Headers(), c.headers)
    73  	injectTraceContext(ctx, hr)
    74  
    75  	res, err := c.client.Do(hr)
    76  	if err != nil {
    77  		return nil, fmt.Errorf("doing HTTP request: %w", err)
    78  	}
    79  	if !slices.Contains(c.statuses, res.StatusCode) {
    80  		return nil, NewHTTPError(fmt.Sprintf("HTTP Request failed. %s %s → %d", hr.Method, c.url.String(), res.StatusCode), res.StatusCode, res.Header)
    81  	}
    82  
    83  	ctx = extractTraceContext(ctx, res.Header)
    84  	return NewResponseWithContext(ctx, res.StatusCode, res.Body, res.Header), nil
    85  }
    86  
    87  func addAllHeaders(dst http.Header, srcs ...http.Header) {
    88  	for _, src := range srcs {
    89  		for name, values := range src {
    90  			for _, value := range values {
    91  				dst.Add(name, value)
    92  			}
    93  		}
    94  	}
    95  }
    96  
    97  func injectTraceContext(ctx context.Context, req *http.Request) {
    98  	if ctx == nil || req == nil {
    99  		return
   100  	}
   101  	otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
   102  }
   103  
   104  func extractTraceContext(ctx context.Context, headers http.Header) context.Context {
   105  	if ctx == nil || headers == nil {
   106  		return ctx
   107  	}
   108  	return otel.GetTextMapPropagator().Extract(ctx, propagation.HeaderCarrier(headers))
   109  }
   110  
   111  var _ transport.Channel = (*Channel)(nil)
   112  
   113  func NewChannel(url *url.URL, options ...Option) *Channel {
   114  	cfg := chanConfig{}
   115  	for _, opt := range options {
   116  		opt(&cfg)
   117  	}
   118  	if cfg.client == nil {
   119  		cfg.client = &http.Client{}
   120  	}
   121  	if cfg.method == "" {
   122  		cfg.method = "POST"
   123  	}
   124  	if len(cfg.statuses) == 0 {
   125  		cfg.statuses = append(cfg.statuses, http.StatusOK)
   126  	}
   127  	return &Channel{
   128  		url:      url,
   129  		client:   cfg.client,
   130  		headers:  cfg.headers,
   131  		method:   cfg.method,
   132  		statuses: cfg.statuses,
   133  	}
   134  }