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