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