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