client.go

  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}