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