1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
4
5// Package cooked calls the Cooked customer API.
6package cooked
7
8import (
9 "bytes"
10 "context"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "io"
15 "math/big"
16 "net/http"
17 "net/http/cookiejar"
18 "net/url"
19 "strconv"
20 "strings"
21 "sync"
22 "time"
23
24 "golang.org/x/net/publicsuffix"
25)
26
27const maxResponseBytes = 1 << 20
28
29// Client is an authenticated Cooked API client.
30type Client struct {
31 baseURL *url.URL
32 username string
33 password string
34 http *http.Client
35
36 requestMu sync.Mutex
37 authenticatedUsername string
38}
39
40// ShoppingList is the authenticated user's Cooked shopping list.
41type ShoppingList struct {
42 Aisles []Aisle `json:"aisles"`
43 Recipes []string `json:"recipes"`
44}
45
46// Aisle groups shopping-list product groups.
47type Aisle struct {
48 ID string `json:"aisle-id"`
49 Name string `json:"aisle-name"`
50 ProductGroups []ProductGroup `json:"product-groups"`
51}
52
53// ProductGroup is an item on the shopping list.
54type ProductGroup struct {
55 ID string `json:"id"`
56 Name string `json:"name"`
57 Quantity string `json:"quantity"`
58 Selected bool `json:"selected"`
59}
60
61// AddShoppingListResult is the result of adding ingredients to a shopping list.
62type AddShoppingListResult struct {
63 AddedCount int
64 Ingredients []string
65}
66
67// ShoppingListProductGroupUpdate is a full shopping-list product-group update.
68type ShoppingListProductGroupUpdate struct {
69 Name string
70 Quantity string
71 AisleID string
72 Selected bool
73}
74
75// RecipeCard is a saved recipe summary returned by Cooked list and search endpoints.
76type RecipeCard struct {
77 ID string `json:"id"`
78 Title string `json:"title"`
79 ThumbnailURL string `json:"thumbnail-url,omitempty"`
80}
81
82// RecipeMetadata is metadata for a Cooked recipe.
83type RecipeMetadata struct {
84 Title string `json:"title"`
85 ImageURLs []string `json:"image-urls"`
86 Owner string `json:"owner"`
87 EditPermission bool `json:"edit-permission"`
88}
89
90// RecipeContent is the Markdown-style body and portions for a Cooked recipe.
91type RecipeContent struct {
92 Content string `json:"content"`
93 Portions int `json:"portions"`
94}
95
96// RecipeTextPreview is Cooked's preview of raw recipe text before saving.
97type RecipeTextPreview struct {
98 Title string `json:"title"`
99 Markdown string `json:"markdown"`
100 Portions int `json:"portions"`
101}
102
103// UnmarshalJSON accepts Cooked's integer-valued decimal portions, such as 1.0.
104func (p *RecipeTextPreview) UnmarshalJSON(data []byte) error {
105 type raw RecipeTextPreview
106 var response struct {
107 raw
108 Portions json.Number `json:"portions"`
109 }
110 if err := json.Unmarshal(data, &response); err != nil {
111 return err
112 }
113
114 portions, err := jsonNumberToInt(response.Portions)
115 if err != nil {
116 return fmt.Errorf("portions: %w", err)
117 }
118
119 *p = RecipeTextPreview(response.raw)
120 p.Portions = portions
121
122 return nil
123}
124
125// RecipeURLImport is the result of importing a recipe URL.
126type RecipeURLImport struct {
127 RecipeID string
128 DraftID string
129}
130
131// NewClient returns a Cooked client with an in-memory cookie jar.
132func NewClient(baseURL *url.URL, username, password string) (*Client, error) {
133 jar, err := newCookieJar()
134 if err != nil {
135 return nil, fmt.Errorf("create cookie jar: %w", err)
136 }
137
138 return &Client{
139 baseURL: baseURL,
140 username: username,
141 password: password,
142 http: &http.Client{
143 Jar: jar,
144 Timeout: 30 * time.Second,
145 },
146 }, nil
147}
148
149// ReadShoppingList logs in when needed and returns the authenticated user's shopping list.
150func (c *Client) ReadShoppingList(ctx context.Context) (ShoppingList, error) {
151 var response shoppingListResponse
152 decodeShoppingList := func(decoder *json.Decoder) error {
153 return decoder.Decode(&response)
154 }
155 if err := c.doAuthenticated(
156 ctx,
157 http.MethodGet,
158 "/api/user/{username}/shopping-list",
159 nil,
160 decodeShoppingList,
161 ); err != nil {
162 return ShoppingList{}, err
163 }
164
165 if err := validateShoppingListResponse(response); err != nil {
166 return ShoppingList{}, err
167 }
168
169 shoppingList := *response.ShoppingList
170 shoppingList.Recipes = response.Recipes
171
172 return shoppingList, nil
173}
174
175// ClearShoppingList logs in when needed and clears the authenticated user's shopping list.
176func (c *Client) ClearShoppingList(ctx context.Context) error {
177 return c.doAuthenticated(ctx, http.MethodDelete, "/api/user/{username}/shopping-list", nil, nil)
178}
179
180// AddShoppingListIngredients logs in when needed and adds ingredients to the shopping list.
181func (c *Client) AddShoppingListIngredients(
182 ctx context.Context,
183 ingredients, recipeID string,
184) (AddShoppingListResult, error) {
185 body, err := json.Marshal(addShoppingListRequest{Ingredients: ingredients, RecipeID: recipeID})
186 if err != nil {
187 return AddShoppingListResult{}, fmt.Errorf("encode Cooked shopping-list add request: %w", err)
188 }
189
190 var response addShoppingListResponse
191 decodeAdd := func(decoder *json.Decoder) error {
192 return decoder.Decode(&response)
193 }
194 if err := c.doAuthenticated(ctx, http.MethodPut, "/api/user/shopping-list", body, decodeAdd); err != nil {
195 return AddShoppingListResult{}, err
196 }
197 if response.AddedCount == nil {
198 return AddShoppingListResult{}, fmt.Errorf("add shopping-list ingredients response missing added count")
199 }
200 if strings.TrimSpace(ingredients) != "" && *response.AddedCount == 0 {
201 return AddShoppingListResult{}, fmt.Errorf(
202 "add shopping-list ingredients response returned added-count 0 for nonblank ingredients",
203 )
204 }
205
206 return AddShoppingListResult{AddedCount: *response.AddedCount, Ingredients: response.Ingredients}, nil
207}
208
209// RemoveShoppingListProductGroups logs in when needed and removes shopping-list product groups.
210func (c *Client) RemoveShoppingListProductGroups(ctx context.Context, ids []string) error {
211 body, err := json.Marshal(removeShoppingListProductGroupsRequest{IDs: ids})
212 if err != nil {
213 return fmt.Errorf("encode Cooked shopping-list remove request: %w", err)
214 }
215
216 return c.doAuthenticated(ctx, http.MethodDelete, "/api/user/{username}/shopping-list/product-groups", body, nil)
217}
218
219// ReplaceShoppingListSelection logs in when needed and replaces the selected shopping-list product groups.
220func (c *Client) ReplaceShoppingListSelection(ctx context.Context, ids []string) error {
221 body, err := json.Marshal(replaceShoppingListSelectionRequest{Selected: ids})
222 if err != nil {
223 return fmt.Errorf("encode Cooked shopping-list selection request: %w", err)
224 }
225
226 return c.doAuthenticated(
227 ctx,
228 http.MethodPut,
229 "/api/user/{username}/shopping-list/product-groups/selection",
230 body,
231 nil,
232 )
233}
234
235// UpdateShoppingListProductGroup logs in when needed and updates a shopping-list product group.
236func (c *Client) UpdateShoppingListProductGroup(
237 ctx context.Context,
238 productGroupID string,
239 update ShoppingListProductGroupUpdate,
240) error {
241 body, err := json.Marshal(updateShoppingListProductGroupRequest(update))
242 if err != nil {
243 return fmt.Errorf("encode Cooked shopping-list product-group update request: %w", err)
244 }
245
246 path := "/api/user/{username}/shopping-list/product-groups/" + url.PathEscape(productGroupID)
247
248 return c.doAuthenticated(ctx, http.MethodPut, path, body, nil)
249}
250
251// ListRecipes logs in when needed and returns one page of saved recipes.
252func (c *Client) ListRecipes(ctx context.Context, page, limit int) ([]RecipeCard, error) {
253 query := url.Values{}
254 query.Set("page", fmt.Sprintf("%d", page))
255 query.Set("page-count", fmt.Sprintf("%d", limit))
256
257 return c.getRecipes(ctx, "/api/user/{username}/recipes?"+query.Encode())
258}
259
260// SearchRecipes logs in when needed and searches the authenticated user's saved recipes.
261func (c *Client) SearchRecipes(ctx context.Context, queryText string, page int) ([]RecipeCard, error) {
262 query := url.Values{}
263 query.Set("q", queryText)
264 query.Set("page", fmt.Sprintf("%d", page))
265
266 return c.getRecipes(ctx, "/api/user/{username}/recipes/search?"+query.Encode())
267}
268
269// ReadRecipeMetadata logs in when needed and returns recipe metadata.
270func (c *Client) ReadRecipeMetadata(ctx context.Context, recipeID string) (RecipeMetadata, error) {
271 var metadata RecipeMetadata
272 decodeMetadata := func(decoder *json.Decoder) error {
273 return decoder.Decode(&metadata)
274 }
275 path := "/api/recipe/" + url.PathEscape(recipeID) + "/metadata"
276 if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeMetadata); err != nil {
277 return RecipeMetadata{}, err
278 }
279
280 return metadata, nil
281}
282
283// ReadRecipeContent logs in when needed and returns recipe content and portions.
284func (c *Client) ReadRecipeContent(ctx context.Context, recipeID string) (RecipeContent, error) {
285 var content RecipeContent
286 decodeContent := func(decoder *json.Decoder) error {
287 return decoder.Decode(&content)
288 }
289 path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"
290 if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeContent); err != nil {
291 return RecipeContent{}, err
292 }
293 if err := validateRecipeContent(content); err != nil {
294 return RecipeContent{}, err
295 }
296
297 return content, nil
298}
299
300// PreviewRecipeText logs in when needed and previews raw recipe text without saving it.
301func (c *Client) PreviewRecipeText(ctx context.Context, title, text string) (RecipeTextPreview, error) {
302 body, err := json.Marshal(previewRecipeTextRequest{RecipeName: title, RecipeText: text})
303 if err != nil {
304 return RecipeTextPreview{}, fmt.Errorf("encode Cooked recipe text preview request: %w", err)
305 }
306
307 var preview RecipeTextPreview
308 decodePreview := func(decoder *json.Decoder) error {
309 return decoder.Decode(&preview)
310 }
311 if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new/from-text/preview", body, decodePreview); err != nil {
312 return RecipeTextPreview{}, err
313 }
314
315 return preview, nil
316}
317
318// SavePreparedRecipe logs in when needed and saves prepared recipe markdown as a new recipe.
319func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error) {
320 body, err := json.Marshal(savePreparedRecipeRequest{Title: title, Description: markdown, Portions: portions})
321 if err != nil {
322 return "", fmt.Errorf("encode Cooked prepared recipe save request: %w", err)
323 }
324
325 var response saveRecipeResponse
326 decodeSave := func(decoder *json.Decoder) error {
327 return decoder.Decode(&response)
328 }
329 if err := c.doAuthenticated(ctx, http.MethodPost, "/api/recipe/import/save", body, decodeSave); err != nil {
330 return "", err
331 }
332 if strings.TrimSpace(response.RecipeID) == "" {
333 return "", fmt.Errorf("save prepared recipe response missing recipe ID")
334 }
335
336 return response.RecipeID, nil
337}
338
339// SaveRecipeDraft logs in when needed and saves a reviewed URL import draft.
340func (c *Client) SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions int) (string, error) {
341 body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions})
342 if err != nil {
343 return "", fmt.Errorf("encode Cooked recipe draft save request: %w", err)
344 }
345
346 var response saveRecipeResponse
347 decodeSave := func(decoder *json.Decoder) error {
348 return decoder.Decode(&response)
349 }
350 path := "/api/extract/" + url.PathEscape(draftID) + "/save"
351 if err := c.doAuthenticated(ctx, http.MethodPost, path, body, decodeSave); err != nil {
352 return "", err
353 }
354 if strings.TrimSpace(response.RecipeID) == "" {
355 return "", fmt.Errorf("save recipe draft response missing recipe ID")
356 }
357
358 return response.RecipeID, nil
359}
360
361// UpdateRecipeContent logs in when needed and updates an existing recipe's content.
362func (c *Client) UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error {
363 body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions})
364 if err != nil {
365 return fmt.Errorf("encode Cooked recipe update request: %w", err)
366 }
367
368 path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"
369
370 return c.doAuthenticated(ctx, http.MethodPost, path, body, nil)
371}
372
373// ImportRecipeURL logs in when needed and starts a recipe URL import.
374func (c *Client) ImportRecipeURL(ctx context.Context, recipeURL string) (RecipeURLImport, error) {
375 body, err := json.Marshal(importRecipeURLRequest{URL: recipeURL})
376 if err != nil {
377 return RecipeURLImport{}, fmt.Errorf("encode Cooked recipe URL import request: %w", err)
378 }
379
380 var response importRecipeURLResponse
381 decodeImport := func(decoder *json.Decoder) error {
382 return decoder.Decode(&response)
383 }
384 if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new", body, decodeImport); err != nil {
385 return RecipeURLImport{}, err
386 }
387
388 if strings.TrimSpace(response.RecipeID) == "" && strings.TrimSpace(response.ExtractionID) == "" {
389 return RecipeURLImport{}, fmt.Errorf("import recipe URL response missing recipe or draft ID")
390 }
391
392 return RecipeURLImport{RecipeID: response.RecipeID, DraftID: response.ExtractionID}, nil
393}
394
395// DeleteRecipe logs in when needed and deletes an existing recipe.
396func (c *Client) DeleteRecipe(ctx context.Context, recipeID string) error {
397 path := "/api/recipe/" + url.PathEscape(recipeID)
398
399 return c.doAuthenticated(ctx, http.MethodDelete, path, nil, nil)
400}
401
402func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, error) {
403 var recipes []RecipeCard
404 decodeRecipes := func(decoder *json.Decoder) error {
405 return decoder.Decode(&recipes)
406 }
407 if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeRecipes); err != nil {
408 return nil, err
409 }
410 if err := validateRecipeCards(recipes); err != nil {
411 return nil, err
412 }
413
414 return recipes, nil
415}
416
417func validateRecipeCards(recipes []RecipeCard) error {
418 if recipes == nil {
419 return fmt.Errorf("cooked recipe list response missing recipes")
420 }
421
422 for index, recipe := range recipes {
423 if strings.TrimSpace(recipe.ID) == "" {
424 return fmt.Errorf("cooked recipe list response recipe %d missing id", index)
425 }
426 if strings.TrimSpace(recipe.Title) == "" {
427 return fmt.Errorf("cooked recipe list response recipe %d missing title", index)
428 }
429 }
430
431 return nil
432}
433
434func validateRecipeContent(content RecipeContent) error {
435 if strings.TrimSpace(content.Content) == "" {
436 return fmt.Errorf("cooked recipe content response missing content")
437 }
438 if content.Portions < 1 {
439 return fmt.Errorf("cooked recipe content response missing or invalid portions")
440 }
441
442 return nil
443}
444
445func validateShoppingListResponse(response shoppingListResponse) error {
446 if response.ShoppingList == nil {
447 return fmt.Errorf("cooked shopping-list response missing shopping-list")
448 }
449 if response.ShoppingList.Aisles == nil {
450 return fmt.Errorf("cooked shopping-list response missing aisles")
451 }
452
453 for aisleIndex, aisle := range response.ShoppingList.Aisles {
454 if err := validateShoppingListAisle(aisleIndex, aisle); err != nil {
455 return err
456 }
457 }
458
459 return nil
460}
461
462func validateShoppingListAisle(aisleIndex int, aisle Aisle) error {
463 if strings.TrimSpace(aisle.ID) == "" {
464 return fmt.Errorf("cooked shopping-list response aisle %d missing id", aisleIndex)
465 }
466 if strings.TrimSpace(aisle.Name) == "" {
467 return fmt.Errorf("cooked shopping-list response aisle %d missing name", aisleIndex)
468 }
469 if aisle.ProductGroups == nil {
470 return fmt.Errorf("cooked shopping-list response aisle %d missing product groups", aisleIndex)
471 }
472
473 for productIndex, product := range aisle.ProductGroups {
474 if err := validateShoppingListProductGroup(aisleIndex, productIndex, product); err != nil {
475 return err
476 }
477 }
478
479 return nil
480}
481
482func validateShoppingListProductGroup(aisleIndex, productIndex int, product ProductGroup) error {
483 if strings.TrimSpace(product.ID) == "" {
484 return fmt.Errorf(
485 "cooked shopping-list response aisle %d product group %d missing id",
486 aisleIndex,
487 productIndex,
488 )
489 }
490 if strings.TrimSpace(product.Name) == "" {
491 return fmt.Errorf(
492 "cooked shopping-list response aisle %d product group %d missing name",
493 aisleIndex,
494 productIndex,
495 )
496 }
497
498 return nil
499}
500
501func jsonNumberToInt(number json.Number) (int, error) {
502 text := number.String()
503 if text == "" {
504 return 0, fmt.Errorf("missing")
505 }
506
507 rational, ok := new(big.Rat).SetString(text)
508 if !ok {
509 return 0, fmt.Errorf("invalid JSON number %q", text)
510 }
511 if !rational.IsInt() {
512 return 0, fmt.Errorf("must be integer-valued, got %s", text)
513 }
514
515 integer := rational.Num()
516 if !integer.IsInt64() {
517 return 0, fmt.Errorf("out of range, got %s", text)
518 }
519
520 value := integer.Int64()
521 maxInt := int64(1<<(strconv.IntSize-1) - 1)
522 minInt := -maxInt - 1
523 if value < minInt || value > maxInt {
524 return 0, fmt.Errorf("out of range for int, got %s", text)
525 }
526
527 return int(value), nil
528}
529
530func (c *Client) doAuthenticated(
531 ctx context.Context,
532 method, path string,
533 body []byte,
534 decodeResponse responseDecoder,
535) error {
536 c.requestMu.Lock()
537 defer c.requestMu.Unlock()
538
539 if err := c.ensureLogin(ctx); err != nil {
540 return err
541 }
542
543 err := c.do(ctx, method, c.userPath(path), body, decodeResponse)
544 if !isUnauthorized(err) {
545 return err
546 }
547
548 c.resetSession()
549 if err := c.ensureLogin(ctx); err != nil {
550 return err
551 }
552
553 return c.do(ctx, method, c.userPath(path), body, decodeResponse)
554}
555
556func (c *Client) ensureLogin(ctx context.Context) error {
557 authenticated := c.authenticatedUsername != ""
558 if authenticated {
559 return nil
560 }
561
562 body, err := json.Marshal(loginRequest{Username: c.username, Password: c.password})
563 if err != nil {
564 return fmt.Errorf("encode Cooked login request: %w", err)
565 }
566
567 var login loginResponse
568 decodeLogin := func(decoder *json.Decoder) error {
569 return decoder.Decode(&login)
570 }
571 if err := c.do(ctx, http.MethodPost, "/api/public/login", body, decodeLogin); err != nil {
572 return fmt.Errorf("authenticate with Cooked: %w", err)
573 }
574 if login.Username == "" {
575 return fmt.Errorf("authenticate with Cooked: missing username in login response")
576 }
577
578 c.authenticatedUsername = login.Username
579
580 return nil
581}
582
583func (c *Client) resetSession() {
584 jar, err := newCookieJar()
585 if err != nil {
586 return
587 }
588
589 c.authenticatedUsername = ""
590 c.http = &http.Client{Jar: jar, Timeout: c.http.Timeout}
591}
592
593func (c *Client) do(ctx context.Context, method, path string, body []byte, decodeResponse responseDecoder) error {
594 request, err := c.newRequest(ctx, method, path, body)
595 if err != nil {
596 return err
597 }
598
599 response, err := c.http.Do(request)
600 if err != nil {
601 return fmt.Errorf("call Cooked: %w", err)
602 }
603 defer func() {
604 _ = response.Body.Close()
605 }()
606
607 if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
608 return decodeError(response)
609 }
610
611 if decodeResponse == nil {
612 return nil
613 }
614 if err := decodeResponse(json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes))); err != nil {
615 return fmt.Errorf("decode Cooked response: %w", err)
616 }
617
618 return nil
619}
620
621func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
622 relativeURL, err := url.Parse(path)
623 if err != nil {
624 return nil, fmt.Errorf("parse Cooked request path: %w", err)
625 }
626 requestURL := c.baseURL.ResolveReference(relativeURL)
627
628 var reader io.Reader
629 if body != nil {
630 reader = bytes.NewReader(body)
631 }
632
633 request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), reader)
634 if err != nil {
635 return nil, fmt.Errorf("build Cooked request: %w", err)
636 }
637 request.Header.Set("Accept", "application/json")
638 if body != nil {
639 request.Header.Set("Content-Type", "application/json")
640 }
641
642 return request, nil
643}
644
645func (c *Client) userPath(path string) string {
646 username := c.authenticatedUsername
647
648 return strings.ReplaceAll(path, "{username}", url.PathEscape(username))
649}
650
651type responseDecoder func(*json.Decoder) error
652
653type shoppingListResponse struct {
654 ShoppingList *ShoppingList `json:"shopping-list"`
655 Recipes []string `json:"recipes"`
656}
657
658type addShoppingListRequest struct {
659 Ingredients string `json:"ingredients"`
660 RecipeID string `json:"recipe-id,omitempty"`
661}
662
663type addShoppingListResponse struct {
664 AddedCount *int `json:"added-count"`
665 Ingredients []string `json:"ingredients"`
666}
667
668type removeShoppingListProductGroupsRequest struct {
669 IDs []string `json:"ids"`
670}
671
672type replaceShoppingListSelectionRequest struct {
673 Selected []string `json:"selected"`
674}
675
676type updateShoppingListProductGroupRequest struct {
677 Name string `json:"name"`
678 Quantity string `json:"quantity"`
679 AisleID string `json:"aisle-id"`
680 Selected bool `json:"selected"`
681}
682
683type previewRecipeTextRequest struct {
684 RecipeName string `json:"recipe-name"`
685 RecipeText string `json:"recipe-text"`
686}
687
688type savePreparedRecipeRequest struct {
689 Title string `json:"title"`
690 Description string `json:"description"`
691 Portions int `json:"portions"`
692}
693
694type recipeContentRequest struct {
695 Description string `json:"description"`
696 Portions int `json:"portions"`
697}
698
699type importRecipeURLRequest struct {
700 URL string `json:"url"`
701}
702
703type importRecipeURLResponse struct {
704 RecipeID string `json:"recipe-id"`
705 ExtractionID string `json:"extraction-id"`
706}
707
708type saveRecipeResponse struct {
709 RecipeID string `json:"recipe-id"`
710}
711
712type loginRequest struct {
713 Username string `json:"username"`
714 Password string `json:"password"`
715}
716
717type loginResponse struct {
718 Username string `json:"username"`
719}
720
721type cookedError struct {
722 StatusCode int
723 Code string
724 Message string
725}
726
727func (e cookedError) Error() string {
728 var parts []string
729 parts = append(parts, fmt.Sprintf("Cooked returned HTTP %d", e.StatusCode))
730 if e.Code != "" {
731 parts = append(parts, e.Code)
732 }
733 if e.Message != "" {
734 parts = append(parts, e.Message)
735 }
736
737 return strings.Join(parts, ": ")
738}
739
740func decodeError(response *http.Response) error {
741 var payload struct {
742 Code string `json:"code"`
743 Message string `json:"message"`
744 }
745 _ = json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&payload)
746
747 return cookedError{
748 StatusCode: response.StatusCode,
749 Code: payload.Code,
750 Message: payload.Message,
751 }
752}
753
754func isUnauthorized(err error) bool {
755 if err == nil {
756 return false
757 }
758
759 var cookedErr cookedError
760 if !errors.As(err, &cookedErr) {
761 return false
762 }
763
764 return cookedErr.StatusCode == http.StatusUnauthorized
765}
766
767func newCookieJar() (*cookiejar.Jar, error) {
768 return cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
769}