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