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}