Add Publix API client for savings and store location endpoints
HTTP client that wraps the Publix services API with two endpoints: - /api/v4/savings — fetches weekly ad deals for a given store number - /api/v1/storelocation — finds nearby stores by ZIP code Includes request types (SavingsResponse, SavingItem, StoreResponse, Store) mapping directly to the Publix JSON schema. The client sends a PublixStore header for store-scoped requests and uses a 15-second timeout. Tests use httptest servers to verify header propagation, JSON decoding, and error handling for non-200 responses.
This commit is contained in:
122
internal/api/client.go
Normal file
122
internal/api/client.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
143
internal/api/client_test.go
Normal file
143
internal/api/client_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
internal/api/types.go
Normal file
41
internal/api/types.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user