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// NewClient returns a Cooked client with an in-memory cookie jar.
81func NewClient(baseURL *url.URL, username, password string) (*Client, error) {
82 jar, err := newCookieJar()
83 if err != nil {
84 return nil, fmt.Errorf("create cookie jar: %w", err)
85 }
86
87 return &Client{
88 baseURL: baseURL,
89 username: username,
90 password: password,
91 http: &http.Client{
92 Jar: jar,
93 Timeout: 30 * time.Second,
94 },
95 }, nil
96}
97
98// ReadShoppingList logs in when needed and returns the authenticated user's shopping list.
99func (c *Client) ReadShoppingList(ctx context.Context) (ShoppingList, error) {
100 var response shoppingListResponse
101 decodeShoppingList := func(decoder *json.Decoder) error {
102 return decoder.Decode(&response)
103 }
104 if err := c.doAuthenticated(
105 ctx,
106 http.MethodGet,
107 "/api/user/{username}/shopping-list",
108 nil,
109 decodeShoppingList,
110 ); err != nil {
111 return ShoppingList{}, err
112 }
113
114 response.ShoppingList.Recipes = response.Recipes
115 return response.ShoppingList, nil
116}
117
118// ListRecipes logs in when needed and returns one page of saved recipes.
119func (c *Client) ListRecipes(ctx context.Context, page, limit int) ([]RecipeCard, error) {
120 query := url.Values{}
121 query.Set("page", fmt.Sprintf("%d", page))
122 query.Set("page-count", fmt.Sprintf("%d", limit))
123
124 return c.getRecipes(ctx, "/api/user/{username}/recipes?"+query.Encode())
125}
126
127// SearchRecipes logs in when needed and searches the authenticated user's saved recipes.
128func (c *Client) SearchRecipes(ctx context.Context, queryText string, page int) ([]RecipeCard, error) {
129 query := url.Values{}
130 query.Set("q", queryText)
131 query.Set("page", fmt.Sprintf("%d", page))
132
133 return c.getRecipes(ctx, "/api/user/{username}/recipes/search?"+query.Encode())
134}
135
136// ReadRecipeMetadata logs in when needed and returns recipe metadata.
137func (c *Client) ReadRecipeMetadata(ctx context.Context, recipeID string) (RecipeMetadata, error) {
138 var metadata RecipeMetadata
139 decodeMetadata := func(decoder *json.Decoder) error {
140 return decoder.Decode(&metadata)
141 }
142 path := "/api/recipe/" + url.PathEscape(recipeID) + "/metadata"
143 if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeMetadata); err != nil {
144 return RecipeMetadata{}, err
145 }
146
147 return metadata, nil
148}
149
150// ReadRecipeContent logs in when needed and returns recipe content and portions.
151func (c *Client) ReadRecipeContent(ctx context.Context, recipeID string) (RecipeContent, error) {
152 var content RecipeContent
153 decodeContent := func(decoder *json.Decoder) error {
154 return decoder.Decode(&content)
155 }
156 path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"
157 if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeContent); err != nil {
158 return RecipeContent{}, err
159 }
160
161 return content, nil
162}
163
164func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, error) {
165 var response recipeListResponse
166 decodeRecipes := func(decoder *json.Decoder) error {
167 return decoder.Decode(&response)
168 }
169 if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeRecipes); err != nil {
170 return nil, err
171 }
172
173 return response.Recipes, nil
174}
175
176func (c *Client) doAuthenticated(
177 ctx context.Context,
178 method, path string,
179 body []byte,
180 decodeResponse responseDecoder,
181) error {
182 c.requestMu.Lock()
183 defer c.requestMu.Unlock()
184
185 if err := c.ensureLogin(ctx); err != nil {
186 return err
187 }
188
189 err := c.do(ctx, method, c.userPath(path), body, decodeResponse)
190 if !isUnauthorized(err) {
191 return err
192 }
193
194 c.resetSession()
195 if err := c.ensureLogin(ctx); err != nil {
196 return err
197 }
198
199 return c.do(ctx, method, c.userPath(path), body, decodeResponse)
200}
201
202func (c *Client) ensureLogin(ctx context.Context) error {
203 authenticated := c.authenticatedUsername != ""
204 if authenticated {
205 return nil
206 }
207
208 body, err := json.Marshal(loginRequest{Username: c.username, Password: c.password})
209 if err != nil {
210 return fmt.Errorf("encode Cooked login request: %w", err)
211 }
212
213 var login loginResponse
214 decodeLogin := func(decoder *json.Decoder) error {
215 return decoder.Decode(&login)
216 }
217 if err := c.do(ctx, http.MethodPost, "/api/public/login", body, decodeLogin); err != nil {
218 return fmt.Errorf("authenticate with Cooked: %w", err)
219 }
220 if login.Username == "" {
221 return fmt.Errorf("authenticate with Cooked: missing username in login response")
222 }
223
224 c.authenticatedUsername = login.Username
225
226 return nil
227}
228
229func (c *Client) resetSession() {
230 jar, err := newCookieJar()
231 if err != nil {
232 return
233 }
234
235 c.authenticatedUsername = ""
236 c.http = &http.Client{Jar: jar, Timeout: c.http.Timeout}
237}
238
239func (c *Client) do(ctx context.Context, method, path string, body []byte, decodeResponse responseDecoder) error {
240 request, err := c.newRequest(ctx, method, path, body)
241 if err != nil {
242 return err
243 }
244
245 response, err := c.http.Do(request)
246 if err != nil {
247 return fmt.Errorf("call Cooked: %w", err)
248 }
249 defer func() {
250 _ = response.Body.Close()
251 }()
252
253 if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
254 return decodeError(response)
255 }
256
257 if decodeResponse == nil {
258 return nil
259 }
260 if err := decodeResponse(json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes))); err != nil {
261 return fmt.Errorf("decode Cooked response: %w", err)
262 }
263
264 return nil
265}
266
267func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
268 relativeURL, err := url.Parse(path)
269 if err != nil {
270 return nil, fmt.Errorf("parse Cooked request path: %w", err)
271 }
272 requestURL := c.baseURL.ResolveReference(relativeURL)
273
274 var reader io.Reader
275 if body != nil {
276 reader = bytes.NewReader(body)
277 }
278
279 request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), reader)
280 if err != nil {
281 return nil, fmt.Errorf("build Cooked request: %w", err)
282 }
283 request.Header.Set("Accept", "application/json")
284 if body != nil {
285 request.Header.Set("Content-Type", "application/json")
286 }
287
288 return request, nil
289}
290
291func (c *Client) userPath(path string) string {
292 username := c.authenticatedUsername
293
294 return strings.ReplaceAll(path, "{username}", url.PathEscape(username))
295}
296
297type responseDecoder func(*json.Decoder) error
298
299type shoppingListResponse struct {
300 ShoppingList ShoppingList `json:"shopping-list"`
301 Recipes []string `json:"recipes"`
302}
303
304type recipeListResponse struct {
305 Recipes []RecipeCard `json:"recipes"`
306}
307
308type loginRequest struct {
309 Username string `json:"username"`
310 Password string `json:"password"`
311}
312
313type loginResponse struct {
314 Username string `json:"username"`
315}
316
317type cookedError struct {
318 StatusCode int
319 Code string
320 Message string
321}
322
323func (e cookedError) Error() string {
324 var parts []string
325 parts = append(parts, fmt.Sprintf("Cooked returned HTTP %d", e.StatusCode))
326 if e.Code != "" {
327 parts = append(parts, e.Code)
328 }
329 if e.Message != "" {
330 parts = append(parts, e.Message)
331 }
332
333 return strings.Join(parts, ": ")
334}
335
336func decodeError(response *http.Response) error {
337 var payload struct {
338 Code string `json:"code"`
339 Message string `json:"message"`
340 }
341 _ = json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&payload)
342
343 return cookedError{
344 StatusCode: response.StatusCode,
345 Code: payload.Code,
346 Message: payload.Message,
347 }
348}
349
350func isUnauthorized(err error) bool {
351 if err == nil {
352 return false
353 }
354
355 var cookedErr cookedError
356 if !errors.As(err, &cookedErr) {
357 return false
358 }
359
360 return cookedErr.StatusCode == http.StatusUnauthorized
361}
362
363func newCookieJar() (*cookiejar.Jar, error) {
364 return cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
365}