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