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