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 }