// Package ebay is a thin client for eBay's official Buy > Browse API. It // handles client-credentials OAuth2 token caching and active-listing search // (item_summary/search). It deliberately covers only what Veola needs: a // keyword search returning normalized listings. Sold/completed data (the // Marketplace Insights API) is not implemented here. package ebay import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" ) // endpoints bundles the production/sandbox base URLs for the two APIs used. type endpoints struct { oauth string browse string } func endpointsFor(environment string) endpoints { if strings.EqualFold(strings.TrimSpace(environment), "sandbox") { return endpoints{ oauth: "https://api.sandbox.ebay.com/identity/v1/oauth2/token", browse: "https://api.sandbox.ebay.com/buy/browse/v1", } } return endpoints{ oauth: "https://api.ebay.com/identity/v1/oauth2/token", browse: "https://api.ebay.com/buy/browse/v1", } } // Client is safe for concurrent use. The application access token is cached // in memory and refreshed shortly before it expires. type Client struct { HTTP *http.Client mu sync.Mutex clientID string clientSecret string ends endpoints token string tokenExpiry time.Time } // New builds a client for the given keyset. environment is "production" // (default) or "sandbox". Credentials may be empty; calls then fail fast // with a "not configured" error. func New(clientID, clientSecret, environment string) *Client { return &Client{ HTTP: &http.Client{Timeout: 30 * time.Second}, clientID: clientID, clientSecret: clientSecret, ends: endpointsFor(environment), } } // EnsureCredentials updates the keyset if it changed, discarding any cached // token so the next call re-authenticates. The environment is fixed at // construction time and is not changed here. Safe to call on every poll. func (c *Client) EnsureCredentials(clientID, clientSecret string) { c.mu.Lock() defer c.mu.Unlock() if clientID == c.clientID && clientSecret == c.clientSecret { return } c.clientID = clientID c.clientSecret = clientSecret c.token = "" c.tokenExpiry = time.Time{} } func (c *Client) accessToken(ctx context.Context) (string, error) { c.mu.Lock() defer c.mu.Unlock() if c.clientID == "" || c.clientSecret == "" { return "", errors.New("ebay credentials not configured") } if c.token != "" && time.Now().Before(c.tokenExpiry) { return c.token, nil } form := url.Values{} form.Set("grant_type", "client_credentials") form.Set("scope", "https://api.ebay.com/oauth/api_scope") req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.ends.oauth, strings.NewReader(form.Encode())) if err != nil { return "", err } basic := base64.StdEncoding.EncodeToString([]byte(c.clientID + ":" + c.clientSecret)) req.Header.Set("Authorization", "Basic "+basic) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTP.Do(req) if err != nil { return "", fmt.Errorf("ebay oauth: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode >= 300 { return "", fmt.Errorf("ebay oauth: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var tr struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` TokenType string `json:"token_type"` } if err := json.Unmarshal(body, &tr); err != nil { return "", fmt.Errorf("ebay oauth: decode: %w", err) } if tr.AccessToken == "" { return "", errors.New("ebay oauth: empty access token") } c.token = tr.AccessToken // Refresh a minute early to avoid racing the expiry. ttl := time.Duration(tr.ExpiresIn) * time.Second if ttl <= time.Minute { ttl = time.Minute } c.tokenExpiry = time.Now().Add(ttl - time.Minute) return c.token, nil } // browseItemSummary mirrors the subset of the item_summary/search response // Veola consumes. type browseItemSummary struct { ItemID string `json:"itemId"` Title string `json:"title"` Price struct { Value string `json:"value"` Currency string `json:"currency"` } `json:"price"` ItemWebURL string `json:"itemWebUrl"` Image struct { ImageURL string `json:"imageUrl"` } `json:"image"` ThumbnailImages []struct { ImageURL string `json:"imageUrl"` } `json:"thumbnailImages"` Seller struct { Username string `json:"username"` } `json:"seller"` // itemEndDate is present only on auction-format listings. ItemEndDate string `json:"itemEndDate"` } // Search runs one item_summary/search call and returns normalized listings. // An empty query is rejected: the Browse API requires a non-empty q. func (c *Client) Search(ctx context.Context, p SearchParams) ([]Listing, error) { query := strings.TrimSpace(p.Query) if query == "" { return nil, errors.New("ebay search requires a non-empty query") } token, err := c.accessToken(ctx) if err != nil { return nil, err } marketplace := p.MarketplaceID if marketplace == "" { marketplace = "EBAY_US" } limit := p.Limit if limit <= 0 || limit > 200 { limit = 50 } q := url.Values{} q.Set("q", query) q.Set("limit", strconv.Itoa(limit)) // The Browse API "filter" parameter takes a comma-separated list of // filter clauses; assemble whichever ones the caller requested. var filters []string if f := buyingOptionsFilter(p.ListingType); f != "" { filters = append(filters, f) } if f := conditionIDsFilter(p.Condition); f != "" { filters = append(filters, f) } if f := itemLocationFilter(p.Region); f != "" { filters = append(filters, f) } if len(filters) > 0 { q.Set("filter", strings.Join(filters, ",")) } reqURL := c.ends.browse + "/item_summary/search?" + q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("X-EBAY-C-MARKETPLACE-ID", marketplace) req.Header.Set("Content-Type", "application/json") resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("ebay browse: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode >= 300 { return nil, fmt.Errorf("ebay browse: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var sr struct { ItemSummaries []browseItemSummary `json:"itemSummaries"` } if err := json.Unmarshal(body, &sr); err != nil { return nil, fmt.Errorf("ebay browse: decode: %w", err) } out := make([]Listing, 0, len(sr.ItemSummaries)) for _, s := range sr.ItemSummaries { price, _ := strconv.ParseFloat(strings.TrimSpace(s.Price.Value), 64) img := s.Image.ImageURL if img == "" && len(s.ThumbnailImages) > 0 { img = s.ThumbnailImages[0].ImageURL } store := "ebay" if s.Seller.Username != "" { store = "ebay (" + s.Seller.Username + ")" } var endsAt *time.Time if s.ItemEndDate != "" { if t, err := time.Parse(time.RFC3339, s.ItemEndDate); err == nil { endsAt = &t } } out = append(out, Listing{ Title: s.Title, Price: price, Currency: s.Price.Currency, URL: s.ItemWebURL, Store: store, ImageURL: img, EndsAt: endsAt, }) } return out, nil }