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