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