client.go raw

   1  // Package bunny provides functionality to interact with the Bunny CDN HTTP API.
   2  package bunny
   3  
   4  import (
   5  	"bytes"
   6  	"context"
   7  	"encoding/json"
   8  	"errors"
   9  	"fmt"
  10  	"io"
  11  	"mime"
  12  	"net/http"
  13  	"net/http/httputil"
  14  	"net/url"
  15  	"time"
  16  
  17  	"github.com/google/go-querystring/query"
  18  	"github.com/google/uuid"
  19  )
  20  
  21  const (
  22  	// BaseURL is the base URL of the Bunny CDN HTTP API.
  23  	BaseURL = "https://api.bunny.net"
  24  	// AccessKeyHeaderKey is the name of the HTTP header that contains the Bunny API key.
  25  	AccessKeyHeaderKey = "AccessKey"
  26  	// DefaultUserAgent is the default value of the sent HTTP User-Agent header.
  27  	DefaultUserAgent = "bunny-go"
  28  )
  29  
  30  const (
  31  	hdrContentTypeName = "Content-Type"
  32  	contentTypeJSON    = "application/json"
  33  )
  34  
  35  // Logf is a log function signature.
  36  type Logf func(format string, v ...any)
  37  
  38  // Client is a Bunny CDN HTTP API Client.
  39  type Client struct {
  40  	baseURL *url.URL
  41  	apiKey  string
  42  
  43  	httpClient       *http.Client
  44  	httpRequestLogf  Logf
  45  	httpResponseLogf Logf
  46  	logf             Logf
  47  	userAgent        string
  48  
  49  	PullZone     *PullZoneService
  50  	StorageZone  *StorageZoneService
  51  	DNSZone      *DNSZoneService
  52  	VideoLibrary *VideoLibraryService
  53  }
  54  
  55  var discardLogF = func(string, ...any) {}
  56  
  57  // NewClient returns a new bunny.net API client.
  58  // The APIKey can be found in on the Account Settings page.
  59  //
  60  // Bunny.net API docs: https://support.bunny.net/hc/en-us/articles/360012168840-Where-do-I-find-my-API-key-
  61  func NewClient(apiKey string, opts ...Option) *Client {
  62  	clt := Client{
  63  		baseURL:          mustParseURL(BaseURL),
  64  		apiKey:           apiKey,
  65  		httpClient:       &http.Client{Timeout: 10 * time.Second},
  66  		userAgent:        DefaultUserAgent,
  67  		httpRequestLogf:  discardLogF,
  68  		httpResponseLogf: discardLogF,
  69  		logf:             discardLogF,
  70  	}
  71  
  72  	clt.PullZone = &PullZoneService{client: &clt}
  73  	clt.StorageZone = &StorageZoneService{client: &clt}
  74  	clt.DNSZone = &DNSZoneService{client: &clt}
  75  	clt.VideoLibrary = &VideoLibraryService{client: &clt}
  76  
  77  	for _, opt := range opts {
  78  		opt(&clt)
  79  	}
  80  
  81  	return &clt
  82  }
  83  
  84  func mustParseURL(urlStr string) *url.URL {
  85  	res, err := url.Parse(urlStr)
  86  	if err != nil {
  87  		panic(fmt.Sprintf("Parsing url: %s failed: %s", urlStr, err))
  88  	}
  89  
  90  	return res
  91  }
  92  
  93  // newRequest creates an bunny.net API request.
  94  // urlStr maybe absolute or relative, if it is relative it is joined with
  95  // client.baseURL.
  96  func (c *Client) newRequest(method, urlStr string, body io.Reader) (*http.Request, error) {
  97  	endpoint, err := c.baseURL.Parse(urlStr)
  98  	if err != nil {
  99  		return nil, err
 100  	}
 101  
 102  	req, err := http.NewRequest(method, endpoint.String(), body)
 103  	if err != nil {
 104  		return nil, err
 105  	}
 106  
 107  	req.Header.Set(AccessKeyHeaderKey, c.apiKey)
 108  	req.Header.Add("Accept", contentTypeJSON)
 109  	req.Header.Set("User-Agent", c.userAgent)
 110  
 111  	if body != nil {
 112  		req.Header.Set(hdrContentTypeName, contentTypeJSON)
 113  	}
 114  
 115  	return req, nil
 116  }
 117  
 118  // newGetRequest creates an bunny.NET API GET request.
 119  // params must be a struct or nil, it is encoded into a query parameter.
 120  // The struct must contain  `url` tags of the go-querystring package.
 121  func (c *Client) newGetRequest(urlStr string, params any) (*http.Request, error) {
 122  	if params != nil {
 123  		queryvals, err := query.Values(params)
 124  		if err != nil {
 125  			return nil, err
 126  		}
 127  
 128  		urlStr = urlStr + "?" + queryvals.Encode()
 129  	}
 130  
 131  	return c.newRequest(http.MethodGet, urlStr, nil)
 132  }
 133  
 134  func toJSON(data any) (io.Reader, error) {
 135  	var buf io.ReadWriter
 136  
 137  	if data == nil {
 138  		return http.NoBody, nil
 139  	}
 140  
 141  	buf = &bytes.Buffer{}
 142  	enc := json.NewEncoder(buf)
 143  	enc.SetEscapeHTML(false)
 144  
 145  	if err := enc.Encode(data); err != nil {
 146  		return nil, err
 147  	}
 148  
 149  	return buf, nil
 150  }
 151  
 152  // newPostRequest creates a bunny.NET API POST request.
 153  // If body is not nil, it is encoded as JSON and send as HTTP-Body.
 154  func (c *Client) newPostRequest(urlStr string, body any) (*http.Request, error) {
 155  	buf, err := toJSON(body)
 156  	if err != nil {
 157  		return nil, err
 158  	}
 159  
 160  	req, err := c.newRequest(http.MethodPost, urlStr, buf)
 161  	if err != nil {
 162  		return nil, err
 163  	}
 164  
 165  	return req, nil
 166  }
 167  
 168  // newDeleteRequest creates a bunny.NET API DELETE request.
 169  // If body is not nil, it is encoded as JSON and send as HTTP-Body.
 170  func (c *Client) newDeleteRequest(urlStr string, body any) (*http.Request, error) {
 171  	buf, err := toJSON(body)
 172  	if err != nil {
 173  		return nil, err
 174  	}
 175  
 176  	return c.newRequest(http.MethodDelete, urlStr, buf)
 177  }
 178  
 179  // newPutRequest creates a bunny.NET API PUT request.
 180  // If body is not nil, it is encoded as JSON and sent as a HTTP-Body.
 181  func (c *Client) newPutRequest(urlStr string, body any) (*http.Request, error) {
 182  	buf, err := toJSON(body)
 183  	if err != nil {
 184  		return nil, err
 185  	}
 186  
 187  	return c.newRequest(http.MethodPut, urlStr, buf)
 188  }
 189  
 190  // sendRequest sends a http Request to the bunny API.
 191  // If the server returns a 2xx status code with an response body, the body is
 192  // unmarshaled as JSON into result.
 193  // If the ctx times out ctx.Error() is returned.
 194  // If sending the response fails (http.Client.Do), the error will be returned.
 195  // If the server returns an 401 error, an AuthenticationError error is returned.
 196  // If the server returned an error and contains an APIError as JSON in the body,
 197  // an APIError is returned.
 198  // If the server returned a status code that is not 2xx an HTTPError is returned.
 199  // If the HTTP request was successful, the response body is read and
 200  // unmarshaled into result.
 201  func (c *Client) sendRequest(ctx context.Context, req *http.Request, result any) error {
 202  	if ctx != nil {
 203  		req = req.WithContext(ctx)
 204  	}
 205  
 206  	logReqID := c.logRequest(req)
 207  
 208  	resp, err := c.httpClient.Do(req)
 209  	if err != nil {
 210  		var urlErr *url.Error
 211  		if errors.As(err, &urlErr) {
 212  			if urlErr.Timeout() && ctx.Err() != nil {
 213  				return ctx.Err()
 214  			}
 215  		}
 216  
 217  		return err
 218  	}
 219  
 220  	c.logResponse(resp, logReqID)
 221  
 222  	defer resp.Body.Close()
 223  
 224  	if err := c.checkResp(req, resp); err != nil {
 225  		return err
 226  	}
 227  
 228  	return c.unmarshalHTTPJSONBody(resp, req.URL.String(), result)
 229  }
 230  
 231  func ensureJSONContentType(hdr http.Header) error {
 232  	val := hdr.Get(hdrContentTypeName)
 233  	if val == "" {
 234  		return fmt.Errorf("%s header is missing or empty", hdrContentTypeName)
 235  	}
 236  
 237  	contentType, _, err := mime.ParseMediaType(val)
 238  	if err != nil {
 239  		return fmt.Errorf("could not parse %s header value: %w", hdrContentTypeName, err)
 240  	}
 241  
 242  	if contentType != contentTypeJSON {
 243  		return fmt.Errorf("expected %s to be %q, got: %q", hdrContentTypeName, contentTypeJSON, contentType)
 244  	}
 245  
 246  	return nil
 247  }
 248  
 249  // checkResp checks if the resp indicates that the request was successful.
 250  // If it wasn't an error is returned.
 251  func (c *Client) checkResp(req *http.Request, resp *http.Response) error {
 252  	if resp.StatusCode >= 200 && resp.StatusCode < 300 {
 253  		return nil
 254  	}
 255  
 256  	switch resp.StatusCode {
 257  	case http.StatusUnauthorized:
 258  		msg, err := io.ReadAll(resp.Body)
 259  		if err != nil {
 260  			// ignore connection errors causing that the body can
 261  			// not be received
 262  			msg = []byte(http.StatusText(http.StatusUnauthorized))
 263  		}
 264  
 265  		return &AuthenticationError{
 266  			Message: string(msg),
 267  		}
 268  
 269  	default:
 270  		httpErr := HTTPError{
 271  			RequestURL: req.URL.String(),
 272  			StatusCode: resp.StatusCode,
 273  		}
 274  
 275  		return c.parseHTTPRespErrBody(resp, &httpErr)
 276  	}
 277  }
 278  
 279  // parseHTTPRespErrBody processes the body of a http.Response with a non 2xx status code.
 280  // If the response body is empty, baseErr is returned.
 281  // If the body could not be parsed because of an error, the occurred errors are
 282  // added to baseErr and baseErr is returned.
 283  // If the body contains json data it is parsed and an APIError is returned.
 284  func (c *Client) parseHTTPRespErrBody(resp *http.Response, baseErr *HTTPError) error {
 285  	var err error
 286  
 287  	baseErr.RespBody, err = io.ReadAll(resp.Body)
 288  	if err != nil {
 289  		baseErr.Errors = append(baseErr.Errors, fmt.Errorf("reading response body failed: %w", err))
 290  		return baseErr
 291  	}
 292  
 293  	if len(baseErr.RespBody) == 0 {
 294  		return baseErr
 295  	}
 296  
 297  	err = ensureJSONContentType(resp.Header)
 298  	if err != nil {
 299  		baseErr.Errors = append(baseErr.Errors, fmt.Errorf("processing response failed: %w", err))
 300  		return baseErr
 301  	}
 302  
 303  	var apiErr APIError
 304  	if err := json.Unmarshal(baseErr.RespBody, &apiErr); err != nil {
 305  		baseErr.Errors = append(baseErr.Errors, fmt.Errorf("could not parse body as APIError: %w", err))
 306  		return baseErr
 307  	}
 308  
 309  	apiErr.HTTPError = *baseErr
 310  
 311  	return &apiErr
 312  }
 313  
 314  func (c *Client) unmarshalHTTPJSONBody(resp *http.Response, reqURL string, result any) error {
 315  	body, err := io.ReadAll(resp.Body)
 316  	if err != nil {
 317  		return &HTTPError{
 318  			RequestURL: reqURL,
 319  			StatusCode: resp.StatusCode,
 320  			Errors:     []error{fmt.Errorf("reading response body failed: %w", err)},
 321  		}
 322  	}
 323  
 324  	if len(body) == 0 {
 325  		if result != nil {
 326  			return &HTTPError{
 327  				RequestURL: reqURL,
 328  				StatusCode: resp.StatusCode,
 329  				Errors:     []error{fmt.Errorf("response has no body, expected a json %T response body", result)},
 330  			}
 331  		}
 332  
 333  		return nil
 334  	}
 335  
 336  	if result == nil {
 337  		c.logf("http-response contains body but none was expected")
 338  		return nil
 339  	}
 340  
 341  	err = ensureJSONContentType(resp.Header)
 342  	if err != nil {
 343  		return &HTTPError{
 344  			RequestURL: reqURL,
 345  			RespBody:   body,
 346  			StatusCode: resp.StatusCode,
 347  			Errors:     []error{fmt.Errorf("processing response failed: %w", err)},
 348  		}
 349  	}
 350  
 351  	if err := json.Unmarshal(body, result); err != nil {
 352  		return &HTTPError{
 353  			RequestURL: reqURL,
 354  			RespBody:   body,
 355  			StatusCode: resp.StatusCode,
 356  			Errors:     []error{fmt.Errorf("could not parse body as %T: %w", result, err)},
 357  		}
 358  	}
 359  
 360  	return nil
 361  }
 362  
 363  // logRequest dumps the http request to the http request logger and returns a
 364  // unique request identifier. The identifier can be used when logging the
 365  // response for the request, to make it easier to associate request and
 366  // response log messages.
 367  func (c *Client) logRequest(req *http.Request) string {
 368  	if c.httpRequestLogf == nil {
 369  		return ""
 370  	}
 371  
 372  	logReqID := uuid.New().String()
 373  
 374  	// hide the access key in the dumped request
 375  	accessKey := req.Header.Get(AccessKeyHeaderKey)
 376  	if accessKey != "" {
 377  		req.Header.Set(AccessKeyHeaderKey, "***hidden***")
 378  
 379  		defer func() { req.Header.Set(AccessKeyHeaderKey, accessKey) }()
 380  	}
 381  
 382  	debugReq, err := httputil.DumpRequestOut(req, true)
 383  	if err != nil {
 384  		c.httpRequestLogf("dumping http request (reqID: %s) failed: %s", logReqID, err)
 385  		return logReqID
 386  	}
 387  
 388  	c.httpRequestLogf("sending http-request (reqID: %s): %s", logReqID, string(debugReq))
 389  
 390  	return logReqID
 391  }
 392  
 393  func (c *Client) logResponse(resp *http.Response, logReqID string) {
 394  	if c.httpResponseLogf == nil {
 395  		return
 396  	}
 397  
 398  	debugResp, err := httputil.DumpResponse(resp, true)
 399  	if err != nil {
 400  		c.httpRequestLogf("dumping http response (reqID: %s) failed: %s", logReqID, err)
 401  		return
 402  	}
 403  
 404  	c.httpRequestLogf("received http-response (reqID: %s): %s", logReqID, string(debugResp))
 405  }
 406