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	response.ShoppingList.Recipes = response.Recipes
166	return response.ShoppingList, nil
167}
168
169// ClearShoppingList logs in when needed and clears the authenticated user's shopping list.
170func (c *Client) ClearShoppingList(ctx context.Context) error {
171	return c.doAuthenticated(ctx, http.MethodDelete, "/api/user/{username}/shopping-list", nil, nil)
172}
173
174// AddShoppingListIngredients logs in when needed and adds ingredients to the shopping list.
175func (c *Client) AddShoppingListIngredients(
176	ctx context.Context,
177	ingredients, recipeID string,
178) (AddShoppingListResult, error) {
179	body, err := json.Marshal(addShoppingListRequest{Ingredients: ingredients, RecipeID: recipeID})
180	if err != nil {
181		return AddShoppingListResult{}, fmt.Errorf("encode Cooked shopping-list add request: %w", err)
182	}
183
184	var response addShoppingListResponse
185	decodeAdd := func(decoder *json.Decoder) error {
186		return decoder.Decode(&response)
187	}
188	if err := c.doAuthenticated(ctx, http.MethodPut, "/api/user/shopping-list", body, decodeAdd); err != nil {
189		return AddShoppingListResult{}, err
190	}
191	if response.AddedCount == nil {
192		return AddShoppingListResult{}, fmt.Errorf("add shopping-list ingredients response missing added count")
193	}
194	if strings.TrimSpace(ingredients) != "" && *response.AddedCount == 0 {
195		return AddShoppingListResult{}, fmt.Errorf(
196			"add shopping-list ingredients response returned added-count 0 for nonblank ingredients",
197		)
198	}
199
200	return AddShoppingListResult{AddedCount: *response.AddedCount, Ingredients: response.Ingredients}, nil
201}
202
203// RemoveShoppingListProductGroups logs in when needed and removes shopping-list product groups.
204func (c *Client) RemoveShoppingListProductGroups(ctx context.Context, ids []string) error {
205	body, err := json.Marshal(removeShoppingListProductGroupsRequest{IDs: ids})
206	if err != nil {
207		return fmt.Errorf("encode Cooked shopping-list remove request: %w", err)
208	}
209
210	return c.doAuthenticated(ctx, http.MethodDelete, "/api/user/{username}/shopping-list/product-groups", body, nil)
211}
212
213// ReplaceShoppingListSelection logs in when needed and replaces the selected shopping-list product groups.
214func (c *Client) ReplaceShoppingListSelection(ctx context.Context, ids []string) error {
215	body, err := json.Marshal(replaceShoppingListSelectionRequest{Selected: ids})
216	if err != nil {
217		return fmt.Errorf("encode Cooked shopping-list selection request: %w", err)
218	}
219
220	return c.doAuthenticated(
221		ctx,
222		http.MethodPut,
223		"/api/user/{username}/shopping-list/product-groups/selection",
224		body,
225		nil,
226	)
227}
228
229// UpdateShoppingListProductGroup logs in when needed and updates a shopping-list product group.
230func (c *Client) UpdateShoppingListProductGroup(
231	ctx context.Context,
232	productGroupID string,
233	update ShoppingListProductGroupUpdate,
234) error {
235	body, err := json.Marshal(updateShoppingListProductGroupRequest(update))
236	if err != nil {
237		return fmt.Errorf("encode Cooked shopping-list product-group update request: %w", err)
238	}
239
240	path := "/api/user/{username}/shopping-list/product-groups/" + url.PathEscape(productGroupID)
241
242	return c.doAuthenticated(ctx, http.MethodPut, path, body, nil)
243}
244
245// ListRecipes logs in when needed and returns one page of saved recipes.
246func (c *Client) ListRecipes(ctx context.Context, page, limit int) ([]RecipeCard, error) {
247	query := url.Values{}
248	query.Set("page", fmt.Sprintf("%d", page))
249	query.Set("page-count", fmt.Sprintf("%d", limit))
250
251	return c.getRecipes(ctx, "/api/user/{username}/recipes?"+query.Encode())
252}
253
254// SearchRecipes logs in when needed and searches the authenticated user's saved recipes.
255func (c *Client) SearchRecipes(ctx context.Context, queryText string, page int) ([]RecipeCard, error) {
256	query := url.Values{}
257	query.Set("q", queryText)
258	query.Set("page", fmt.Sprintf("%d", page))
259
260	return c.getRecipes(ctx, "/api/user/{username}/recipes/search?"+query.Encode())
261}
262
263// ReadRecipeMetadata logs in when needed and returns recipe metadata.
264func (c *Client) ReadRecipeMetadata(ctx context.Context, recipeID string) (RecipeMetadata, error) {
265	var metadata RecipeMetadata
266	decodeMetadata := func(decoder *json.Decoder) error {
267		return decoder.Decode(&metadata)
268	}
269	path := "/api/recipe/" + url.PathEscape(recipeID) + "/metadata"
270	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeMetadata); err != nil {
271		return RecipeMetadata{}, err
272	}
273
274	return metadata, nil
275}
276
277// ReadRecipeContent logs in when needed and returns recipe content and portions.
278func (c *Client) ReadRecipeContent(ctx context.Context, recipeID string) (RecipeContent, error) {
279	var content RecipeContent
280	decodeContent := func(decoder *json.Decoder) error {
281		return decoder.Decode(&content)
282	}
283	path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"
284	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeContent); err != nil {
285		return RecipeContent{}, err
286	}
287	if err := validateRecipeContent(content); err != nil {
288		return RecipeContent{}, err
289	}
290
291	return content, nil
292}
293
294// PreviewRecipeText logs in when needed and previews raw recipe text without saving it.
295func (c *Client) PreviewRecipeText(ctx context.Context, title, text string) (RecipeTextPreview, error) {
296	body, err := json.Marshal(previewRecipeTextRequest{RecipeName: title, RecipeText: text})
297	if err != nil {
298		return RecipeTextPreview{}, fmt.Errorf("encode Cooked recipe text preview request: %w", err)
299	}
300
301	var preview RecipeTextPreview
302	decodePreview := func(decoder *json.Decoder) error {
303		return decoder.Decode(&preview)
304	}
305	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new/from-text/preview", body, decodePreview); err != nil {
306		return RecipeTextPreview{}, err
307	}
308
309	return preview, nil
310}
311
312// SavePreparedRecipe logs in when needed and saves prepared recipe markdown as a new recipe.
313func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error) {
314	body, err := json.Marshal(savePreparedRecipeRequest{Title: title, Description: markdown, Portions: portions})
315	if err != nil {
316		return "", fmt.Errorf("encode Cooked prepared recipe save request: %w", err)
317	}
318
319	var response saveRecipeResponse
320	decodeSave := func(decoder *json.Decoder) error {
321		return decoder.Decode(&response)
322	}
323	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/recipe/import/save", body, decodeSave); err != nil {
324		return "", err
325	}
326	if strings.TrimSpace(response.RecipeID) == "" {
327		return "", fmt.Errorf("save prepared recipe response missing recipe ID")
328	}
329
330	return response.RecipeID, nil
331}
332
333// SaveRecipeDraft logs in when needed and saves a reviewed URL import draft.
334func (c *Client) SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions int) (string, error) {
335	body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions})
336	if err != nil {
337		return "", fmt.Errorf("encode Cooked recipe draft save request: %w", err)
338	}
339
340	var response saveRecipeResponse
341	decodeSave := func(decoder *json.Decoder) error {
342		return decoder.Decode(&response)
343	}
344	path := "/api/extract/" + url.PathEscape(draftID) + "/save"
345	if err := c.doAuthenticated(ctx, http.MethodPost, path, body, decodeSave); err != nil {
346		return "", err
347	}
348	if strings.TrimSpace(response.RecipeID) == "" {
349		return "", fmt.Errorf("save recipe draft response missing recipe ID")
350	}
351
352	return response.RecipeID, nil
353}
354
355// UpdateRecipeContent logs in when needed and updates an existing recipe's content.
356func (c *Client) UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error {
357	body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions})
358	if err != nil {
359		return fmt.Errorf("encode Cooked recipe update request: %w", err)
360	}
361
362	path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"
363
364	return c.doAuthenticated(ctx, http.MethodPost, path, body, nil)
365}
366
367// ImportRecipeURL logs in when needed and starts a recipe URL import.
368func (c *Client) ImportRecipeURL(ctx context.Context, recipeURL string) (RecipeURLImport, error) {
369	body, err := json.Marshal(importRecipeURLRequest{URL: recipeURL})
370	if err != nil {
371		return RecipeURLImport{}, fmt.Errorf("encode Cooked recipe URL import request: %w", err)
372	}
373
374	var response importRecipeURLResponse
375	decodeImport := func(decoder *json.Decoder) error {
376		return decoder.Decode(&response)
377	}
378	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new", body, decodeImport); err != nil {
379		return RecipeURLImport{}, err
380	}
381
382	if strings.TrimSpace(response.RecipeID) == "" && strings.TrimSpace(response.ExtractionID) == "" {
383		return RecipeURLImport{}, fmt.Errorf("import recipe URL response missing recipe or draft ID")
384	}
385
386	return RecipeURLImport{RecipeID: response.RecipeID, DraftID: response.ExtractionID}, nil
387}
388
389// DeleteRecipe logs in when needed and deletes an existing recipe.
390func (c *Client) DeleteRecipe(ctx context.Context, recipeID string) error {
391	path := "/api/recipe/" + url.PathEscape(recipeID)
392
393	return c.doAuthenticated(ctx, http.MethodDelete, path, nil, nil)
394}
395
396func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, error) {
397	var recipes []RecipeCard
398	decodeRecipes := func(decoder *json.Decoder) error {
399		return decoder.Decode(&recipes)
400	}
401	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeRecipes); err != nil {
402		return nil, err
403	}
404	if err := validateRecipeCards(recipes); err != nil {
405		return nil, err
406	}
407
408	return recipes, nil
409}
410
411func validateRecipeCards(recipes []RecipeCard) error {
412	if recipes == nil {
413		return fmt.Errorf("cooked recipe list response missing recipes")
414	}
415
416	for index, recipe := range recipes {
417		if strings.TrimSpace(recipe.ID) == "" {
418			return fmt.Errorf("cooked recipe list response recipe %d missing id", index)
419		}
420		if strings.TrimSpace(recipe.Title) == "" {
421			return fmt.Errorf("cooked recipe list response recipe %d missing title", index)
422		}
423	}
424
425	return nil
426}
427
428func validateRecipeContent(content RecipeContent) error {
429	if strings.TrimSpace(content.Content) == "" {
430		return fmt.Errorf("cooked recipe content response missing content")
431	}
432	if content.Portions < 1 {
433		return fmt.Errorf("cooked recipe content response missing or invalid portions")
434	}
435
436	return nil
437}
438
439func jsonNumberToInt(number json.Number) (int, error) {
440	text := number.String()
441	if text == "" {
442		return 0, fmt.Errorf("missing")
443	}
444
445	rational, ok := new(big.Rat).SetString(text)
446	if !ok {
447		return 0, fmt.Errorf("invalid JSON number %q", text)
448	}
449	if !rational.IsInt() {
450		return 0, fmt.Errorf("must be integer-valued, got %s", text)
451	}
452
453	integer := rational.Num()
454	if !integer.IsInt64() {
455		return 0, fmt.Errorf("out of range, got %s", text)
456	}
457
458	value := integer.Int64()
459	maxInt := int64(1<<(strconv.IntSize-1) - 1)
460	minInt := -maxInt - 1
461	if value < minInt || value > maxInt {
462		return 0, fmt.Errorf("out of range for int, got %s", text)
463	}
464
465	return int(value), nil
466}
467
468func (c *Client) doAuthenticated(
469	ctx context.Context,
470	method, path string,
471	body []byte,
472	decodeResponse responseDecoder,
473) error {
474	c.requestMu.Lock()
475	defer c.requestMu.Unlock()
476
477	if err := c.ensureLogin(ctx); err != nil {
478		return err
479	}
480
481	err := c.do(ctx, method, c.userPath(path), body, decodeResponse)
482	if !isUnauthorized(err) {
483		return err
484	}
485
486	c.resetSession()
487	if err := c.ensureLogin(ctx); err != nil {
488		return err
489	}
490
491	return c.do(ctx, method, c.userPath(path), body, decodeResponse)
492}
493
494func (c *Client) ensureLogin(ctx context.Context) error {
495	authenticated := c.authenticatedUsername != ""
496	if authenticated {
497		return nil
498	}
499
500	body, err := json.Marshal(loginRequest{Username: c.username, Password: c.password})
501	if err != nil {
502		return fmt.Errorf("encode Cooked login request: %w", err)
503	}
504
505	var login loginResponse
506	decodeLogin := func(decoder *json.Decoder) error {
507		return decoder.Decode(&login)
508	}
509	if err := c.do(ctx, http.MethodPost, "/api/public/login", body, decodeLogin); err != nil {
510		return fmt.Errorf("authenticate with Cooked: %w", err)
511	}
512	if login.Username == "" {
513		return fmt.Errorf("authenticate with Cooked: missing username in login response")
514	}
515
516	c.authenticatedUsername = login.Username
517
518	return nil
519}
520
521func (c *Client) resetSession() {
522	jar, err := newCookieJar()
523	if err != nil {
524		return
525	}
526
527	c.authenticatedUsername = ""
528	c.http = &http.Client{Jar: jar, Timeout: c.http.Timeout}
529}
530
531func (c *Client) do(ctx context.Context, method, path string, body []byte, decodeResponse responseDecoder) error {
532	request, err := c.newRequest(ctx, method, path, body)
533	if err != nil {
534		return err
535	}
536
537	response, err := c.http.Do(request)
538	if err != nil {
539		return fmt.Errorf("call Cooked: %w", err)
540	}
541	defer func() {
542		_ = response.Body.Close()
543	}()
544
545	if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
546		return decodeError(response)
547	}
548
549	if decodeResponse == nil {
550		return nil
551	}
552	if err := decodeResponse(json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes))); err != nil {
553		return fmt.Errorf("decode Cooked response: %w", err)
554	}
555
556	return nil
557}
558
559func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
560	relativeURL, err := url.Parse(path)
561	if err != nil {
562		return nil, fmt.Errorf("parse Cooked request path: %w", err)
563	}
564	requestURL := c.baseURL.ResolveReference(relativeURL)
565
566	var reader io.Reader
567	if body != nil {
568		reader = bytes.NewReader(body)
569	}
570
571	request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), reader)
572	if err != nil {
573		return nil, fmt.Errorf("build Cooked request: %w", err)
574	}
575	request.Header.Set("Accept", "application/json")
576	if body != nil {
577		request.Header.Set("Content-Type", "application/json")
578	}
579
580	return request, nil
581}
582
583func (c *Client) userPath(path string) string {
584	username := c.authenticatedUsername
585
586	return strings.ReplaceAll(path, "{username}", url.PathEscape(username))
587}
588
589type responseDecoder func(*json.Decoder) error
590
591type shoppingListResponse struct {
592	ShoppingList ShoppingList `json:"shopping-list"`
593	Recipes      []string     `json:"recipes"`
594}
595
596type addShoppingListRequest struct {
597	Ingredients string `json:"ingredients"`
598	RecipeID    string `json:"recipe-id,omitempty"`
599}
600
601type addShoppingListResponse struct {
602	AddedCount  *int     `json:"added-count"`
603	Ingredients []string `json:"ingredients"`
604}
605
606type removeShoppingListProductGroupsRequest struct {
607	IDs []string `json:"ids"`
608}
609
610type replaceShoppingListSelectionRequest struct {
611	Selected []string `json:"selected"`
612}
613
614type updateShoppingListProductGroupRequest struct {
615	Name     string `json:"name"`
616	Quantity string `json:"quantity"`
617	AisleID  string `json:"aisle-id"`
618	Selected bool   `json:"selected"`
619}
620
621type previewRecipeTextRequest struct {
622	RecipeName string `json:"recipe-name"`
623	RecipeText string `json:"recipe-text"`
624}
625
626type savePreparedRecipeRequest struct {
627	Title       string `json:"title"`
628	Description string `json:"description"`
629	Portions    int    `json:"portions"`
630}
631
632type recipeContentRequest struct {
633	Description string `json:"description"`
634	Portions    int    `json:"portions"`
635}
636
637type importRecipeURLRequest struct {
638	URL string `json:"url"`
639}
640
641type importRecipeURLResponse struct {
642	RecipeID     string `json:"recipe-id"`
643	ExtractionID string `json:"extraction-id"`
644}
645
646type saveRecipeResponse struct {
647	RecipeID string `json:"recipe-id"`
648}
649
650type loginRequest struct {
651	Username string `json:"username"`
652	Password string `json:"password"`
653}
654
655type loginResponse struct {
656	Username string `json:"username"`
657}
658
659type cookedError struct {
660	StatusCode int
661	Code       string
662	Message    string
663}
664
665func (e cookedError) Error() string {
666	var parts []string
667	parts = append(parts, fmt.Sprintf("Cooked returned HTTP %d", e.StatusCode))
668	if e.Code != "" {
669		parts = append(parts, e.Code)
670	}
671	if e.Message != "" {
672		parts = append(parts, e.Message)
673	}
674
675	return strings.Join(parts, ": ")
676}
677
678func decodeError(response *http.Response) error {
679	var payload struct {
680		Code    string `json:"code"`
681		Message string `json:"message"`
682	}
683	_ = json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&payload)
684
685	return cookedError{
686		StatusCode: response.StatusCode,
687		Code:       payload.Code,
688		Message:    payload.Message,
689	}
690}
691
692func isUnauthorized(err error) bool {
693	if err == nil {
694		return false
695	}
696
697	var cookedErr cookedError
698	if !errors.As(err, &cookedErr) {
699		return false
700	}
701
702	return cookedErr.StatusCode == http.StatusUnauthorized
703}
704
705func newCookieJar() (*cookiejar.Jar, error) {
706	return cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
707}