diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..27f6968 --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,122 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + defaultSavingsAPI = "https://services.publix.com/api/v4/savings" + defaultStoreAPI = "https://services.publix.com/api/v1/storelocation" + userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" +) + +// Client is an HTTP client for the Publix API. +type Client struct { + httpClient *http.Client + savingsURL string + storeURL string +} + +// NewClient creates a new Publix API client. +func NewClient() *Client { + return &Client{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + savingsURL: defaultSavingsAPI, + storeURL: defaultStoreAPI, + } +} + +// NewClientWithBaseURLs creates a client with custom base URLs (for testing). +func NewClientWithBaseURLs(savingsURL, storeURL string) *Client { + return &Client{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + savingsURL: savingsURL, + storeURL: storeURL, + } +} + +func (c *Client) get(ctx context.Context, reqURL, storeNumber string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + if storeNumber != "" { + req.Header.Set("PublixStore", storeNumber) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, reqURL) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + return body, nil +} + +// FetchStores finds Publix stores near the given zip code. +func (c *Client) FetchStores(ctx context.Context, zipCode string, count int) ([]Store, error) { + params := url.Values{ + "types": {"R,G,H,N,S"}, + "option": {""}, + "count": {fmt.Sprintf("%d", count)}, + "includeOpenAndCloseDates": {"true"}, + "zipCode": {zipCode}, + } + + body, err := c.get(ctx, c.storeURL+"?"+params.Encode(), "") + if err != nil { + return nil, fmt.Errorf("fetching stores: %w", err) + } + + var resp StoreResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decoding stores: %w", err) + } + return resp.Stores, nil +} + +// FetchSavings fetches all weekly ad savings for the given store. +func (c *Client) FetchSavings(ctx context.Context, storeNumber string) (*SavingsResponse, error) { + params := url.Values{ + "page": {"1"}, + "pageSize": {"0"}, + "includePersonalizedDeals": {"false"}, + "languageID": {"1"}, + "isWeb": {"true"}, + "getSavingType": {"WeeklyAd"}, + } + + body, err := c.get(ctx, c.savingsURL+"?"+params.Encode(), storeNumber) + if err != nil { + return nil, fmt.Errorf("fetching savings: %w", err) + } + + var resp SavingsResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decoding savings: %w", err) + } + return &resp, nil +} + +// StoreNumber returns the numeric portion of a store key (strips leading zeros). +func StoreNumber(key string) string { + return strings.TrimLeft(key, "0") +} diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..bde9c3c --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,143 @@ +package api_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tayloree/publix-deals/internal/api" +) + +func ptr(s string) *string { return &s } + +func newTestSavingsServer(t *testing.T, storeNumber string, items []api.SavingItem) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the PublixStore header is sent + got := r.Header.Get("PublixStore") + if storeNumber != "" { + assert.Equal(t, storeNumber, got, "PublixStore header mismatch") + } + + resp := api.SavingsResponse{ + Savings: items, + LanguageID: 1, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) +} + +func newTestStoreServer(t *testing.T, stores []api.Store) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.NotEmpty(t, r.URL.Query().Get("zipCode"), "zipCode param required") + + resp := api.StoreResponse{Stores: stores} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) +} + +func TestFetchSavings(t *testing.T) { + items := []api.SavingItem{ + { + ID: "test-1", + Title: ptr("Chicken Breasts"), + Savings: ptr("$3.99 lb"), + Department: ptr("Meat"), + Categories: []string{"meat"}, + StartFormatted: "2/18", + EndFormatted: "2/24", + }, + { + ID: "test-2", + Title: ptr("Nutella"), + Savings: ptr("Buy 1 Get 1 FREE"), + Categories: []string{"bogo", "grocery"}, + }, + } + + srv := newTestSavingsServer(t, "1425", items) + defer srv.Close() + + client := api.NewClientWithBaseURLs(srv.URL, "") + resp, err := client.FetchSavings(context.Background(), "1425") + + require.NoError(t, err) + assert.Len(t, resp.Savings, 2) + assert.Equal(t, "Chicken Breasts", *resp.Savings[0].Title) + assert.Equal(t, "Buy 1 Get 1 FREE", *resp.Savings[1].Savings) +} + +func TestFetchSavings_EmptyStore(t *testing.T) { + srv := newTestSavingsServer(t, "", nil) + defer srv.Close() + + client := api.NewClientWithBaseURLs(srv.URL, "") + resp, err := client.FetchSavings(context.Background(), "") + + require.NoError(t, err) + assert.Empty(t, resp.Savings) +} + +func TestFetchSavings_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + client := api.NewClientWithBaseURLs(srv.URL, "") + _, err := client.FetchSavings(context.Background(), "1425") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestFetchStores(t *testing.T) { + stores := []api.Store{ + {Key: "01425", Name: "Peachers Mill", City: "Clarksville", State: "TN", Zip: "37042", Distance: "5"}, + {Key: "00100", Name: "Downtown", City: "Nashville", State: "TN", Zip: "37201", Distance: "15"}, + } + + srv := newTestStoreServer(t, stores) + defer srv.Close() + + client := api.NewClientWithBaseURLs("", srv.URL) + result, err := client.FetchStores(context.Background(), "37042", 5) + + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, "Peachers Mill", result[0].Name) + assert.Equal(t, "01425", result[0].Key) +} + +func TestFetchStores_NoResults(t *testing.T) { + srv := newTestStoreServer(t, nil) + defer srv.Close() + + client := api.NewClientWithBaseURLs("", srv.URL) + result, err := client.FetchStores(context.Background(), "00000", 5) + + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestStoreNumber(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"01425", "1425"}, + {"00100", "100"}, + {"1425", "1425"}, + {"0", ""}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, api.StoreNumber(tt.input), "StoreNumber(%q)", tt.input) + } +} diff --git a/internal/api/types.go b/internal/api/types.go new file mode 100644 index 0000000..cbfa001 --- /dev/null +++ b/internal/api/types.go @@ -0,0 +1,41 @@ +package api + +// SavingsResponse is the top-level response from the Publix savings API. +type SavingsResponse struct { + Savings []SavingItem `json:"Savings"` + WeeklyAdLatestUpdatedDateTime string `json:"WeeklyAdLatestUpdatedDateTime"` + IsPersonalizationEnabled bool `json:"IsPersonalizationEnabled"` + LanguageID int `json:"LanguageId"` +} + +// SavingItem represents a single deal/saving from the weekly ad. +type SavingItem struct { + ID string `json:"id"` + Title *string `json:"title"` + Description *string `json:"description"` + Savings *string `json:"savings"` + Department *string `json:"department"` + Brand *string `json:"brand"` + Categories []string `json:"categories"` + AdditionalDealInfo *string `json:"additionalDealInfo"` + ImageURL *string `json:"imageUrl"` + StartFormatted string `json:"wa_startDateFormatted"` + EndFormatted string `json:"wa_endDateFormatted"` +} + +// StoreResponse is the top-level response from the store locator API. +type StoreResponse struct { + Stores []Store `json:"Stores"` +} + +// Store represents a Publix store location. +type Store struct { + Key string `json:"KEY"` + Name string `json:"NAME"` + Addr string `json:"ADDR"` + City string `json:"CITY"` + State string `json:"STATE"` + Zip string `json:"ZIP"` + Distance string `json:"DISTANCE"` + Phone string `json:"PHONE"` +}