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}