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// 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}