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}