ai cooking
0
fork

Configure Feed

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

pagination (#340)

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

authored by

Paul Miller
paul miller
and committed by
GitHub
3b1fe096 3ffe1777

+138 -37
+2
deploy/deploy.yaml
··· 38 38 value: "xDzACMu074AcEI_x3dhC" 39 39 - name: ADMIN_EMAILS 40 40 value: "paul.miller@gmail.com" 41 + - name: WHOLEFOODS_ENABLE 42 + value: "1" 41 43 - name: APPLICATIONINSIGHTS_CONNECTION_STRING 42 44 value: "InstrumentationKey=a532fcc7-5098-4f44-8dde-ff2f32d6a59b;IngestionEndpoint=https://westus3-1.in.applicationinsights.azure.com/;LiveEndpoint=https://westus3.livediagnostics.monitor.azure.com/;ApplicationId=fdc94780-6135-4a29-980e-ab114a402e58" 43 45 volumeMounts:
+23 -12
internal/wholefoods/client.go
··· 14 14 15 15 const ( 16 16 // DefaultBaseURL is the public Whole Foods Market website origin. 17 - DefaultBaseURL = "https://www.wholefoodsmarket.com" 17 + DefaultBaseURL = "https://www.wholefoodsmarket.com" 18 + defaultCategoryLimit = 60 18 19 ) 19 20 20 21 // Client calls the public Whole Foods category products endpoint. ··· 153 154 } 154 155 } 155 156 156 - // Category fetches a category page payload like /api/products/category/beef?store=10216. 157 - // TODO add pagination 158 - // /api/products/category/fish?store=10216&limit=60&offset=0 159 - func (c *Client) Category(ctx context.Context, queryterm, store string) (*CategoryResponse, error) { 157 + // Category fetches category products and follows limit/offset pagination until 158 + // the API returns fewer items than the requested page size. 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 - params := endpoint.Query() 176 - params.Set("store", store) 177 - endpoint.RawQuery = params.Encode() 178 - var decoded CategoryResponse 179 - if err := c.getJSON(ctx, endpoint.String(), &decoded); err != nil { 180 - return nil, err 175 + var combined []Product 176 + for offset := 0; ; offset += defaultCategoryLimit { 177 + params := endpoint.Query() 178 + params.Set("store", store) 179 + params.Set("limit", fmt.Sprintf("%d", defaultCategoryLimit)) 180 + params.Set("offset", fmt.Sprintf("%d", offset)) 181 + endpoint.RawQuery = params.Encode() 182 + 183 + var page CategoryResponse 184 + if err := c.getJSON(ctx, endpoint.String(), &page); err != nil { 185 + return nil, err 186 + } 187 + 188 + combined = append(combined, page.Results...) 189 + 190 + if len(page.Results) < defaultCategoryLimit { 191 + return combined, nil 192 + } 181 193 } 182 - return &decoded, nil 183 194 } 184 195 185 196 // StoreSummary fetches a store summary payload like /api/stores/10216/summary.
+85 -16
internal/wholefoods/client_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 6 + "fmt" 5 7 "net/http" 6 8 "net/http/httptest" 7 9 "os" 10 + "slices" 11 + "strconv" 8 12 "strings" 9 13 "testing" 10 14 ) ··· 38 42 if got := capturedReq.URL.Query().Get("store"); got != "10216" { 39 43 t.Fatalf("unexpected store query value: %q", got) 40 44 } 45 + if got := capturedReq.URL.Query().Get("limit"); got != "60" { 46 + t.Fatalf("unexpected limit query value: %q", got) 47 + } 48 + if got := capturedReq.URL.Query().Get("offset"); got != "0" { 49 + t.Fatalf("unexpected offset query value: %q", got) 50 + } 41 51 if got := capturedReq.Header.Get("Accept"); got != "application/json" { 42 52 t.Fatalf("unexpected Accept header: %q", got) 43 53 } 44 54 45 - if len(resp.Facets) != 3 { 46 - t.Fatalf("unexpected facets count: %d", len(resp.Facets)) 47 - } 48 - if len(resp.Breadcrumb) != 2 { 49 - t.Fatalf("unexpected breadcrumb count: %d", len(resp.Breadcrumb)) 50 - } 51 - if got := resp.Breadcrumb[1].Slug; got != "beef" { 52 - t.Fatalf("unexpected breadcrumb slug: %q", got) 55 + if len(resp) != 18 { 56 + t.Fatalf("unexpected results count: %d", len(resp)) 53 57 } 54 - if got := resp.Meta.Total.Value; got != 18 { 55 - t.Fatalf("unexpected total value: %d", got) 56 - } 57 - if len(resp.Results) != 18 { 58 - t.Fatalf("unexpected results count: %d", len(resp.Results)) 59 - } 60 - if got := resp.Results[0].Name; got != "Organic Ground Beef 93% Lean/7% Fat, 16 OZ" { 58 + if got := resp[0].Name; got != "Organic Ground Beef 93% Lean/7% Fat, 16 OZ" { 61 59 t.Fatalf("unexpected first result name: %q", got) 62 60 } 63 - if got := resp.Results[14].SalePrice; got != 12.44 { 61 + if got := resp[14].SalePrice; got != 12.44 { 64 62 t.Fatalf("unexpected sale price: %v", got) 65 63 } 66 64 } ··· 97 95 } 98 96 if !strings.Contains(err.Error(), "status 502") { 99 97 t.Fatalf("unexpected error: %v", err) 98 + } 99 + } 100 + 101 + func TestCategory_PaginatesUntilShortPage(t *testing.T) { 102 + t.Parallel() 103 + 104 + var offsets []int 105 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 106 + offset, err := strconv.Atoi(r.URL.Query().Get("offset")) 107 + if err != nil { 108 + t.Fatalf("parse offset: %v", err) 109 + } 110 + offsets = append(offsets, offset) 111 + 112 + limit, err := strconv.Atoi(r.URL.Query().Get("limit")) 113 + if err != nil { 114 + t.Fatalf("parse limit: %v", err) 115 + } 116 + if limit != defaultCategoryLimit { 117 + t.Fatalf("unexpected limit: %d", limit) 118 + } 119 + 120 + pageSize := limit 121 + if offset >= limit*2 { 122 + pageSize = 5 123 + } 124 + 125 + results := make([]Product, 0, pageSize) 126 + for i := 0; i < pageSize; i++ { 127 + n := offset + i 128 + results = append(results, Product{ 129 + Name: fmt.Sprintf("Product %d", n), 130 + Slug: fmt.Sprintf("product-%d", n), 131 + Brand: "Whole Foods Market", 132 + Store: 10216, 133 + }) 134 + } 135 + 136 + resp := CategoryResponse{ 137 + Breadcrumb: []Breadcrumb{{Label: "Meat", Slug: "meat"}, {Label: "Fish", Slug: "fish"}}, 138 + Meta: Meta{ 139 + Total: Total{Value: limit*2 + 5, Relation: "eq"}, 140 + }, 141 + Results: results, 142 + } 143 + 144 + w.Header().Set("Content-Type", "application/json") 145 + if err := json.NewEncoder(w).Encode(resp); err != nil { 146 + t.Fatalf("encode response: %v", err) 147 + } 148 + })) 149 + t.Cleanup(server.Close) 150 + 151 + client := NewClientWithBaseURL(server.URL, server.Client()) 152 + 153 + resp, err := client.Category(context.Background(), "fish", "10216") 154 + if err != nil { 155 + t.Fatalf("Category returned error: %v", err) 156 + } 157 + 158 + if got, want := offsets, []int{0, defaultCategoryLimit, defaultCategoryLimit * 2}; !slices.Equal(got, want) { 159 + t.Fatalf("unexpected offsets: got %v want %v", got, want) 160 + } 161 + if got, want := len(resp), defaultCategoryLimit*2+5; got != want { 162 + t.Fatalf("unexpected result count: got %d want %d", got, want) 163 + } 164 + if got := resp[0].Slug; got != "product-0" { 165 + t.Fatalf("unexpected first slug: %q", got) 166 + } 167 + if got := resp[len(resp)-1].Slug; got != "product-124" { 168 + t.Fatalf("unexpected last slug: %q", got) 100 169 } 101 170 } 102 171
+3 -3
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) (*CategoryResponse, error) 21 + Category(ctx context.Context, queryterm, store string) ([]Product, error) 22 22 } 23 23 24 24 type identityProvider struct{} ··· 62 62 return nil, err 63 63 } 64 64 65 - ingredients := lo.Map(resp.Results, func(product Product, _ int) kroger.Ingredient { 65 + ingredients := lo.Map(resp, func(product Product, _ int) kroger.Ingredient { 66 66 return productToIngredient(product) 67 67 }) 68 68 slog.InfoContext(ctx, "Found ingredients for category", "count", len(ingredients), "category", category, "location", locationID) ··· 86 86 return nil, err 87 87 } 88 88 89 - ingredients := lo.Map(resp.Results, func(product Product, _ int) kroger.Ingredient { 89 + ingredients := lo.Map(resp, func(product Product, _ int) kroger.Ingredient { 90 90 return productToIngredient(product) 91 91 }) 92 92 if skip >= len(ingredients) {
+25 -6
internal/wholefoods/staples_test.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "slices" 7 + "sync" 7 8 "testing" 8 9 ) 9 10 ··· 25 26 type stubCategoryClient struct { 26 27 results map[string][]Product 27 28 errs map[string]error 29 + mu sync.Mutex 28 30 calls []string 29 31 } 30 32 31 - func (s *stubCategoryClient) Category(_ context.Context, queryterm, store string) (*CategoryResponse, error) { 33 + func (s *stubCategoryClient) Category(_ context.Context, queryterm, store string) ([]Product, error) { 34 + s.mu.Lock() 32 35 s.calls = append(s.calls, store+":"+queryterm) 36 + s.mu.Unlock() 33 37 if err := s.errs[queryterm]; err != nil { 34 38 return nil, err 35 39 } 36 - return &CategoryResponse{Results: slices.Clone(s.results[queryterm])}, nil 40 + return slices.Clone(s.results[queryterm]), nil 41 + } 42 + 43 + func (s *stubCategoryClient) callCount() int { 44 + s.mu.Lock() 45 + defer s.mu.Unlock() 46 + return len(s.calls) 47 + } 48 + 49 + func (s *stubCategoryClient) hasCall(want string) bool { 50 + s.mu.Lock() 51 + defer s.mu.Unlock() 52 + return slices.Contains(s.calls, want) 37 53 } 38 54 39 55 func TestStaplesProvider_MapsProductsToIngredients(t *testing.T) { ··· 78 94 if ingredient.PriceSale == nil || *ingredient.PriceSale != float32(4.49) { 79 95 t.Fatalf("unexpected sale price: %+v", ingredient.PriceSale) 80 96 } 81 - if len(client.calls) != len(defaultStaples()) { 82 - t.Fatalf("expected %d category calls, got %d", len(defaultStaples()), len(client.calls)) 97 + if got, want := client.callCount(), len(defaultStaples()); got != want { 98 + t.Fatalf("expected %d category calls, got %d", want, got) 83 99 } 84 100 } 85 101 ··· 116 132 if got[0].Description == nil || *got[0].Description != "Rose" { 117 133 t.Fatalf("unexpected ingredient description: %+v", got[0].Description) 118 134 } 119 - if len(client.calls) != 1 || client.calls[0] != "10216:pinot noir" { 120 - t.Fatalf("unexpected category calls: %v", client.calls) 135 + if got := client.callCount(); got != 1 { 136 + t.Fatalf("expected 1 category call, got %d", got) 137 + } 138 + if !client.hasCall("10216:pinot noir") { 139 + t.Fatalf("missing expected category call") 121 140 } 122 141 }