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