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