taler-merchant-demos

Python-based Frontends for the Demonstration Web site
Log | Files | Refs | README | LICENSE

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 }