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