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// RecipeTextPreview is Cooked's preview of raw recipe text before saving.
 81type RecipeTextPreview struct {
 82	Title    string `json:"title"`
 83	Markdown string `json:"markdown"`
 84	Portions int    `json:"portions"`
 85}
 86
 87// NewClient returns a Cooked client with an in-memory cookie jar.
 88func NewClient(baseURL *url.URL, username, password string) (*Client, error) {
 89	jar, err := newCookieJar()
 90	if err != nil {
 91		return nil, fmt.Errorf("create cookie jar: %w", err)
 92	}
 93
 94	return &Client{
 95		baseURL:  baseURL,
 96		username: username,
 97		password: password,
 98		http: &http.Client{
 99			Jar:     jar,
100			Timeout: 30 * time.Second,
101		},
102	}, nil
103}
104
105// ReadShoppingList logs in when needed and returns the authenticated user's shopping list.
106func (c *Client) ReadShoppingList(ctx context.Context) (ShoppingList, error) {
107	var response shoppingListResponse
108	decodeShoppingList := func(decoder *json.Decoder) error {
109		return decoder.Decode(&response)
110	}
111	if err := c.doAuthenticated(
112		ctx,
113		http.MethodGet,
114		"/api/user/{username}/shopping-list",
115		nil,
116		decodeShoppingList,
117	); err != nil {
118		return ShoppingList{}, err
119	}
120
121	response.ShoppingList.Recipes = response.Recipes
122	return response.ShoppingList, nil
123}
124
125// ClearShoppingList logs in when needed and clears the authenticated user's shopping list.
126func (c *Client) ClearShoppingList(ctx context.Context) error {
127	return c.doAuthenticated(ctx, http.MethodDelete, "/api/user/{username}/shopping-list", nil, nil)
128}
129
130// ListRecipes logs in when needed and returns one page of saved recipes.
131func (c *Client) ListRecipes(ctx context.Context, page, limit int) ([]RecipeCard, error) {
132	query := url.Values{}
133	query.Set("page", fmt.Sprintf("%d", page))
134	query.Set("page-count", fmt.Sprintf("%d", limit))
135
136	return c.getRecipes(ctx, "/api/user/{username}/recipes?"+query.Encode())
137}
138
139// SearchRecipes logs in when needed and searches the authenticated user's saved recipes.
140func (c *Client) SearchRecipes(ctx context.Context, queryText string, page int) ([]RecipeCard, error) {
141	query := url.Values{}
142	query.Set("q", queryText)
143	query.Set("page", fmt.Sprintf("%d", page))
144
145	return c.getRecipes(ctx, "/api/user/{username}/recipes/search?"+query.Encode())
146}
147
148// ReadRecipeMetadata logs in when needed and returns recipe metadata.
149func (c *Client) ReadRecipeMetadata(ctx context.Context, recipeID string) (RecipeMetadata, error) {
150	var metadata RecipeMetadata
151	decodeMetadata := func(decoder *json.Decoder) error {
152		return decoder.Decode(&metadata)
153	}
154	path := "/api/recipe/" + url.PathEscape(recipeID) + "/metadata"
155	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeMetadata); err != nil {
156		return RecipeMetadata{}, err
157	}
158
159	return metadata, nil
160}
161
162// ReadRecipeContent logs in when needed and returns recipe content and portions.
163func (c *Client) ReadRecipeContent(ctx context.Context, recipeID string) (RecipeContent, error) {
164	var content RecipeContent
165	decodeContent := func(decoder *json.Decoder) error {
166		return decoder.Decode(&content)
167	}
168	path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"
169	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeContent); err != nil {
170		return RecipeContent{}, err
171	}
172
173	return content, nil
174}
175
176// PreviewRecipeText logs in when needed and previews raw recipe text without saving it.
177func (c *Client) PreviewRecipeText(ctx context.Context, title, text string) (RecipeTextPreview, error) {
178	body, err := json.Marshal(previewRecipeTextRequest{RecipeName: title, RecipeText: text})
179	if err != nil {
180		return RecipeTextPreview{}, fmt.Errorf("encode Cooked recipe text preview request: %w", err)
181	}
182
183	var preview RecipeTextPreview
184	decodePreview := func(decoder *json.Decoder) error {
185		return decoder.Decode(&preview)
186	}
187	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/new/from-text/preview", body, decodePreview); err != nil {
188		return RecipeTextPreview{}, err
189	}
190
191	return preview, nil
192}
193
194// SavePreparedRecipe logs in when needed and saves prepared recipe markdown as a new recipe.
195func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error) {
196	body, err := json.Marshal(savePreparedRecipeRequest{Title: title, Description: markdown, Portions: portions})
197	if err != nil {
198		return "", fmt.Errorf("encode Cooked prepared recipe save request: %w", err)
199	}
200
201	var response saveRecipeResponse
202	decodeSave := func(decoder *json.Decoder) error {
203		return decoder.Decode(&response)
204	}
205	if err := c.doAuthenticated(ctx, http.MethodPost, "/api/recipe/import/save", body, decodeSave); err != nil {
206		return "", err
207	}
208
209	return response.RecipeID, nil
210}
211
212// UpdateRecipeContent logs in when needed and updates an existing recipe's content.
213func (c *Client) UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error {
214	body, err := json.Marshal(updateRecipeContentRequest{Description: markdown, Portions: portions})
215	if err != nil {
216		return fmt.Errorf("encode Cooked recipe update request: %w", err)
217	}
218
219	path := "/api/recipe/" + url.PathEscape(recipeID) + "/content"
220
221	return c.doAuthenticated(ctx, http.MethodPost, path, body, nil)
222}
223
224// DeleteRecipe logs in when needed and deletes an existing recipe.
225func (c *Client) DeleteRecipe(ctx context.Context, recipeID string) error {
226	path := "/api/recipe/" + url.PathEscape(recipeID)
227
228	return c.doAuthenticated(ctx, http.MethodDelete, path, nil, nil)
229}
230
231func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, error) {
232	var response recipeListResponse
233	decodeRecipes := func(decoder *json.Decoder) error {
234		return decoder.Decode(&response)
235	}
236	if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeRecipes); err != nil {
237		return nil, err
238	}
239
240	return response.Recipes, nil
241}
242
243func (c *Client) doAuthenticated(
244	ctx context.Context,
245	method, path string,
246	body []byte,
247	decodeResponse responseDecoder,
248) error {
249	c.requestMu.Lock()
250	defer c.requestMu.Unlock()
251
252	if err := c.ensureLogin(ctx); err != nil {
253		return err
254	}
255
256	err := c.do(ctx, method, c.userPath(path), body, decodeResponse)
257	if !isUnauthorized(err) {
258		return err
259	}
260
261	c.resetSession()
262	if err := c.ensureLogin(ctx); err != nil {
263		return err
264	}
265
266	return c.do(ctx, method, c.userPath(path), body, decodeResponse)
267}
268
269func (c *Client) ensureLogin(ctx context.Context) error {
270	authenticated := c.authenticatedUsername != ""
271	if authenticated {
272		return nil
273	}
274
275	body, err := json.Marshal(loginRequest{Username: c.username, Password: c.password})
276	if err != nil {
277		return fmt.Errorf("encode Cooked login request: %w", err)
278	}
279
280	var login loginResponse
281	decodeLogin := func(decoder *json.Decoder) error {
282		return decoder.Decode(&login)
283	}
284	if err := c.do(ctx, http.MethodPost, "/api/public/login", body, decodeLogin); err != nil {
285		return fmt.Errorf("authenticate with Cooked: %w", err)
286	}
287	if login.Username == "" {
288		return fmt.Errorf("authenticate with Cooked: missing username in login response")
289	}
290
291	c.authenticatedUsername = login.Username
292
293	return nil
294}
295
296func (c *Client) resetSession() {
297	jar, err := newCookieJar()
298	if err != nil {
299		return
300	}
301
302	c.authenticatedUsername = ""
303	c.http = &http.Client{Jar: jar, Timeout: c.http.Timeout}
304}
305
306func (c *Client) do(ctx context.Context, method, path string, body []byte, decodeResponse responseDecoder) error {
307	request, err := c.newRequest(ctx, method, path, body)
308	if err != nil {
309		return err
310	}
311
312	response, err := c.http.Do(request)
313	if err != nil {
314		return fmt.Errorf("call Cooked: %w", err)
315	}
316	defer func() {
317		_ = response.Body.Close()
318	}()
319
320	if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
321		return decodeError(response)
322	}
323
324	if decodeResponse == nil {
325		return nil
326	}
327	if err := decodeResponse(json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes))); err != nil {
328		return fmt.Errorf("decode Cooked response: %w", err)
329	}
330
331	return nil
332}
333
334func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
335	relativeURL, err := url.Parse(path)
336	if err != nil {
337		return nil, fmt.Errorf("parse Cooked request path: %w", err)
338	}
339	requestURL := c.baseURL.ResolveReference(relativeURL)
340
341	var reader io.Reader
342	if body != nil {
343		reader = bytes.NewReader(body)
344	}
345
346	request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), reader)
347	if err != nil {
348		return nil, fmt.Errorf("build Cooked request: %w", err)
349	}
350	request.Header.Set("Accept", "application/json")
351	if body != nil {
352		request.Header.Set("Content-Type", "application/json")
353	}
354
355	return request, nil
356}
357
358func (c *Client) userPath(path string) string {
359	username := c.authenticatedUsername
360
361	return strings.ReplaceAll(path, "{username}", url.PathEscape(username))
362}
363
364type responseDecoder func(*json.Decoder) error
365
366type shoppingListResponse struct {
367	ShoppingList ShoppingList `json:"shopping-list"`
368	Recipes      []string     `json:"recipes"`
369}
370
371type recipeListResponse struct {
372	Recipes []RecipeCard `json:"recipes"`
373}
374
375type previewRecipeTextRequest struct {
376	RecipeName string `json:"recipe-name"`
377	RecipeText string `json:"recipe-text"`
378}
379
380type savePreparedRecipeRequest struct {
381	Title       string `json:"title"`
382	Description string `json:"description"`
383	Portions    int    `json:"portions"`
384}
385
386type updateRecipeContentRequest struct {
387	Description string `json:"description"`
388	Portions    int    `json:"portions"`
389}
390
391type saveRecipeResponse struct {
392	RecipeID string `json:"recipe-id"`
393}
394
395type loginRequest struct {
396	Username string `json:"username"`
397	Password string `json:"password"`
398}
399
400type loginResponse struct {
401	Username string `json:"username"`
402}
403
404type cookedError struct {
405	StatusCode int
406	Code       string
407	Message    string
408}
409
410func (e cookedError) Error() string {
411	var parts []string
412	parts = append(parts, fmt.Sprintf("Cooked returned HTTP %d", e.StatusCode))
413	if e.Code != "" {
414		parts = append(parts, e.Code)
415	}
416	if e.Message != "" {
417		parts = append(parts, e.Message)
418	}
419
420	return strings.Join(parts, ": ")
421}
422
423func decodeError(response *http.Response) error {
424	var payload struct {
425		Code    string `json:"code"`
426		Message string `json:"message"`
427	}
428	_ = json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&payload)
429
430	return cookedError{
431		StatusCode: response.StatusCode,
432		Code:       payload.Code,
433		Message:    payload.Message,
434	}
435}
436
437func isUnauthorized(err error) bool {
438	if err == nil {
439		return false
440	}
441
442	var cookedErr cookedError
443	if !errors.As(err, &cookedErr) {
444		return false
445	}
446
447	return cookedErr.StatusCode == http.StatusUnauthorized
448}
449
450func newCookieJar() (*cookiejar.Jar, error) {
451	return cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
452}