ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

unexport a bunch of stuff (#496)

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
78cba699 49583bf2

+138 -139
+3 -3
cmd/careme/middleware.go
··· 233 233 } 234 234 235 235 // just recover and log 236 - func BaseMiddleware(h http.Handler) http.Handler { 236 + func baseMiddleware(h http.Handler) http.Handler { 237 237 h = &recoverer{h} 238 238 return &logger{h} 239 239 } 240 240 241 241 // instrument with app insights and log with operation and session ids. 242 - func AppMiddleWare(h http.Handler, tracker requestTracker) http.Handler { 243 - h = BaseMiddleware(h) 242 + func appMiddleware(h http.Handler, tracker requestTracker) http.Handler { 243 + h = baseMiddleware(h) 244 244 h = newAppInsightsTracker(h, tracker) // must be "inside" operatid and session handler. 245 245 h = &operationIDHandler{h} 246 246 return &sessionIDHandler{h}
+4 -4
cmd/careme/middleware_test.go
··· 252 252 func TestWithMiddlewareProvidesBothIDs(t *testing.T) { 253 253 var operationID string 254 254 var sessionID string 255 - handler := AppMiddleWare(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 255 + handler := appMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 256 256 operationID, _ = logsetup.OperationIDFromContext(r.Context()) 257 257 sessionID, _ = logsetup.SessionIDFromContext(r.Context()) 258 258 w.WriteHeader(http.StatusNoContent) ··· 280 280 func TestWithMiddlewareProvidesIDsWithoutTracker(t *testing.T) { 281 281 var operationID string 282 282 var sessionID string 283 - handler := AppMiddleWare(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 283 + handler := appMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 284 284 operationID, _ = logsetup.OperationIDFromContext(r.Context()) 285 285 sessionID, _ = logsetup.SessionIDFromContext(r.Context()) 286 286 w.WriteHeader(http.StatusNoContent) ··· 330 330 appMux.HandleFunc("/about", func(w http.ResponseWriter, _ *http.Request) { 331 331 w.WriteHeader(http.StatusNoContent) 332 332 }) 333 - rootMux.Handle("/static/", BaseMiddleware(infraMux)) 334 - rootMux.Handle("/", AppMiddleWare(appMux, &fakeRequestTracker{})) 333 + rootMux.Handle("/static/", baseMiddleware(infraMux)) 334 + rootMux.Handle("/", appMiddleware(appMux, &fakeRequestTracker{})) 335 335 336 336 staticReq := httptest.NewRequest(http.MethodGet, "http://careme.cooking/static/app.js", nil) 337 337 staticRec := httptest.NewRecorder()
+5 -5
cmd/careme/ready.go
··· 12 12 13 13 type readyOnce struct { 14 14 done bool 15 - checks []Readyable 15 + checks []readyable 16 16 mu sync.Mutex 17 17 } 18 18 19 - func (r *readyOnce) Ready(ctx context.Context) error { 19 + func (r *readyOnce) ready(ctx context.Context) error { 20 20 r.mu.Lock() 21 21 defer r.mu.Unlock() 22 22 if r.done { ··· 33 33 return nil 34 34 } 35 35 36 - type Readyable interface { 36 + type readyable interface { 37 37 Ready(context.Context) error 38 38 } 39 39 40 - func (r *readyOnce) Add(f ...Readyable) { 40 + func (r *readyOnce) add(f ...readyable) { 41 41 r.checks = append(r.checks, f...) 42 42 } 43 43 44 44 func (r *readyOnce) ServeHTTP(w http.ResponseWriter, req *http.Request) { 45 - if err := r.Ready(req.Context()); err != nil { 45 + if err := r.ready(req.Context()); err != nil { 46 46 http.Error(w, "not ready: "+err.Error(), http.StatusServiceUnavailable) 47 47 return 48 48 }
+5 -5
cmd/careme/web.go
··· 51 51 52 52 rootMux := http.NewServeMux() 53 53 appRoutes := routing.Wrap(rootMux, func(h http.Handler) http.Handler { 54 - return authClient.WithAuthHTTP(AppMiddleWare(h, newRequestTrackerFromEnv())) 54 + return authClient.WithAuthHTTP(appMiddleware(h, newRequestTrackerFromEnv())) 55 55 }) 56 - infraRoutes := routing.Wrap(rootMux, BaseMiddleware) 56 + infraRoutes := routing.Wrap(rootMux, baseMiddleware) 57 57 58 58 authClient.Register(appRoutes) 59 59 static.Register(infraRoutes) ··· 69 69 generator = recipes.NewMockGenerator() 70 70 } else { 71 71 mc := critique.NewManager(cfg, cache) 72 - ro.Add(mc) 72 + ro.add(mc) 73 73 aiclient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL") 74 - ro.Add(aiclient) 74 + ro.add(aiclient) 75 75 staples, err := recipes.NewCachedStaplesService(cfg, cache) 76 76 if err != nil { 77 77 return fmt.Errorf("failed to create staples service: %w", err) ··· 96 96 userHandler.Register(appRoutes) 97 97 98 98 locationServer := locations.NewServer(locationStorage, centroids, userStorage) 99 - ro.Add(locationServer) 99 + ro.add(locationServer) 100 100 locationServer.Register(appRoutes, authClient) 101 101 102 102 sitemapHandler := sitemap.New(cache, cfg.ResolvedPublicOrigin())
+3 -3
cmd/careme/web_e2e_test.go
··· 173 173 174 174 rootMux := http.NewServeMux() 175 175 appRoutes := routing.Wrap(rootMux, func(h http.Handler) http.Handler { 176 - return mockAuth.WithAuthHTTP(AppMiddleWare(h, &fakeRequestTracker{})) 176 + return mockAuth.WithAuthHTTP(appMiddleware(h, &fakeRequestTracker{})) 177 177 }) 178 - infraRoutes := routing.Wrap(rootMux, BaseMiddleware) 178 + infraRoutes := routing.Wrap(rootMux, baseMiddleware) 179 179 locationServer := locations.NewServer(locationStorage, centroids, userStorage) 180 180 locationServer.Register(appRoutes, mockAuth) 181 181 utfactory := users.FakeUnsubscribeTokenFactory() ··· 183 183 recipes.NewHandler(cfg, userStorage, generator, locationStorage, cacheStore, cacheStore, mockAuth).Register(appRoutes) 184 184 185 185 ro := &readyOnce{} 186 - ro.Add(locationServer) 186 + ro.add(locationServer) 187 187 188 188 infraRoutes.Handle("/ready", ro) 189 189
+10 -10
internal/ai/client.go
··· 23 23 "github.com/invopop/jsonschema" 24 24 ) 25 25 26 - type Client struct { 26 + type client struct { 27 27 apiKey string 28 28 schema map[string]any 29 29 wineSchema map[string]any ··· 93 93 } 94 94 95 95 // ignoring model for now. 96 - func NewClient(apiKey, _ string) *Client { 96 + func NewClient(apiKey, _ string) *client { 97 97 // ignor model for now. 98 98 r := jsonschema.Reflector{ 99 99 DoNotReference: true, // no $defs and no $ref ··· 107 107 _ = json.Unmarshal(recipesSchemaJSON, &m) 108 108 var wine map[string]any 109 109 _ = json.Unmarshal(wineSchemaJSON, &wine) 110 - return &Client{ 110 + return &client{ 111 111 apiKey: apiKey, 112 112 schema: m, 113 113 wineSchema: wine, ··· 209 209 } 210 210 } 211 211 212 - func (c *Client) Regenerate(ctx context.Context, instructions []string, conversationID string) (*ShoppingList, error) { 212 + func (c *client) Regenerate(ctx context.Context, instructions []string, conversationID string) (*ShoppingList, error) { 213 213 if conversationID == "" { 214 214 return nil, fmt.Errorf("conversation ID is required for regeneration") 215 215 } ··· 240 240 return responseToShoppingList(ctx, resp) 241 241 } 242 242 243 - func (c *Client) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 243 + func (c *client) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 244 244 question = strings.TrimSpace(question) 245 245 if question == "" { 246 246 return "", fmt.Errorf("question is required") ··· 272 272 return answer, nil 273 273 } 274 274 275 - func (c *Client) GenerateRecipeImage(ctx context.Context, recipe Recipe) (*GeneratedImage, error) { 275 + func (c *client) GenerateRecipeImage(ctx context.Context, recipe Recipe) (*GeneratedImage, error) { 276 276 prompt, err := buildRecipeImagePrompt(recipe) 277 277 if err != nil { 278 278 return nil, fmt.Errorf("failed to build recipe image prompt: %w", err) ··· 336 336 ) 337 337 } 338 338 339 - func (c *Client) PickWine(ctx context.Context, recipe Recipe, wines []kroger.Ingredient) (*WineSelection, error) { 339 + func (c *client) PickWine(ctx context.Context, recipe Recipe, wines []kroger.Ingredient) (*WineSelection, error) { 340 340 prompt, err := buildWineSelectionPrompt(recipe, wines) 341 341 if err != nil { 342 342 return nil, fmt.Errorf("failed to build wine selection prompt: %w", err) ··· 363 363 } 364 364 365 365 // is this dependency on krorger unncessary? just pass in a blob of toml or whatever? same with last recipes? 366 - func (c *Client) GenerateRecipes(ctx context.Context, location *locations.Location, saleIngredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ShoppingList, error) { 366 + func (c *client) GenerateRecipes(ctx context.Context, location *locations.Location, saleIngredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ShoppingList, error) { 367 367 messages, err := c.buildRecipeMessages(location, saleIngredients, instructions, date, lastRecipes) 368 368 if err != nil { 369 369 return nil, fmt.Errorf("failed to build recipe messages: %w", err) ··· 439 439 } 440 440 441 441 // buildRecipeMessages creates separate messages for the LLM to process more efficiently 442 - func (c *Client) buildRecipeMessages(location *locations.Location, saleIngredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (responses.ResponseInputParam, error) { 442 + func (c *client) buildRecipeMessages(location *locations.Location, saleIngredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (responses.ResponseInputParam, error) { 443 443 var messages []responses.ResponseInputItemUnionParam 444 444 // constants we might make variable later 445 445 messages = append(messages, user("Prioritize ingredients that are in season for the current date and user's state location "+date.Format("January 2nd")+" in "+location.State+".")) ··· 473 473 return messages, nil 474 474 } 475 475 476 - func (c *Client) Ready(ctx context.Context) error { 476 + func (c *client) Ready(ctx context.Context) error { 477 477 // more CORRECT to do a very simple response request with allowed tokens 1 but this seems cheaper 478 478 // https://chatgpt.com/share/6984da16-ff88-8009-8486-4e0479ac6a01 479 479 // could only do it once to ensure startup
+8 -9
internal/recipes/generator.go
··· 35 35 GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int, date time.Time) ([]kroger.Ingredient, error) 36 36 } 37 37 38 - // TODO unexport? 39 - type Generator struct { 38 + type generatorService struct { 40 39 aiClient aiClient 41 40 critiquer critique.Service 42 41 staples staplesService 43 42 } 44 43 45 - func NewGenerator(aiClient aiClient, critiquer critique.Service, staples staplesService) (*Generator, error) { 44 + func NewGenerator(aiClient aiClient, critiquer critique.Service, staples staplesService) (*generatorService, error) { 46 45 if aiClient == nil { 47 46 return nil, fmt.Errorf("ai client is required") 48 47 } ··· 52 51 if staples == nil { 53 52 return nil, fmt.Errorf("staples service is required") 54 53 } 55 - return &Generator{ 54 + return &generatorService{ 56 55 aiClient: aiClient, 57 56 critiquer: critiquer, 58 57 staples: staples, 59 58 }, nil 60 59 } 61 60 62 - func (g *Generator) PickAWine(ctx context.Context, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 61 + func (g *generatorService) PickAWine(ctx context.Context, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 63 62 var styles []string 64 63 for _, style := range recipe.WineStyles { 65 64 style = strings.TrimSpace(style) ··· 95 94 return selection, nil 96 95 } 97 96 98 - func (g *Generator) GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) { 97 + func (g *generatorService) GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) { 99 98 hash := p.Hash() 100 99 start := time.Now() 101 100 ··· 147 146 return shoppingList, nil 148 147 } 149 148 150 - func (g *Generator) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 149 + func (g *generatorService) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 151 150 return g.aiClient.AskQuestion(ctx, question, conversationID) 152 151 } 153 152 154 - func (g *Generator) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { 153 + func (g *generatorService) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { 155 154 return g.aiClient.GenerateRecipeImage(ctx, recipe) 156 155 } 157 156 ··· 189 188 return lo.Uniq(titles) 190 189 } 191 190 192 - func (g *Generator) critiqueAndMaybeRetry(ctx context.Context, shoppingList *ai.ShoppingList) (*ai.ShoppingList, error) { 191 + func (g *generatorService) critiqueAndMaybeRetry(ctx context.Context, shoppingList *ai.ShoppingList) (*ai.ShoppingList, error) { 193 192 if g.critiquer == nil { 194 193 return shoppingList, nil 195 194 }
+10 -10
internal/recipes/generator_test.go
··· 275 275 Commentary: "Great with your dish.", 276 276 }, 277 277 } 278 - g := &Generator{ 278 + g := &generatorService{ 279 279 staples: &cachedStaplesService{cache: rio, provider: &captureWineStaplesProvider{}}, 280 280 aiClient: aiStub, 281 281 } ··· 321 321 "sparkling": {{Description: loPtr("Whole Foods Bubbly")}}, 322 322 }, 323 323 } 324 - g := &Generator{ 324 + g := &generatorService{ 325 325 staples: &cachedStaplesService{cache: IO(cache.NewFileCache(t.TempDir())), provider: staplesStub}, 326 326 aiClient: aiStub, 327 327 } ··· 360 360 Recipes: []ai.Recipe{newResult}, 361 361 }, 362 362 } 363 - g := &Generator{ 363 + g := &generatorService{ 364 364 aiClient: aiStub, 365 365 } 366 366 ··· 415 415 }, 416 416 } 417 417 critiquer := &captureCritiqueService{} 418 - g := &Generator{ 418 + g := &generatorService{ 419 419 staples: &cachedStaplesService{cache: io}, 420 420 aiClient: aiStub, 421 421 critiquer: critiquer, ··· 441 441 newResult := ai.Recipe{Title: "Brand New Dinner", Description: "Fresh idea"} 442 442 443 443 critiquer := &captureCritiqueService{} 444 - g := &Generator{ 444 + g := &generatorService{ 445 445 aiClient: &captureRegenerateAIClient{shoppingList: &ai.ShoppingList{ConversationID: "conv-123", Recipes: []ai.Recipe{newResult}}}, 446 446 critiquer: critiquer, 447 447 } ··· 511 511 }, 512 512 } 513 513 514 - g := &Generator{ 514 + g := &generatorService{ 515 515 staples: &cachedStaplesService{cache: io}, 516 516 aiClient: aiStub, 517 517 critiquer: critiquer, ··· 597 597 } 598 598 }, 599 599 } 600 - g := &Generator{ 600 + g := &generatorService{ 601 601 staples: &cachedStaplesService{cache: io}, 602 602 aiClient: aiStub, 603 603 critiquer: critiquer, ··· 631 631 Recipes: []ai.Recipe{steady}, 632 632 }}, 633 633 } 634 - g := &Generator{ 634 + g := &generatorService{ 635 635 staples: &cachedStaplesService{cache: io}, 636 636 aiClient: aiStub, 637 637 critiquer: &captureCritiqueService{}, ··· 693 693 } 694 694 }, 695 695 } 696 - g := &Generator{ 696 + g := &generatorService{ 697 697 aiClient: aiStub, 698 698 critiquer: critiquer, 699 699 } ··· 764 764 }, nil 765 765 }, 766 766 } 767 - g := &Generator{ 767 + g := &generatorService{ 768 768 staples: &cachedStaplesService{cache: io}, 769 769 aiClient: aiStub, 770 770 critiquer: critiquer,
+1 -1
internal/watchdog/onceper.go
··· 18 18 dog watchdog 19 19 } 20 20 21 - func NewOncePer(period time.Duration, dog watchdog) oncePer { 21 + func newOncePer(period time.Duration, dog watchdog) oncePer { 22 22 return oncePer{ 23 23 period: period, 24 24 dog: dog,
+1 -1
internal/watchdog/onceper_test.go
··· 11 11 t.Parallel() 12 12 13 13 dog := &stubWatchdog{} 14 - guard := NewOncePer(time.Hour, dog) 14 + guard := newOncePer(time.Hour, dog) 15 15 16 16 if err := guard.Watchdog(context.Background()); err != nil { 17 17 t.Fatalf("first call: %v", err)
+1 -1
internal/watchdog/server.go
··· 26 26 } 27 27 28 28 func (s *Server) Add(name string, dog watchdog, period time.Duration) { 29 - guard := NewOncePer(period, dog) 29 + guard := newOncePer(period, dog) 30 30 s.watchers = append(s.watchers, watcher{ 31 31 name: name, 32 32 period: period,
+29 -29
internal/wholefoods/client.go
··· 18 18 defaultCategoryLimit = 60 19 19 ) 20 20 21 - // Client calls the public Whole Foods category products endpoint. 22 - type Client struct { 21 + // client calls the public Whole Foods category products endpoint. 22 + type client struct { 23 23 baseURL string 24 24 httpClient *http.Client 25 25 } 26 26 27 - // CategoryResponse matches the public category API payload shape used in wf-output/beef.json. 28 - type CategoryResponse struct { 29 - Facets []Facet `json:"facets"` 30 - Breadcrumb []Breadcrumb `json:"breadcrumb"` 31 - Results []Product `json:"results"` 32 - Meta Meta `json:"meta"` 27 + // categoryResponse matches the public category API payload shape used in wf-output/beef.json. 28 + type categoryResponse struct { 29 + Facets []facet `json:"facets"` 30 + Breadcrumb []breadcrumb `json:"breadcrumb"` 31 + Results []product `json:"results"` 32 + Meta meta `json:"meta"` 33 33 } 34 34 35 - type Facet struct { 35 + type facet struct { 36 36 Label string `json:"label"` 37 37 Slug string `json:"slug"` 38 38 Type string `json:"type,omitempty"` 39 - Refinements []FacetRefinement `json:"refinements"` 39 + Refinements []facetRefinement `json:"refinements"` 40 40 } 41 41 42 - type FacetRefinement struct { 42 + type facetRefinement struct { 43 43 Label string `json:"label"` 44 44 Slug string `json:"slug"` 45 45 Count int `json:"count"` 46 46 IsSelected bool `json:"isSelected"` 47 47 Disabled bool `json:"disabled"` 48 - Refinements []FacetRefinement `json:"refinements,omitempty"` 48 + Refinements []facetRefinement `json:"refinements,omitempty"` 49 49 } 50 50 51 - type Breadcrumb struct { 51 + type breadcrumb struct { 52 52 Label string `json:"label"` 53 53 Slug string `json:"slug"` 54 54 } 55 55 56 - type Product struct { 56 + type product struct { 57 57 RegularPrice float64 `json:"regularPrice"` 58 58 SalePrice float64 `json:"salePrice,omitempty"` 59 59 IncrementalSalePrice float64 `json:"incrementalSalePrice,omitempty"` ··· 69 69 UOM string `json:"uom,omitempty"` 70 70 } 71 71 72 - type Meta struct { 73 - Total Total `json:"total"` 74 - State State `json:"state"` 72 + type meta struct { 73 + Total total `json:"total"` 74 + State state `json:"state"` 75 75 } 76 76 77 - type Total struct { 77 + type total struct { 78 78 Value int `json:"value"` 79 79 Relation string `json:"relation"` 80 80 } 81 81 82 - type State struct { 83 - Refinements []StateRefinement `json:"refinements"` 82 + type state struct { 83 + Refinements []stateRefinement `json:"refinements"` 84 84 Sort string `json:"sort"` 85 85 } 86 86 87 - type StateRefinement struct { 87 + type stateRefinement struct { 88 88 Label string `json:"label"` 89 89 Slug string `json:"slug"` 90 90 FilterSlug string `json:"filterSlug"` ··· 134 134 } 135 135 136 136 // NewClient creates a Whole Foods client with a default base URL and timeout. 137 - func NewClient(httpClient *http.Client) *Client { 137 + func NewClient(httpClient *http.Client) *client { 138 138 return NewClientWithBaseURL(DefaultBaseURL, httpClient) 139 139 } 140 140 141 141 // NewClientWithBaseURL creates a Whole Foods client for the provided base URL. 142 - func NewClientWithBaseURL(baseURL string, httpClient *http.Client) *Client { 142 + func NewClientWithBaseURL(baseURL string, httpClient *http.Client) *client { 143 143 baseURL = strings.TrimSpace(baseURL) 144 144 if baseURL == "" { 145 145 baseURL = DefaultBaseURL ··· 148 148 httpClient = &http.Client{Timeout: 20 * time.Second} 149 149 } 150 150 151 - return &Client{ 151 + return &client{ 152 152 baseURL: strings.TrimRight(baseURL, "/"), 153 153 httpClient: httpClient, 154 154 } ··· 156 156 157 157 // Category fetches category products and follows limit/offset pagination until 158 158 // the API returns fewer items than the requested page size. 159 - func (c *Client) Category(ctx context.Context, queryterm, store string) ([]Product, error) { 159 + func (c *client) Category(ctx context.Context, queryterm, store string) ([]product, error) { 160 160 queryterm = strings.TrimSpace(queryterm) 161 161 if queryterm == "" { 162 162 return nil, errors.New("queryterm is required") ··· 172 172 return nil, fmt.Errorf("parse category URL: %w", err) 173 173 } 174 174 175 - var combined []Product 175 + var combined []product 176 176 for offset := 0; ; offset += defaultCategoryLimit { 177 177 params := endpoint.Query() 178 178 params.Set("store", store) ··· 180 180 params.Set("offset", fmt.Sprintf("%d", offset)) 181 181 endpoint.RawQuery = params.Encode() 182 182 183 - var page CategoryResponse 183 + var page categoryResponse 184 184 if err := c.getJSON(ctx, endpoint.String(), &page); err != nil { 185 185 return nil, err 186 186 } ··· 196 196 var ErrNotFound = fmt.Errorf("store not found") 197 197 198 198 // StoreSummary fetches a store summary payload like /api/stores/10216/summary. 199 - func (c *Client) StoreSummary(ctx context.Context, store string) (*StoreSummaryResponse, error) { 199 + func (c *client) StoreSummary(ctx context.Context, store string) (*StoreSummaryResponse, error) { 200 200 store = strings.TrimSpace(store) 201 201 if store == "" { 202 202 return nil, errors.New("store is required") ··· 211 211 return &decoded, nil 212 212 } 213 213 214 - func (c *Client) getJSON(ctx context.Context, endpoint string, dest any) error { 214 + func (c *client) getJSON(ctx context.Context, endpoint string, dest any) error { 215 215 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 216 216 if err != nil { 217 217 return fmt.Errorf("build request: %w", err)
+6 -6
internal/wholefoods/client_test.go
··· 167 167 pageSize = 5 168 168 } 169 169 170 - results := make([]Product, 0, pageSize) 170 + results := make([]product, 0, pageSize) 171 171 for i := 0; i < pageSize; i++ { 172 172 n := offset + i 173 - results = append(results, Product{ 173 + results = append(results, product{ 174 174 Name: fmt.Sprintf("Product %d", n), 175 175 Slug: fmt.Sprintf("product-%d", n), 176 176 Brand: "Whole Foods Market", ··· 178 178 }) 179 179 } 180 180 181 - resp := CategoryResponse{ 182 - Breadcrumb: []Breadcrumb{{Label: "Meat", Slug: "meat"}, {Label: "Fish", Slug: "fish"}}, 183 - Meta: Meta{ 184 - Total: Total{Value: limit*2 + 5, Relation: "eq"}, 181 + resp := categoryResponse{ 182 + Breadcrumb: []breadcrumb{{Label: "Meat", Slug: "meat"}, {Label: "Fish", Slug: "fish"}}, 183 + Meta: meta{ 184 + Total: total{Value: limit*2 + 5, Relation: "eq"}, 185 185 }, 186 186 Results: results, 187 187 }
+38 -38
internal/wholefoods/products.go
··· 16 16 defaultProductSearchSort = "relevanceblender" 17 17 ) 18 18 19 - // ProductSearchRequest configures a call to the Whole Foods product search API. 20 - type ProductSearchRequest struct { 19 + // productSearchRequest configures a call to the Whole Foods product search API. 20 + type productSearchRequest struct { 21 21 Text string 22 22 OfferListingDiscriminator string 23 23 Offset int ··· 28 28 Categories []string 29 29 } 30 30 31 - // ProductSearchResponse matches the public Whole Foods search payload returned by the WWOS RSI API. 32 - type ProductSearchResponse struct { 33 - MainResultSet ProductSearchResultSet `json:"mainResultSet"` 31 + // productSearchResponse matches the public Whole Foods search payload returned by the WWOS RSI API. 32 + type productSearchResponse struct { 33 + MainResultSet productSearchResultSet `json:"mainResultSet"` 34 34 } 35 35 36 - type ProductSearchResultSet struct { 37 - SearchResults []ProductSearchResult `json:"searchResults"` 36 + type productSearchResultSet struct { 37 + SearchResults []productSearchResult `json:"searchResults"` 38 38 ApproximateTotalResultCount int `json:"approximateTotalResultCount"` 39 39 AvailableTotalResultCount int `json:"availableTotalResultCount"` 40 40 TotalResultCountPreVE int `json:"totalResultCountPreVE"` 41 41 Keywords string `json:"keywords"` 42 - AugmentModifications []ProductSearchAugmentModification `json:"augmentModifications,omitempty"` 42 + AugmentModifications []productSearchAugmentModification `json:"augmentModifications,omitempty"` 43 43 } 44 44 45 - type ProductSearchResult struct { 45 + type productSearchResult struct { 46 46 ASIN string `json:"asin"` 47 47 InjectionSource string `json:"injectionSource"` 48 48 IsAdultProduct bool `json:"isAdultProduct"` ··· 50 50 AmazonsChoiceExactLabel bool `json:"amazonsChoiceExactLabel"` 51 51 } 52 52 53 - type ProductSearchAugmentModification struct { 53 + type productSearchAugmentModification struct { 54 54 Action string `json:"action"` 55 55 Type string `json:"type"` 56 56 Source string `json:"source"` 57 57 Metadata map[string]string `json:"metadata,omitempty"` 58 58 } 59 59 60 - // ProductHydrationRequest configures a call to the Whole Foods product hydration API. 61 - type ProductHydrationRequest struct { 60 + // productHydrationRequest configures a call to the Whole Foods product hydration API. 61 + type productHydrationRequest struct { 62 62 OfferListingDiscriminator string 63 63 ProgramType string 64 64 ASINs []string 65 65 } 66 66 67 - // ProductHydrationResponse matches the public Whole Foods WWOS product hydration payload. 68 - type ProductHydrationResponse []HydratedProduct 67 + // productHydrationResponse matches the public Whole Foods WWOS product hydration payload. 68 + type productHydrationResponse []hydratedProduct 69 69 70 - type HydratedProduct struct { 70 + type hydratedProduct struct { 71 71 BrandName string `json:"brandName"` 72 72 Name string `json:"name"` 73 73 ASIN string `json:"asin"` ··· 77 77 ProductImages []string `json:"productImages"` 78 78 Availability string `json:"availability"` 79 79 PDPType string `json:"pdpType"` 80 - OfferDetails *HydratedOfferDetails `json:"offerDetails"` 81 - VariableUnitOfMeasure *HydratedVariableUnitOfMeasure `json:"variableUnitOfMeasure"` 80 + OfferDetails *hydratedOfferDetails `json:"offerDetails"` 81 + VariableUnitOfMeasure *hydratedVariableUnitOfMeasure `json:"variableUnitOfMeasure"` 82 82 CTATag string `json:"ctaTag,omitempty"` 83 83 DeliveryPromiseHTML string `json:"deliveryPromiseHtml,omitempty"` 84 84 DietTypes []string `json:"dietTypes,omitempty"` 85 - Category HydratedCategory `json:"category"` 85 + Category hydratedCategory `json:"category"` 86 86 } 87 87 88 - type HydratedOfferDetails struct { 89 - Price HydratedPrice `json:"price"` 88 + type hydratedOfferDetails struct { 89 + Price hydratedPrice `json:"price"` 90 90 OfferListingID string `json:"offerListingId"` 91 91 MaxOrderQuantity int `json:"maxOrderQuantity"` 92 92 IsMaxQuantityRestricted bool `json:"isMaxQuantityRestricted"` 93 93 } 94 94 95 - type HydratedPrice struct { 95 + type hydratedPrice struct { 96 96 CurrencyCode string `json:"currencyCode"` 97 97 PriceAmount float64 `json:"priceAmount"` 98 98 BasisPriceAmount *float64 `json:"basisPriceAmount"` 99 - Savings HydratedSavings `json:"savings"` 100 - PrimeBenefit HydratedPrimeBenefit `json:"primeBenefit"` 99 + Savings hydratedSavings `json:"savings"` 100 + PrimeBenefit hydratedPrimeBenefit `json:"primeBenefit"` 101 101 } 102 102 103 - type HydratedSavings struct { 103 + type hydratedSavings struct { 104 104 CurrencyCode *string `json:"currencyCode"` 105 105 SavingsAmount *float64 `json:"savingsAmount"` 106 106 PercentSavings *float64 `json:"percentSavings"` 107 107 } 108 108 109 - type HydratedPrimeBenefit struct { 109 + type hydratedPrimeBenefit struct { 110 110 IsApplied *bool `json:"isApplied"` 111 111 Text *string `json:"text"` 112 112 CurrencyCode *string `json:"currencyCode"` ··· 114 114 SavingsAmount *float64 `json:"savingsAmount"` 115 115 } 116 116 117 - type HydratedVariableUnitOfMeasure struct { 118 - PricingUOM HydratedUnitOfMeasure `json:"pricingUom"` 119 - SellingUOM HydratedUnitOfMeasure `json:"sellingUom"` 120 - SelectorItemList []HydratedSelectorItem `json:"selectorItemList"` 117 + type hydratedVariableUnitOfMeasure struct { 118 + PricingUOM hydratedUnitOfMeasure `json:"pricingUom"` 119 + SellingUOM hydratedUnitOfMeasure `json:"sellingUom"` 120 + SelectorItemList []hydratedSelectorItem `json:"selectorItemList"` 121 121 } 122 122 123 - type HydratedUnitOfMeasure struct { 123 + type hydratedUnitOfMeasure struct { 124 124 Dimension string `json:"dimension"` 125 125 Unit string `json:"unit"` 126 126 } 127 127 128 - type HydratedSelectorItem struct { 129 - SelectorPrice HydratedSelectorPrice `json:"selectorPrice"` 128 + type hydratedSelectorItem struct { 129 + SelectorPrice hydratedSelectorPrice `json:"selectorPrice"` 130 130 SelectorSellingQuantityString string `json:"selectorSellingQuantityString"` 131 131 SelectorSellingQuantityValue int `json:"selectorSellingQuantityValue"` 132 132 } 133 133 134 - type HydratedSelectorPrice struct { 134 + type hydratedSelectorPrice struct { 135 135 BaseUnit *string `json:"baseUnit"` 136 136 CurrencyCode string `json:"currencyCode"` 137 137 PriceAmount float64 `json:"priceAmount"` 138 138 } 139 139 140 - type HydratedCategory struct { 140 + type hydratedCategory struct { 141 141 ProductType string `json:"productType"` 142 142 GLProductGroupSymbol string `json:"glProductGroupSymbol"` 143 143 DisplayName string `json:"displayName"` ··· 146 146 // ProductSearch fetches search results from 147 147 // https://www.wholefoodsmarket.com/api/wwos/rsi/search?text=merlot&old=A04C&offset=0&size=30&sort=relevanceblender&programType=GROCERY&filters=&categories=18473610011 148 148 // where the Whole Foods API uses the query parameter name "old" for the offer listing discriminator. 149 - func (c *Client) ProductSearch(ctx context.Context, req ProductSearchRequest) (*ProductSearchResponse, error) { 149 + func (c *client) productSearch(ctx context.Context, req productSearchRequest) (*productSearchResponse, error) { 150 150 text := strings.TrimSpace(req.Text) 151 151 if text == "" { 152 152 return nil, errors.New("text is required") ··· 198 198 endpoint.RawQuery = params.Encode() 199 199 200 200 slog.InfoContext(ctx, "wf product search", "url", endpoint) 201 - var decoded ProductSearchResponse 201 + var decoded productSearchResponse 202 202 if err := c.getJSON(ctx, endpoint.String(), &decoded); err != nil { 203 203 return nil, err 204 204 } ··· 208 208 // ProductHydration fetches hydrated product records from 209 209 // https://www.wholefoodsmarket.com/api/wwos/products?offerListingDiscriminator=A04C&programType=GROCERY&asins=B06WVGV73Z%2CB07G4TKBFP 210 210 // where the asins query parameter is a comma-separated list of Whole Foods ASINs. 211 - func (c *Client) ProductHydration(ctx context.Context, req ProductHydrationRequest) (ProductHydrationResponse, error) { 211 + func (c *client) productHydration(ctx context.Context, req productHydrationRequest) (productHydrationResponse, error) { 212 212 discriminator := strings.TrimSpace(req.OfferListingDiscriminator) 213 213 if discriminator == "" { 214 214 return nil, errors.New("offer listing discriminator is required") ··· 236 236 endpoint.RawQuery = params.Encode() 237 237 238 238 slog.InfoContext(ctx, "wf product hydration", "url", endpoint) 239 - var decoded ProductHydrationResponse 239 + var decoded productHydrationResponse 240 240 if err := c.getJSON(ctx, endpoint.String(), &decoded); err != nil { 241 241 return nil, err 242 242 }
+6 -6
internal/wholefoods/products_test.go
··· 56 56 57 57 client := NewClientWithBaseURL(server.URL, server.Client()) 58 58 59 - resp, err := client.ProductSearch(context.Background(), ProductSearchRequest{ 59 + resp, err := client.productSearch(context.Background(), productSearchRequest{ 60 60 Text: " merlot ", 61 61 OfferListingDiscriminator: " A04C ", 62 62 Categories: []string{"18473610011"}, ··· 118 118 119 119 client := NewClient(nil) 120 120 121 - _, err := client.ProductSearch(context.Background(), ProductSearchRequest{ 121 + _, err := client.productSearch(context.Background(), productSearchRequest{ 122 122 OfferListingDiscriminator: "A04C", 123 123 }) 124 124 if err == nil || !strings.Contains(err.Error(), "text is required") { 125 125 t.Fatalf("unexpected text error: %v", err) 126 126 } 127 127 128 - _, err = client.ProductSearch(context.Background(), ProductSearchRequest{ 128 + _, err = client.productSearch(context.Background(), productSearchRequest{ 129 129 Text: "merlot", 130 130 }) 131 131 if err == nil || !strings.Contains(err.Error(), "offer listing discriminator is required") { ··· 236 236 237 237 client := NewClientWithBaseURL(server.URL, server.Client()) 238 238 239 - resp, err := client.ProductHydration(context.Background(), ProductHydrationRequest{ 239 + resp, err := client.productHydration(context.Background(), productHydrationRequest{ 240 240 OfferListingDiscriminator: " A04C ", 241 241 ASINs: []string{"B06WVGV73Z", " B07G4TKBFP "}, 242 242 }) ··· 291 291 292 292 client := NewClient(nil) 293 293 294 - _, err := client.ProductHydration(context.Background(), ProductHydrationRequest{ 294 + _, err := client.productHydration(context.Background(), productHydrationRequest{ 295 295 ASINs: []string{"B06WVGV73Z"}, 296 296 }) 297 297 if err == nil || !strings.Contains(err.Error(), "offer listing discriminator is required") { 298 298 t.Fatalf("unexpected discriminator error: %v", err) 299 299 } 300 300 301 - _, err = client.ProductHydration(context.Background(), ProductHydrationRequest{ 301 + _, err = client.productHydration(context.Background(), productHydrationRequest{ 302 302 OfferListingDiscriminator: "A04C", 303 303 }) 304 304 if err == nil || !strings.Contains(err.Error(), "at least one ASIN is required") {
+4 -4
internal/wholefoods/staples.go
··· 18 18 var defaultStaplesSignature = lo.Must(json.Marshal(defaultStaples())) 19 19 20 20 type CategoryClient interface { 21 - Category(ctx context.Context, queryterm, store string) ([]Product, error) 21 + Category(ctx context.Context, queryterm, store string) ([]product, error) 22 22 } 23 23 24 24 type identityProvider struct{} ··· 63 63 return nil, err 64 64 } 65 65 66 - ingredients := lo.Map(resp, func(product Product, _ int) kroger.Ingredient { 66 + ingredients := lo.Map(resp, func(product product, _ int) kroger.Ingredient { 67 67 return productToIngredient(product) 68 68 }) 69 69 slog.InfoContext(ctx, "Found ingredients for category", "count", len(ingredients), "category", category, "location", locationID) ··· 87 87 return nil, err 88 88 } 89 89 90 - ingredients := lo.Map(resp, func(product Product, _ int) kroger.Ingredient { 90 + ingredients := lo.Map(resp, func(product product, _ int) kroger.Ingredient { 91 91 return productToIngredient(product) 92 92 }) 93 93 if skip >= len(ingredients) { ··· 114 114 // red-wine, white-wine, sparkling 115 115 } 116 116 117 - func productToIngredient(product Product) kroger.Ingredient { 117 + func productToIngredient(product product) kroger.Ingredient { 118 118 var regularPrice *float32 119 119 if product.RegularPrice > 0 { 120 120 price := float32(product.RegularPrice)
+4 -4
internal/wholefoods/staples_test.go
··· 24 24 } 25 25 26 26 type stubCategoryClient struct { 27 - results map[string][]Product 27 + results map[string][]product 28 28 errs map[string]error 29 29 mu sync.Mutex 30 30 calls []string 31 31 } 32 32 33 - func (s *stubCategoryClient) Category(_ context.Context, queryterm, store string) ([]Product, error) { 33 + func (s *stubCategoryClient) Category(_ context.Context, queryterm, store string) ([]product, error) { 34 34 s.mu.Lock() 35 35 s.calls = append(s.calls, store+":"+queryterm) 36 36 s.mu.Unlock() ··· 54 54 55 55 func TestStaplesProvider_MapsProductsToIngredients(t *testing.T) { 56 56 client := &stubCategoryClient{ 57 - results: map[string][]Product{ 57 + results: map[string][]product{ 58 58 "fresh-vegetables": { 59 59 { 60 60 Name: "Organic Asparagus", ··· 113 113 114 114 func TestStaplesProvider_GetIngredients_UsesSearchTerm(t *testing.T) { 115 115 client := &stubCategoryClient{ 116 - results: map[string][]Product{ 116 + results: map[string][]product{ 117 117 "pinot noir": { 118 118 {Name: "Pinot Noir", Slug: "pinot-noir", Brand: "WFM", Store: 10216}, 119 119 {Name: "Rose", Slug: "rose", Brand: "WFM", Store: 10216},