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