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