diff --git a/Makefile b/Makefile index 5587572..7f5384a 100644 --- a/Makefile +++ b/Makefile @@ -4,4 +4,4 @@ test: .PHONY: integration integration: - go test -count=1 -v -run "Auction" ./test/integration -integration=true \ No newline at end of file + go test -count=1 -v -run "Auction" ./test/integration -integration=true -timeout=999999s \ No newline at end of file diff --git a/browse.go b/browse.go index b7733fa..c09d9c8 100644 --- a/browse.go +++ b/browse.go @@ -160,7 +160,7 @@ type LegacyItem struct { // eBay API docs: https://developer.ebay.com/api-docs/buy/browse/resources/item/methods/getItemByLegacyId func (s *BrowseService) GetItemByLegacyID(ctx context.Context, itemLegacyID string, opts ...Opt) (CompactItem, error) { u := fmt.Sprintf("buy/browse/v1/item/get_item_by_legacy_id?legacy_item_id=%s", itemLegacyID) - req, err := s.client.NewRequest(http.MethodGet, u, opts...) + req, err := s.client.NewRequest(http.MethodGet, u, nil, opts...) if err != nil { return CompactItem{}, err } @@ -190,7 +190,7 @@ type CompactItem struct { // eBay API docs: https://developer.ebay.com/api-docs/buy/browse/resources/item/methods/getItem func (s *BrowseService) GetCompactItem(ctx context.Context, itemID string, opts ...Opt) (CompactItem, error) { u := fmt.Sprintf("buy/browse/v1/item/%s?fieldgroups=COMPACT", itemID) - req, err := s.client.NewRequest(http.MethodGet, u, opts...) + req, err := s.client.NewRequest(http.MethodGet, u, nil, opts...) if err != nil { return CompactItem{}, err } @@ -368,7 +368,7 @@ type Item struct { // eBay API docs: https://developer.ebay.com/api-docs/buy/browse/resources/item/methods/getItem func (s *BrowseService) GetItem(ctx context.Context, itemID string, opts ...Opt) (Item, error) { u := fmt.Sprintf("buy/browse/v1/item/%s?fieldgroups=PRODUCT", itemID) - req, err := s.client.NewRequest(http.MethodGet, u, opts...) + req, err := s.client.NewRequest(http.MethodGet, u, nil, opts...) if err != nil { return Item{}, err } diff --git a/ebay.go b/ebay.go index e8659b6..3a540d2 100644 --- a/ebay.go +++ b/ebay.go @@ -1,9 +1,11 @@ package ebay import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httputil" "net/url" @@ -78,7 +80,7 @@ type Opt func(*http.Request) // NewRequest creates an API request. // url should always be specified without a preceding slash. -func (c *Client) NewRequest(method, url string, opts ...Opt) (*http.Request, error) { +func (c *Client) NewRequest(method, url string, body interface{}, opts ...Opt) (*http.Request, error) { if strings.HasPrefix(url, "/") { return nil, errors.New("url should always be specified without a preceding slash") } @@ -86,7 +88,16 @@ func (c *Client) NewRequest(method, url string, opts ...Opt) (*http.Request, err if err != nil { return nil, errors.WithStack(err) } - req, err := http.NewRequest(method, u.String(), nil) + var buf io.ReadWriter + if body != nil { + buf = new(bytes.Buffer) + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(body); err != nil { + return nil, err + } + } + req, err := http.NewRequest(method, u.String(), buf) if err != nil { return nil, errors.WithStack(err) } @@ -112,30 +123,36 @@ func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) error return errors.WithStack(json.NewDecoder(resp.Body).Decode(v)) } -// ErrorData reports one or more errors caused by an API request. +// Error describes one error caused by an eBay API request. +// +// eBay API docs: https://developer.ebay.com/api-docs/static/handling-error-messages.html +type Error struct { + ErrorID int `json:"errorId,omitempty"` + Domain string `json:"domain,omitempty"` + SubDomain string `json:"subDomain,omitempty"` + Category string `json:"category,omitempty"` + Message string `json:"message,omitempty"` + LongMessage string `json:"longMessage,omitempty"` + InputRefIds []string `json:"inputRefIds,omitempty"` + OuputRefIds []string `json:"outputRefIds,omitempty"` + Parameters []struct { + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` + } `json:"parameters,omitempty"` +} + +// ErrorData describes one or more errors caused by an eBay API request. // // eBay API docs: https://developer.ebay.com/api-docs/static/handling-error-messages.html type ErrorData struct { - Errors []struct { - ErrorID int `json:"errorId,omitempty"` - Domain string `json:"domain,omitempty"` - SubDomain string `json:"subDomain,omitempty"` - Category string `json:"category,omitempty"` - Message string `json:"message,omitempty"` - LongMessage string `json:"longMessage,omitempty"` - InputRefIds []string `json:"inputRefIds,omitempty"` - OuputRefIds []string `json:"outputRefIds,omitempty"` - Parameters []struct { - Name string `json:"name,omitempty"` - Value string `json:"value,omitempty"` - } `json:"parameters,omitempty"` - } `json:"errors,omitempty"` - Response *http.Response - RequestDump string + Errors []Error `json:"errors,omitempty"` + + response *http.Response + requestDump string } func (e *ErrorData) Error() string { - return fmt.Sprintf("%d %s: %+v", e.Response.StatusCode, e.RequestDump, e.Errors) + return fmt.Sprintf("%d %s: %+v", e.response.StatusCode, e.requestDump, e.Errors) } // CheckResponse checks the API response for errors, and returns them if present. @@ -144,7 +161,28 @@ func CheckResponse(req *http.Request, resp *http.Response) error { return nil } dump, _ := httputil.DumpRequest(req, true) - errorData := &ErrorData{Response: resp, RequestDump: string(dump)} + errorData := &ErrorData{response: resp, requestDump: string(dump)} _ = json.NewDecoder(resp.Body).Decode(errorData) return errorData } + +// IsError allows to check if err is a specific error codes returned by the eBay API. +// +// eBay API docs: https://developer.ebay.com/devzone/xml/docs/Reference/ebay/Errors/errormessages.htm +func IsError(err error, codes ...int) bool { + if err == nil { + return false + } + errData, ok := err.(*ErrorData) + if !ok { + return false + } + for _, e := range errData.Errors { + for _, code := range codes { + if e.ErrorID == code { + return true + } + } + } + return false +} diff --git a/ebay_test.go b/ebay_test.go index 59eaa1f..3e7fe24 100644 --- a/ebay_test.go +++ b/ebay_test.go @@ -10,15 +10,28 @@ import ( "testing" "github.com/jybp/ebay" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" ) +// setup sets up a test HTTP server. +func setup(t *testing.T) (client *ebay.Client, mux *http.ServeMux, teardown func()) { + mux = http.NewServeMux() + server := httptest.NewServer(mux) + var err error + client, err = ebay.NewCustomClient(nil, server.URL+"/") + if err != nil { + t.Fatal(err) + } + return client, mux, server.Close +} + func TestNewRequest(t *testing.T) { testOpt := func(r *http.Request) { r.URL.RawQuery = "q=1" } client, _ := ebay.NewCustomClient(nil, "https://api.ebay.com/") - r, _ := client.NewRequest(http.MethodPost, "test", testOpt) + r, _ := client.NewRequest(http.MethodPost, "test", nil, testOpt) assert.Equal(t, "https://api.ebay.com/test?q=1", fmt.Sprint(r.URL)) assert.Equal(t, http.MethodPost, r.Method) } @@ -69,14 +82,29 @@ func TestCheckResponse(t *testing.T) { assert.Equal(t, "2200077988|0", err.Errors[0].Parameters[0].Value) } -// setup sets up a test HTTP server -func setup(t *testing.T) (client *ebay.Client, mux *http.ServeMux, teardown func()) { - mux = http.NewServeMux() - server := httptest.NewServer(mux) - var err error - client, err = ebay.NewCustomClient(nil, server.URL+"/") - if err != nil { - t.Fatal(err) +func TestIsErrorMatches(t *testing.T) { + var err error = &ebay.ErrorData{ + Errors: []ebay.Error{ + ebay.Error{ErrorID: 1}, + }, } - return client, mux, server.Close + assert.True(t, ebay.IsError(err, 1, 2, 3)) +} + +func TestIsErrorNoMatches(t *testing.T) { + var err error = &ebay.ErrorData{ + Errors: []ebay.Error{ + ebay.Error{ErrorID: 4}, + }, + } + assert.False(t, ebay.IsError(err, 1, 2, 3)) +} + +func TestIsErrorWrongType(t *testing.T) { + var err error = errors.New("test") + assert.False(t, ebay.IsError(err, 1, 2, 3)) +} + +func TestIsErrorNil(t *testing.T) { + assert.False(t, ebay.IsError(nil, 1, 2, 3)) } diff --git a/offer.go b/offer.go index 3b50cfd..fe920c9 100644 --- a/offer.go +++ b/offer.go @@ -65,16 +65,80 @@ type Bidding struct { } `json:"currentProxyBid"` } -// GetBidding retrieves the buyer's bidding details on an auction. +// Some valid eBay error codes for the GetBidding method. +// +// eBay API docs: https://developer.ebay.com/api-docs/buy/offer/resources/bidding/methods/getBidding#h2-error-codes +const ( + ErrGetBiddingMarketplaceNotSupported = 120017 + ErrGetBiddingNoBiddingActivity = 120033 +) + +// GetBidding retrieves the buyer's bidding details on a specific auction item. // // eBay API docs: https://developer.ebay.com/api-docs/buy/offer/resources/bidding/methods/getBidding func (s *OfferService) GetBidding(ctx context.Context, itemID, marketplaceID string, opts ...Opt) (Item, error) { u := fmt.Sprintf("buy/offer/v1_beta/bidding/%s", itemID) opts = append(opts, OptBuyMarketplace(marketplaceID)) - req, err := s.client.NewRequest(http.MethodGet, u, opts...) + req, err := s.client.NewRequest(http.MethodGet, u, nil, opts...) if err != nil { return Item{}, err } var it Item return it, s.client.Do(ctx, req, &it) } + +// ProxyBid represents an eBay proxy bid. +type ProxyBid struct { + ProxyBidID string `json:"proxyBidId"` +} + +// Some valid eBay error codes for the PlaceProxyBid method. +// +// eBay API docs: https://developer.ebay.com/api-docs/buy/offer/resources/bidding/methods/getBidding#h2-error-codes +const ( + ErrPlaceProxyBidAuctionEndedBecauseOfBuyItNow = 120002 + ErrPlaceProxyBidBidCannotBeGreaterThanBuyItNowPrice = 120005 + ErrPlaceProxyBidAmountTooHigh = 120007 + ErrPlaceProxyBidAmountTooLow = 120008 + ErrPlaceProxyBidCurrencyMustMatchItemPriceCurrency = 120009 + ErrPlaceProxyBidCannotLowerYourProxyBid = 120010 + ErrPlaceProxyBidAmountExceedsLimit = 120011 + ErrPlaceProxyBidAuctionHasEnded = 120012 + ErrPlaceProxyBidAmountInvalid = 120013 + ErrPlaceProxyBidCurrencyInvalid = 120014 + ErrPlaceProxyBidMaximumBidAmountMissing = 120016 +) + +// PlaceProxyBid places a proxy bid for the buyer on a specific auction item. +// +// You must ensure the user agrees to the "Terms of use for Adult Only category" +// (https://signin.ebay.com/ws/eBayISAPI.dll?AdultSignIn2) if he wishes to bid on on a adult-only item. +// +// eBay API docs: https://developer.ebay.com/api-docs/buy/offer/resources/bidding/methods/getBidding +func (s *OfferService) PlaceProxyBid(ctx context.Context, itemID, marketplaceID, maxAmount, currency string, userConsentAdultOnlyItem bool, opts ...Opt) (ProxyBid, error) { + u := fmt.Sprintf("buy/offer/v1_beta/bidding/%s/place_proxy_bid", itemID) + opts = append(opts, OptBuyMarketplace(marketplaceID)) + pl := struct { + MaxAmount struct { + Currency string `json:"currency"` + Value string `json:"value"` + } `json:"maxAmount"` + UserConsent struct { + AdultOnlyItem bool `json:"adultOnlyItem"` + } `json:"userConsent"` + }{ + MaxAmount: struct { + Currency string `json:"currency"` + Value string `json:"value"` + }{currency, maxAmount}, + UserConsent: struct { + AdultOnlyItem bool `json:"adultOnlyItem"` + }{userConsentAdultOnlyItem}, + } + req, err := s.client.NewRequest(http.MethodPost, u, &pl, opts...) + if err != nil { + return ProxyBid{}, err + } + var p ProxyBid + return p, s.client.Do(ctx, req, &p) +} diff --git a/offer_test.go b/offer_test.go index f27066a..c57c305 100644 --- a/offer_test.go +++ b/offer_test.go @@ -1,6 +1,8 @@ package ebay_test import ( + "context" + "fmt" "net/http" "testing" @@ -13,3 +15,17 @@ func TestOptBuyMarketplace(t *testing.T) { ebay.OptBuyMarketplace("EBAY_US")(r) assert.Equal(t, "EBAY_US", r.Header.Get("X-EBAY-C-MARKETPLACE-ID")) } + +func TestGetBidding(t *testing.T) { + client, mux, teardown := setup(t) + defer teardown() + + mux.HandleFunc("/buy/offer/v1_beta/bidding/v1|202117468662|0", func(w http.ResponseWriter, r *http.Request) { + marketplaceID := r.Header.Get("X-EBAY-C-MARKETPLACE-ID") + fmt.Fprintf(w, `{"itemId": "%s"}`, marketplaceID) + }) + + bidding, err := client.Buy.Offer.GetBidding(context.Background(), "v1|202117468662|0", ebay.BuyMarketplaceUSA) + assert.Nil(t, err) + assert.Equal(t, ebay.BuyMarketplaceUSA, bidding.ItemID) +} diff --git a/test/integration/auction_test.go b/test/integration/auction_test.go index 42d631b..fe7f6ae 100644 --- a/test/integration/auction_test.go +++ b/test/integration/auction_test.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "os" - "strconv" "strings" "testing" "time" @@ -45,8 +44,9 @@ func TestAuction(t *testing.T) { t.SkipNow() } - // Manually create an auction in the sandbox and copy/paste the url: - const auctionURL = "https://www.sandbox.ebay.com/itm/110439278158" + // Manually create an auction in the sandbox and copy/paste the url. + // Auctions can't be created using the rest api (yet?). + const auctionURL = "https://www.sandbox.ebay.com/itm/110440008951" ctx := context.Background() @@ -80,7 +80,7 @@ func TestAuction(t *testing.T) { t.Fatalf("item %s is not an auction. BuyingOptions are: %+v", it.ItemID, it.BuyingOptions) } if time.Now().UTC().After(it.ItemEndDate) { - // t.Fatalf("item %s end date has been reached. ItemEndDate is: %s", it.ItemID, it.ItemEndDate.String()) + t.Fatalf("item %s end date has been reached. ItemEndDate is: %s", it.ItemID, it.ItemEndDate.String()) } t.Logf("item %s UniqueBidderCount:%d minimumBidPrice: %+v currentPriceToBid: %+v\n", it.ItemID, it.UniqueBidderCount, it.MinimumPriceToBid, it.CurrentBidPrice) @@ -92,7 +92,6 @@ func TestAuction(t *testing.T) { } state := url.QueryEscape(string(b)) authCodeC := make(chan string) - var expiresIn time.Duration http.HandleFunc("/accept", func(rw http.ResponseWriter, r *http.Request) { actualState, err := url.QueryUnescape(r.URL.Query().Get("state")) if err != nil { @@ -104,12 +103,6 @@ func TestAuction(t *testing.T) { return } code := r.URL.Query().Get("code") - expiresInSeconds, err := strconv.Atoi(r.URL.Query().Get("expires_in")) - if err != nil { - http.Error(rw, fmt.Sprintf("invalid expires_in: %+v", err), http.StatusBadRequest) - return - } - expiresIn = time.Second * time.Duration(expiresInSeconds) authCodeC <- code t.Logf("The authorization code is %s.\n", code) t.Logf("The authorization code will expire in %s seconds.\n", r.URL.Query().Get("expires_in")) @@ -146,18 +139,13 @@ func TestAuction(t *testing.T) { t.Fatal(err) } - // Force token regen. - - // tok.Expiry = time.Now().Add(-time.Hour * 24) // not working? - - fmt.Printf("Sleeping %v so token expires\n", expiresIn) - time.Sleep(expiresIn + time.Second*5) - client = ebay.NewSandboxClient(oauth2.NewClient(ctx, tokensource.New(oauthConf.TokenSource(ctx, tok)))) - bidding, err := client.Buy.Offer.GetBidding(ctx, it.ItemID, ebay.BuyMarketplaceUSA) - if err != nil { - t.Fatalf("%+v", err) + _, err = client.Buy.Offer.GetBidding(ctx, it.ItemID, ebay.BuyMarketplaceUSA) + if !ebay.IsError(err, ebay.ErrGetBiddingNoBiddingActivity) { + t.Logf("Expected ErrNoBiddingActivity, got %+v.", err) } - t.Logf("item %s bidding: %+v\n", it.ItemID, bidding) + + // err := client.Buy.Offer.PlaceProxyBid(ctx) + } diff --git a/tokensource/tokensource.go b/tokensource/tokensource.go index 36f2e2b..8bc0291 100644 --- a/tokensource/tokensource.go +++ b/tokensource/tokensource.go @@ -20,7 +20,6 @@ func New(base oauth2.TokenSource) *TokenSource { func (ts *TokenSource) Token() (*oauth2.Token, error) { t, err := ts.base.Token() if t != nil { - print("new token: " + t.AccessToken + "\n") // TODO remove t.TokenType = "Bearer" } return t, err