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