client.go (4232B)
1 // Package backend implements the Merchant backend calls used by the demos. 2 package backend 3 4 import ( 5 "bytes" 6 "context" 7 "encoding/json" 8 "fmt" 9 "io" 10 "net/http" 11 "net/url" 12 "strings" 13 "time" 14 ) 15 16 const maxResponseSize = 8 << 20 17 18 // Client is a small JSON client for one Merchant backend instance. 19 type Client struct { 20 baseURL *url.URL 21 apiKey string 22 http *http.Client 23 } 24 25 // Error describes either a non-success HTTP response or invalid backend JSON. 26 type Error struct { 27 Status int 28 Hint string 29 Body any 30 Err error 31 } 32 33 func (e *Error) Error() string { 34 if e.Hint != "" { 35 return e.Hint 36 } 37 if e.Err != nil { 38 return e.Err.Error() 39 } 40 if e.Status != 0 { 41 return fmt.Sprintf("backend returned HTTP status %d", e.Status) 42 } 43 return "merchant backend request failed" 44 } 45 46 func (e *Error) Unwrap() error { return e.Err } 47 48 // New constructs a client. A trailing slash is added so relative Merchant API 49 // endpoints resolve below the configured instance URL. 50 func New(rawURL, apiKey string) (*Client, error) { 51 if !strings.HasSuffix(rawURL, "/") { 52 rawURL += "/" 53 } 54 base, err := url.Parse(rawURL) 55 if err != nil { 56 return nil, fmt.Errorf("parse backend URL: %w", err) 57 } 58 if base.Scheme != "http" && base.Scheme != "https" { 59 return nil, fmt.Errorf("backend URL must use http or https") 60 } 61 if base.Host == "" { 62 return nil, fmt.Errorf("backend URL has no host") 63 } 64 return &Client{ 65 baseURL: base, 66 apiKey: apiKey, 67 http: &http.Client{ 68 Timeout: 30 * time.Second, 69 CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, 70 }, 71 }, nil 72 } 73 74 func (c *Client) endpoint(path string, query url.Values) string { 75 rel := &url.URL{Path: strings.TrimPrefix(path, "/"), RawQuery: query.Encode()} 76 return c.baseURL.ResolveReference(rel).String() 77 } 78 79 // URL resolves a public Merchant endpoint below the configured base URL. 80 func (c *Client) URL(path string, query url.Values) string { 81 return c.endpoint(path, query) 82 } 83 84 // Get issues a GET and decodes a successful JSON response into result. 85 func (c *Client) Get(ctx context.Context, path string, query url.Values, result any) error { 86 return c.do(ctx, http.MethodGet, path, query, nil, result) 87 } 88 89 // Post issues a JSON POST and decodes a successful JSON response into result. 90 // A nil result discards the response body. 91 func (c *Client) Post(ctx context.Context, path string, payload, result any) error { 92 return c.do(ctx, http.MethodPost, path, nil, payload, result) 93 } 94 95 func (c *Client) do(ctx context.Context, method, path string, query url.Values, payload, result any) error { 96 var body io.Reader 97 if payload != nil { 98 encoded, err := json.Marshal(payload) 99 if err != nil { 100 return fmt.Errorf("encode backend request: %w", err) 101 } 102 body = bytes.NewReader(encoded) 103 } 104 req, err := http.NewRequestWithContext(ctx, method, c.endpoint(path, query), body) 105 if err != nil { 106 return fmt.Errorf("create backend request: %w", err) 107 } 108 req.Header.Set("Accept", "application/json") 109 if payload != nil { 110 req.Header.Set("Content-Type", "application/json") 111 } 112 if c.apiKey != "" { 113 req.Header.Set("Authorization", "Bearer "+c.apiKey) 114 } 115 resp, err := c.http.Do(req) 116 if err != nil { 117 return &Error{Hint: "Could not establish connection to backend", Err: err} 118 } 119 defer resp.Body.Close() 120 limited := io.LimitReader(resp.Body, maxResponseSize+1) 121 data, err := io.ReadAll(limited) 122 if err != nil { 123 return &Error{Status: resp.StatusCode, Hint: "Could not read response from backend", Err: err} 124 } 125 if len(data) > maxResponseSize { 126 return &Error{Status: resp.StatusCode, Hint: "Backend response is too large"} 127 } 128 if resp.StatusCode < 200 || resp.StatusCode >= 300 { 129 return responseError(resp.StatusCode, data) 130 } 131 if result == nil || len(data) == 0 { 132 return nil 133 } 134 if err := json.Unmarshal(data, result); err != nil { 135 return &Error{Status: resp.StatusCode, Hint: "Could not parse response from backend", Err: err} 136 } 137 return nil 138 } 139 140 func responseError(status int, data []byte) error { 141 body := make(map[string]any) 142 _ = json.Unmarshal(data, &body) 143 hint, _ := body["hint"].(string) 144 if hint == "" { 145 hint = fmt.Sprintf("Backend returned HTTP status %d", status) 146 } 147 return &Error{Status: status, Hint: hint, Body: body} 148 }