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