client.go

  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}