ai cooking
0
fork

Configure Feed

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

Walmarttake2 (#275)

* okay we can get taxonomy

* zip still broken

* python also has same problem

* okay we'rve got parsed stores

* okay we've got objects

* walmart start

* get walmart tests working

* clear out python junk

* linter is excited

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
2b74cf18 8ff13499

+743 -5
+68
cmd/walmartstores/main.go
··· 1 + package main 2 + 3 + import ( 4 + "careme/internal/config" 5 + "careme/internal/walmart" 6 + "context" 7 + "flag" 8 + "fmt" 9 + "log/slog" 10 + "os" 11 + "time" 12 + ) 13 + 14 + const defaultConsumerID = "52dae855-d02f-488b-b179-1df6700d7dcf" 15 + 16 + func main() { 17 + var ( 18 + zip = flag.String("zip", "98005", "ZIP code to query") 19 + keyVersion = flag.String("key-version", envOrDefault("WALMART_KEY_VERSION", "1"), "Walmart key version header") 20 + baseURL = flag.String("base-url", walmart.DefaultBaseURL, "Walmart affiliates API base URL") 21 + privateKey = flag.String("private-key", "", "path to Walmart private key") 22 + consumerID = flag.String("consumer-id", envOrDefault("WALMART_CONSUMER_ID", defaultConsumerID), "Walmart consumer ID") 23 + ) 24 + flag.Parse() 25 + 26 + client, err := walmart.NewClient(config.WalmartConfig{ 27 + ConsumerID: *consumerID, 28 + KeyVersion: *keyVersion, 29 + PrivateKeyPath: *privateKey, 30 + BaseURL: *baseURL, 31 + }) 32 + if err != nil { 33 + exitErr(fmt.Errorf("create Walmart client: %w", err)) 34 + } 35 + 36 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 37 + defer cancel() 38 + 39 + //slog.Info("taxonomy request") 40 + //taxonomy, err := client.Taxonomy(ctx) 41 + //if err != nil { 42 + // exitErr(fmt.Errorf("request taxonomy: %w", err)) 43 + // return 44 + //} 45 + //fmt.Printf("taxonomy: %s\n", string(taxonomy)) 46 + 47 + slog.Info("querying Walmart stores", "zip", *zip) 48 + stores, err := client.SearchStoresByZIP(ctx, *zip) 49 + if err != nil { 50 + exitErr(err) 51 + } 52 + 53 + for _, store := range stores { 54 + fmt.Printf("Store: %s: %s\n", store.Name, store.StreetAddress) 55 + } 56 + } 57 + 58 + func envOrDefault(key, fallback string) string { 59 + if v := os.Getenv(key); v != "" { 60 + return v 61 + } 62 + return fallback 63 + } 64 + 65 + func exitErr(err error) { 66 + fmt.Fprintf(os.Stderr, "error: %v\n", err) 67 + os.Exit(1) 68 + }
+22 -5
internal/config/config.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "net/http" 5 6 "os" 6 7 "strings" 7 8 ) 8 9 9 10 type Config struct { 10 - AI AIConfig `json:"ai"` 11 - Kroger KrogerConfig `json:"kroger"` 12 - Mocks MockConfig `json:"mocks"` 13 - Clerk ClerkConfig `json:"clerk"` 14 - Admin AdminConfig `json:"admin"` 11 + AI AIConfig `json:"ai"` 12 + Kroger KrogerConfig `json:"kroger"` 13 + Walmart WalmartConfig `json:"walmart"` 14 + Mocks MockConfig `json:"mocks"` 15 + Clerk ClerkConfig `json:"clerk"` 16 + Admin AdminConfig `json:"admin"` 15 17 } 16 18 17 19 type AIConfig struct { ··· 39 41 Emails []string `json:"emails"` 40 42 } 41 43 44 + // Config defines the required Walmart affiliate credentials and client options. 45 + type WalmartConfig struct { 46 + ConsumerID string 47 + KeyVersion string 48 + PrivateKeyPath string 49 + BaseURL string 50 + HTTPClient *http.Client 51 + } 52 + 42 53 func (c *ClerkConfig) IsEnabled() bool { 43 54 return c.SecretKey != "" && c.Domain != "" && c.PublishableKey != "" 44 55 } ··· 82 93 }, 83 94 Admin: AdminConfig{ 84 95 Emails: parseAdminEmails(os.Getenv("ADMIN_EMAILS")), 96 + }, 97 + Walmart: WalmartConfig{ 98 + ConsumerID: os.Getenv("WALMART_CONSUMER_ID"), 99 + KeyVersion: os.Getenv("WALMART_KEY_VERSION"), 100 + PrivateKeyPath: os.Getenv("WALMART_PRIVATE_KEY_PATH"), 101 + BaseURL: os.Getenv("WALMART_BASE_URL"), 85 102 }, 86 103 } 87 104 if strings.HasSuffix(config.Clerk.Domain, "careme.cooking") {
+314
internal/walmart/client.go
··· 1 + package walmart 2 + 3 + import ( 4 + "bytes" 5 + "careme/internal/config" 6 + "context" 7 + "crypto" 8 + "crypto/rand" 9 + "crypto/rsa" 10 + "crypto/sha256" 11 + "crypto/x509" 12 + "encoding/base64" 13 + "encoding/json" 14 + "encoding/pem" 15 + "errors" 16 + "fmt" 17 + "io" 18 + "log/slog" 19 + "net/http" 20 + "net/url" 21 + "os" 22 + "sort" 23 + "strings" 24 + "time" 25 + 26 + "golang.org/x/crypto/ssh" 27 + ) 28 + 29 + const ( 30 + // DefaultBaseURL is the Walmart Affiliates API base URL. 31 + DefaultBaseURL = "https://developer.api.walmart.com/api-proxy/service/affil/product/v2" 32 + ) 33 + 34 + // Client calls Walmart Affiliates APIs with signed headers. 35 + type Client struct { 36 + consumerID string 37 + keyVersion string 38 + privateKey *rsa.PrivateKey 39 + baseURL string 40 + httpClient *http.Client 41 + } 42 + 43 + // StoresQuery controls store locator query parameters. 44 + // Current implementation intentionally supports ZIP search only. 45 + type StoresQuery struct { 46 + Lat string 47 + Lon string 48 + Zip string 49 + City string 50 + } 51 + 52 + // NewClient creates a Walmart affiliates client. 53 + func NewClient(cfg config.WalmartConfig) (*Client, error) { 54 + if strings.TrimSpace(cfg.ConsumerID) == "" { 55 + return nil, errors.New("consumer ID is required") 56 + } 57 + if strings.TrimSpace(cfg.KeyVersion) == "" { 58 + cfg.KeyVersion = "1" 59 + } 60 + if strings.TrimSpace(cfg.PrivateKeyPath) == "" { 61 + return nil, errors.New("private key path is required") 62 + } 63 + 64 + privateKey, err := LoadRSAPrivateKey(cfg.PrivateKeyPath) 65 + if err != nil { 66 + return nil, fmt.Errorf("load private key: %w", err) 67 + } 68 + 69 + baseURL := strings.TrimSpace(cfg.BaseURL) 70 + if baseURL == "" { 71 + baseURL = DefaultBaseURL 72 + } 73 + 74 + httpClient := cfg.HTTPClient 75 + if httpClient == nil { 76 + httpClient = &http.Client{Timeout: 20 * time.Second} 77 + } 78 + 79 + return &Client{ 80 + consumerID: cfg.ConsumerID, 81 + keyVersion: cfg.KeyVersion, 82 + privateKey: privateKey, 83 + baseURL: strings.TrimRight(baseURL, "/"), 84 + httpClient: httpClient, 85 + }, nil 86 + } 87 + 88 + // docshttps://walmart.io/docs/affiliates/v1/taxonomy 89 + // example https://developer.api.walmart.com/api-proxy/service/affil/product/v2/taxonomy 90 + func (c *Client) Taxonomy(ctx context.Context) (json.RawMessage, error) { 91 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/taxonomy", nil) 92 + if err != nil { 93 + return nil, fmt.Errorf("build taxonomy request: %w", err) 94 + } 95 + req.Header.Set("Accept", "application/json") 96 + 97 + if err := c.applyAuthHeaders(req); err != nil { 98 + return nil, fmt.Errorf("apply walmart auth headers: %w", err) 99 + } 100 + 101 + slog.InfoContext(ctx, "searching Walmart taxonomy", "url", req.URL.String()) 102 + 103 + resp, err := c.httpClient.Do(req) 104 + if err != nil { 105 + return nil, fmt.Errorf("request taxonomy: %w", err) 106 + } 107 + defer func() { 108 + _ = resp.Body.Close() 109 + }() 110 + 111 + body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) 112 + if err != nil { 113 + return nil, fmt.Errorf("read taxonomy response: %w", err) 114 + } 115 + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { 116 + return nil, fmt.Errorf("taxonomy request failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) 117 + } 118 + if !json.Valid(body) { 119 + return nil, fmt.Errorf("taxonomy request succeeded but response was not valid JSON: %s", strings.TrimSpace(string(body))) 120 + } 121 + 122 + return body, nil 123 + } 124 + 125 + // docs https://walmart.io/docs/affiliates/v1/stores 126 + // example https://developer.api.walmart.com/api-proxy/service/affil/v2/stores?zip=98007 127 + // example https://developer.api.walmart.com/api-proxy/service/affil/v2/stores?zip=77063 128 + // SearchStoresByZIPData returns typed store locations for the provided ZIP code. 129 + func (c *Client) SearchStoresByZIP(ctx context.Context, zip string) ([]Store, error) { 130 + zip = strings.TrimSpace(zip) 131 + if zip == "" { 132 + return nil, errors.New("zip code is required") 133 + } 134 + 135 + // Match Walmart ZIP sample path: /api-proxy/service/affil/v2/stores?zip=... 136 + params := url.Values{} 137 + params.Set("zip", zip) 138 + raw, err := c.searchStoresWithParams(ctx, params) 139 + if err != nil { 140 + return nil, err 141 + } 142 + stores, err := ParseStores(raw) 143 + if err != nil { 144 + return nil, fmt.Errorf("parse stores response: %w", err) 145 + } 146 + return stores, nil 147 + } 148 + 149 + func (c *Client) searchStoresWithParams(ctx context.Context, params url.Values) (json.RawMessage, error) { 150 + storesURL, err := url.Parse(c.baseURL + "/stores") 151 + if err != nil { 152 + return nil, fmt.Errorf("parse stores URL: %w", err) 153 + } 154 + storesURL.RawQuery = params.Encode() 155 + 156 + slog.InfoContext(ctx, "searching Walmart stores", "url", storesURL.String()) 157 + 158 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, storesURL.String(), nil) 159 + if err != nil { 160 + return nil, fmt.Errorf("build stores request: %w", err) 161 + } 162 + req.Header.Set("Accept", "application/json") 163 + 164 + if err := c.applyAuthHeaders(req); err != nil { 165 + return nil, fmt.Errorf("apply walmart auth headers: %w", err) 166 + } 167 + 168 + resp, err := c.httpClient.Do(req) 169 + if err != nil { 170 + return nil, fmt.Errorf("request stores: %w", err) 171 + } 172 + defer func() { 173 + _ = resp.Body.Close() 174 + }() 175 + 176 + var buf bytes.Buffer 177 + _, err = io.Copy(&buf, resp.Body) // ensure body is fully read for connection reuse 178 + if err != nil { 179 + return nil, fmt.Errorf("read stores response: %w", err) 180 + } 181 + 182 + slog.InfoContext(ctx, "received Walmart stores response", "status", resp.StatusCode) 183 + 184 + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { 185 + return nil, fmt.Errorf("stores request failed: status %d", resp.StatusCode) //, strings.TrimSpace(string(body))) 186 + } 187 + 188 + return buf.Bytes(), nil 189 + } 190 + 191 + func (c *Client) applyAuthHeaders(req *http.Request) error { 192 + timestamp := fmt.Sprintf("%d", time.Now().UnixMilli()) 193 + signature, err := buildSignature(c.privateKey, c.consumerID, timestamp, c.keyVersion) 194 + if err != nil { 195 + return err 196 + } 197 + 198 + req.Header.Set("WM_CONSUMER.ID", c.consumerID) 199 + req.Header.Set("WM_CONSUMER.INTIMESTAMP", timestamp) 200 + req.Header.Set("WM_SEC.KEY_VERSION", c.keyVersion) 201 + req.Header.Set("WM_SEC.AUTH_SIGNATURE", signature) 202 + req.Header.Set("WM_QOS.CORRELATION_ID", randomCorrelationID()) 203 + return nil 204 + } 205 + 206 + func buildSignature(privateKey *rsa.PrivateKey, consumerID, timestamp, keyVersion string) (string, error) { 207 + _, payload := canonicalize(map[string]string{ 208 + "WM_CONSUMER.ID": consumerID, 209 + "WM_CONSUMER.INTIMESTAMP": timestamp, 210 + "WM_SEC.KEY_VERSION": keyVersion, 211 + }) 212 + sum := sha256.Sum256([]byte(payload)) 213 + 214 + sig, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, sum[:]) 215 + if err != nil { 216 + return "", fmt.Errorf("sign payload: %w", err) 217 + } 218 + 219 + return base64.StdEncoding.EncodeToString(sig), nil 220 + } 221 + 222 + func canonicalize(headersToSign map[string]string) (string, string) { 223 + keys := make([]string, 0, len(headersToSign)) 224 + for key := range headersToSign { 225 + keys = append(keys, key) 226 + } 227 + sort.Strings(keys) 228 + 229 + var ( 230 + parameterNames strings.Builder 231 + canonicalized strings.Builder 232 + ) 233 + for _, key := range keys { 234 + parameterNames.WriteString(strings.TrimSpace(key)) 235 + parameterNames.WriteString(";") 236 + canonicalized.WriteString(strings.TrimSpace(headersToSign[key])) 237 + canonicalized.WriteString("\n") 238 + } 239 + 240 + return parameterNames.String(), canonicalized.String() 241 + } 242 + 243 + func randomCorrelationID() string { 244 + var b [16]byte 245 + if _, err := rand.Read(b[:]); err != nil { 246 + return fmt.Sprintf("careme-%d", time.Now().UnixNano()) 247 + } 248 + return fmt.Sprintf("%x", b[:]) 249 + } 250 + 251 + // LoadRSAPrivateKey loads a PEM (PKCS1/PKCS8) or OpenSSH private key file. 252 + func LoadRSAPrivateKey(path string) (*rsa.PrivateKey, error) { 253 + content, err := os.ReadFile(path) 254 + if err != nil { 255 + return nil, fmt.Errorf("read key file: %w", err) 256 + } 257 + 258 + if k, err := parsePEMPrivateKey(content); err == nil { 259 + return k, nil 260 + } 261 + 262 + rawKey, err := ssh.ParseRawPrivateKey(content) 263 + if err == nil { 264 + rsaKey, ok := rawKey.(*rsa.PrivateKey) 265 + if !ok { 266 + return nil, fmt.Errorf("private key is %T, expected *rsa.PrivateKey", rawKey) 267 + } 268 + return rsaKey, nil 269 + } 270 + 271 + // Some Walmart examples provide private key as raw base64 PKCS#8 content. 272 + rsaKey, err := parseBase64PKCS8PrivateKey(content) 273 + if err == nil { 274 + return rsaKey, nil 275 + } 276 + 277 + return nil, fmt.Errorf("parse private key: %w", err) 278 + } 279 + 280 + func parseBase64PKCS8PrivateKey(content []byte) (*rsa.PrivateKey, error) { 281 + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(content))) 282 + if err != nil { 283 + return nil, fmt.Errorf("decode base64 key: %w", err) 284 + } 285 + parsed, err := x509.ParsePKCS8PrivateKey(decoded) 286 + if err != nil { 287 + return nil, fmt.Errorf("parse PKCS#8 key: %w", err) 288 + } 289 + rsaKey, ok := parsed.(*rsa.PrivateKey) 290 + if !ok { 291 + return nil, fmt.Errorf("private key is %T, expected *rsa.PrivateKey", parsed) 292 + } 293 + return rsaKey, nil 294 + } 295 + 296 + func parsePEMPrivateKey(content []byte) (*rsa.PrivateKey, error) { 297 + block, _ := pem.Decode(content) 298 + if block == nil { 299 + return nil, errors.New("not PEM encoded") 300 + } 301 + 302 + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { 303 + return key, nil 304 + } 305 + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) 306 + if err != nil { 307 + return nil, fmt.Errorf("parse PEM key: %w", err) 308 + } 309 + key, ok := parsed.(*rsa.PrivateKey) 310 + if !ok { 311 + return nil, fmt.Errorf("private key is %T, expected *rsa.PrivateKey", parsed) 312 + } 313 + return key, nil 314 + }
+191
internal/walmart/client_test.go
··· 1 + package walmart 2 + 3 + import ( 4 + "bytes" 5 + "careme/internal/config" 6 + "context" 7 + "crypto" 8 + "crypto/rand" 9 + "crypto/rsa" 10 + "crypto/sha256" 11 + "crypto/x509" 12 + "encoding/base64" 13 + "encoding/pem" 14 + "fmt" 15 + "net/http" 16 + "net/http/httptest" 17 + "os" 18 + "path/filepath" 19 + "strings" 20 + "testing" 21 + ) 22 + 23 + func TestCanonicalize_SortsAndFormatsLikeJavaExample(t *testing.T) { 24 + t.Parallel() 25 + 26 + keys, values := canonicalize(map[string]string{ 27 + "WM_SEC.KEY_VERSION": "1 ", 28 + "WM_CONSUMER.INTIMESTAMP": " 12345", 29 + "WM_CONSUMER.ID": " abc ", 30 + }) 31 + 32 + if keys != "WM_CONSUMER.ID;WM_CONSUMER.INTIMESTAMP;WM_SEC.KEY_VERSION;" { 33 + t.Fatalf("unexpected key order: %q", keys) 34 + } 35 + if values != "abc\n12345\n1\n" { 36 + t.Fatalf("unexpected canonicalized values: %q", values) 37 + } 38 + } 39 + 40 + func TestSearchStoresByZIP_SetsHeadersAndQuery(t *testing.T) { 41 + t.Parallel() 42 + 43 + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 44 + if err != nil { 45 + t.Fatalf("generate RSA key: %v", err) 46 + } 47 + 48 + var capturedReq *http.Request 49 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 50 + capturedReq = r 51 + _, _ = w.Write([]byte(`{"results":[{"no":1,"name":"Store 1"}]}`)) 52 + })) 53 + t.Cleanup(server.Close) 54 + 55 + keyPath := writePKCS1Key(t, privateKey) 56 + client, err := NewClient(config.WalmartConfig{ 57 + ConsumerID: "consumer-id-123", 58 + KeyVersion: "1", 59 + PrivateKeyPath: keyPath, 60 + BaseURL: server.URL, 61 + HTTPClient: server.Client(), 62 + }) 63 + if err != nil { 64 + t.Fatalf("new client: %v", err) 65 + } 66 + 67 + stores, err := client.SearchStoresByZIP(context.Background(), "98005") 68 + if err != nil { 69 + t.Fatalf("search stores by zip: %v", err) 70 + } 71 + 72 + if stores == nil || len(stores) != 1 { 73 + t.Fatalf("unexpected stores result: %+v", stores) 74 + } 75 + if stores[0].Name != "Store 1" { 76 + t.Fatalf("unexpected store name: %q", stores[0].Name) 77 + } 78 + 79 + if capturedReq == nil { 80 + t.Fatal("expected request to be captured") 81 + } 82 + if capturedReq.URL.Path != "/stores" { 83 + t.Fatalf("unexpected path: %s", capturedReq.URL.Path) 84 + } 85 + if got := capturedReq.URL.Query().Get("zip"); got != "98005" { 86 + t.Fatalf("unexpected zip query value: %q", got) 87 + } 88 + 89 + consumerID := capturedReq.Header.Get("WM_CONSUMER.ID") 90 + if consumerID != "consumer-id-123" { 91 + t.Fatalf("unexpected WM_CONSUMER.ID: %q", consumerID) 92 + } 93 + timestamp := capturedReq.Header.Get("WM_CONSUMER.INTIMESTAMP") 94 + if timestamp == "" { 95 + t.Fatal("missing WM_CONSUMER.INTIMESTAMP") 96 + } 97 + keyVersion := capturedReq.Header.Get("WM_SEC.KEY_VERSION") 98 + if keyVersion != "1" { 99 + t.Fatalf("unexpected WM_SEC.KEY_VERSION: %q", keyVersion) 100 + } 101 + if capturedReq.Header.Get("WM_QOS.CORRELATION_ID") == "" { 102 + t.Fatal("missing WM_QOS.CORRELATION_ID") 103 + } 104 + 105 + rawSigHeader := capturedReq.Header.Get("WM_SEC.AUTH_SIGNATURE") 106 + if rawSigHeader == "" { 107 + t.Fatal("missing WM_SEC.AUTH_SIGNATURE") 108 + } 109 + signature, err := base64.StdEncoding.DecodeString(rawSigHeader) 110 + if err != nil { 111 + t.Fatalf("decode signature: %v", err) 112 + } 113 + 114 + payload := fmt.Sprintf("%s\n%s\n%s\n", consumerID, timestamp, keyVersion) 115 + digest := sha256.Sum256([]byte(payload)) 116 + if err := rsa.VerifyPKCS1v15(&privateKey.PublicKey, crypto.SHA256, digest[:], signature); err != nil { 117 + t.Fatalf("signature verification failed: %v", err) 118 + } 119 + } 120 + 121 + func TestLoadRSAPrivateKey_FromPKCS1PEM(t *testing.T) { 122 + t.Parallel() 123 + 124 + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 125 + if err != nil { 126 + t.Fatalf("generate RSA key: %v", err) 127 + } 128 + keyPath := writePKCS1Key(t, privateKey) 129 + 130 + loaded, err := LoadRSAPrivateKey(keyPath) 131 + if err != nil { 132 + t.Fatalf("load key: %v", err) 133 + } 134 + if loaded.N.Cmp(privateKey.N) != 0 { 135 + t.Fatal("loaded key does not match generated key") 136 + } 137 + } 138 + 139 + func TestSearchStoresByZIP_StatusError(t *testing.T) { 140 + t.Parallel() 141 + 142 + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 143 + if err != nil { 144 + t.Fatalf("generate RSA key: %v", err) 145 + } 146 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 147 + http.Error(w, "nope", http.StatusUnauthorized) 148 + })) 149 + t.Cleanup(server.Close) 150 + 151 + keyPath := writePKCS1Key(t, privateKey) 152 + client, err := NewClient(config.WalmartConfig{ 153 + ConsumerID: "consumer-id-123", 154 + KeyVersion: "1", 155 + PrivateKeyPath: keyPath, 156 + BaseURL: server.URL, 157 + HTTPClient: server.Client(), 158 + }) 159 + if err != nil { 160 + t.Fatalf("new client: %v", err) 161 + } 162 + 163 + _, err = client.SearchStoresByZIP(context.Background(), "98005") 164 + if err == nil { 165 + t.Fatal("expected error") 166 + } 167 + if !strings.Contains(err.Error(), "status 401") { 168 + t.Fatalf("unexpected error: %v", err) 169 + } 170 + } 171 + 172 + func writePKCS1Key(t *testing.T, key *rsa.PrivateKey) string { 173 + t.Helper() 174 + 175 + // Use PKCS8 encoding for portability with current Go versions. 176 + der, err := x509.MarshalPKCS8PrivateKey(key) 177 + if err != nil { 178 + t.Fatalf("marshal key: %v", err) 179 + } 180 + var buf bytes.Buffer 181 + if err := pem.Encode(&buf, &pem.Block{Type: "PRIVATE KEY", Bytes: der}); err != nil { 182 + t.Fatalf("encode key: %v", err) 183 + } 184 + 185 + tmpDir := t.TempDir() 186 + path := filepath.Join(tmpDir, "key.pem") 187 + if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil { 188 + t.Fatalf("write key file: %v", err) 189 + } 190 + return path 191 + }
+89
internal/walmart/store.go
··· 1 + package walmart 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + ) 7 + 8 + // Store represents a Walmart store location returned by the stores API. 9 + type Store struct { 10 + No int `json:"no"` 11 + Name string `json:"name"` 12 + Country string `json:"country"` 13 + Coordinates Coordinates `json:"coordinates"` 14 + StreetAddress string `json:"streetAddress"` 15 + City string `json:"city"` 16 + StateProvCode string `json:"stateProvCode"` 17 + Zip string `json:"zip"` 18 + PhoneNumber string `json:"phoneNumber"` 19 + SundayOpen bool `json:"sundayOpen"` 20 + Timezone string `json:"timezone"` 21 + } 22 + 23 + // Coordinates stores Walmart's [longitude, latitude] coordinate tuple. 24 + type Coordinates struct { 25 + Longitude float64 26 + Latitude float64 27 + } 28 + 29 + func (c *Coordinates) UnmarshalJSON(data []byte) error { 30 + var tuple []float64 31 + if err := json.Unmarshal(data, &tuple); err != nil { 32 + return fmt.Errorf("unmarshal coordinates: %w", err) 33 + } 34 + if len(tuple) != 2 { 35 + return fmt.Errorf("coordinates must contain [longitude, latitude], got %d values", len(tuple)) 36 + } 37 + 38 + c.Longitude = tuple[0] 39 + c.Latitude = tuple[1] 40 + return nil 41 + } 42 + 43 + func (c Coordinates) MarshalJSON() ([]byte, error) { 44 + return json.Marshal([]float64{c.Longitude, c.Latitude}) 45 + } 46 + 47 + // ParseStore unmarshals a single store JSON object. 48 + func ParseStore(data []byte) (Store, error) { 49 + var store Store 50 + if err := json.Unmarshal(data, &store); err != nil { 51 + return Store{}, fmt.Errorf("unmarshal store: %w", err) 52 + } 53 + return store, nil 54 + } 55 + 56 + // ParseStores unmarshals store payloads from array, wrapped, or single-object shapes. 57 + func ParseStores(data []byte) ([]Store, error) { 58 + var stores []Store 59 + if err := json.Unmarshal(data, &stores); err == nil { 60 + return stores, nil 61 + } 62 + 63 + var wrapped struct { 64 + Results json.RawMessage `json:"results"` 65 + Stores json.RawMessage `json:"stores"` 66 + } 67 + if err := json.Unmarshal(data, &wrapped); err == nil { 68 + if len(wrapped.Results) > 0 { 69 + var results []Store 70 + if err := json.Unmarshal(wrapped.Results, &results); err != nil { 71 + return nil, fmt.Errorf("unmarshal results stores: %w", err) 72 + } 73 + return results, nil 74 + } 75 + if len(wrapped.Stores) > 0 { 76 + var nestedStores []Store 77 + if err := json.Unmarshal(wrapped.Stores, &nestedStores); err != nil { 78 + return nil, fmt.Errorf("unmarshal stores field: %w", err) 79 + } 80 + return nestedStores, nil 81 + } 82 + } 83 + 84 + store, err := ParseStore(data) 85 + if err != nil { 86 + return nil, fmt.Errorf("unmarshal stores payload: %w", err) 87 + } 88 + return []Store{store}, nil 89 + }
+59
internal/walmart/store_test.go
··· 1 + package walmart 2 + 3 + import "testing" 4 + 5 + func TestParseStore_SampleJSON(t *testing.T) { 6 + t.Parallel() 7 + 8 + payload := []byte(`{"no":3098,"name":"Bellevue Neighborhood Market","country":"US","coordinates":[-122.139487,47.609036],"streetAddress":"15063 MAIN ST","city":"Bellevue","stateProvCode":"WA","zip":"98007","phoneNumber":"425-643-9054","sundayOpen":true,"timezone":"PST"}`) 9 + 10 + store, err := ParseStore(payload) 11 + if err != nil { 12 + t.Fatalf("parse store: %v", err) 13 + } 14 + 15 + if store.No != 3098 { 16 + t.Fatalf("unexpected store number: %d", store.No) 17 + } 18 + if store.Name != "Bellevue Neighborhood Market" { 19 + t.Fatalf("unexpected store name: %q", store.Name) 20 + } 21 + if store.Coordinates.Longitude != -122.139487 { 22 + t.Fatalf("unexpected longitude: %f", store.Coordinates.Longitude) 23 + } 24 + if store.Coordinates.Latitude != 47.609036 { 25 + t.Fatalf("unexpected latitude: %f", store.Coordinates.Latitude) 26 + } 27 + if store.Zip != "98007" { 28 + t.Fatalf("unexpected zip: %q", store.Zip) 29 + } 30 + if !store.SundayOpen { 31 + t.Fatal("expected sundayOpen=true") 32 + } 33 + } 34 + 35 + func TestParseStores_WrappedResults(t *testing.T) { 36 + t.Parallel() 37 + 38 + payload := []byte(`{"results":[{"no":3098,"name":"Bellevue Neighborhood Market","country":"US","coordinates":[-122.139487,47.609036],"streetAddress":"15063 MAIN ST","city":"Bellevue","stateProvCode":"WA","zip":"98007","phoneNumber":"425-643-9054","sundayOpen":true,"timezone":"PST"}]}`) 39 + 40 + stores, err := ParseStores(payload) 41 + if err != nil { 42 + t.Fatalf("parse stores: %v", err) 43 + } 44 + if len(stores) != 1 { 45 + t.Fatalf("unexpected store count: %d", len(stores)) 46 + } 47 + if stores[0].No != 3098 { 48 + t.Fatalf("unexpected store number: %d", stores[0].No) 49 + } 50 + } 51 + 52 + func TestCoordinates_UnmarshalJSON_RequiresLonLatPair(t *testing.T) { 53 + t.Parallel() 54 + 55 + _, err := ParseStore([]byte(`{"coordinates":[-122.139487]}`)) 56 + if err == nil { 57 + t.Fatal("expected error") 58 + } 59 + }