diff --git a/browse.go b/browse.go index 3c0da4b..b7733fa 100644 --- a/browse.go +++ b/browse.go @@ -8,12 +8,12 @@ import ( "time" ) -// BrowseService handles communication with the Browse API +// BrowseService handles communication with the Browse API. // // eBay API docs: https://developer.ebay.com/api-docs/buy/browse/overview.html type BrowseService service -// Valid values of the "buyingOptions" array for items. +// Valid values for the "buyingOptions" item field. const ( BrowseBuyingOptionAuction = "AUCTION" BrowseBuyingOptionFixedPrice = "FIXED_PRICE" diff --git a/ebay.go b/ebay.go index a0583f7..e8659b6 100644 --- a/ebay.go +++ b/ebay.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httputil" "net/url" "strings" @@ -102,7 +103,7 @@ func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) error return errors.WithStack(err) } defer resp.Body.Close() - if err := CheckResponse(resp); err != nil { + if err := CheckResponse(req, resp); err != nil { return err } if v == nil { @@ -111,7 +112,7 @@ func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) error return errors.WithStack(json.NewDecoder(resp.Body).Decode(v)) } -// An ErrorData reports one or more errors caused by an API request. +// ErrorData reports one or more errors caused by an API request. // // eBay API docs: https://developer.ebay.com/api-docs/static/handling-error-messages.html type ErrorData struct { @@ -129,20 +130,21 @@ type ErrorData struct { Value string `json:"value,omitempty"` } `json:"parameters,omitempty"` } `json:"errors,omitempty"` - Response *http.Response + Response *http.Response + RequestDump string } func (e *ErrorData) Error() string { - return fmt.Sprintf("%s %s: %d %+v", e.Response.Request.Method, e.Response.Request.URL, - e.Response.StatusCode, 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. -func CheckResponse(resp *http.Response) error { +func CheckResponse(req *http.Request, resp *http.Response) error { if s := resp.StatusCode; 200 <= s && s < 300 { return nil } - errorData := &ErrorData{Response: resp} + dump, _ := httputil.DumpRequest(req, true) + errorData := &ErrorData{Response: resp, RequestDump: string(dump)} _ = json.NewDecoder(resp.Body).Decode(errorData) return errorData } diff --git a/ebay_test.go b/ebay_test.go index 3d2f311..59eaa1f 100644 --- a/ebay_test.go +++ b/ebay_test.go @@ -6,6 +6,7 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/jybp/ebay" @@ -24,7 +25,7 @@ func TestNewRequest(t *testing.T) { func TestCheckResponseNoError(t *testing.T) { resp := &http.Response{StatusCode: 200} - assert.Nil(t, ebay.CheckResponse(resp)) + assert.Nil(t, ebay.CheckResponse(&http.Request{}, resp)) } func TestCheckResponse(t *testing.T) { @@ -53,7 +54,7 @@ func TestCheckResponse(t *testing.T) { ] }` resp := &http.Response{StatusCode: 400, Body: ioutil.NopCloser(bytes.NewBufferString(body))} - err, ok := ebay.CheckResponse(resp).(*ebay.ErrorData) + err, ok := ebay.CheckResponse(&http.Request{URL: &url.URL{}}, resp).(*ebay.ErrorData) assert.True(t, ok) assert.Equal(t, 1, len(err.Errors)) assert.Equal(t, 15008, err.Errors[0].ErrorID) diff --git a/offer.go b/offer.go index 87ddfbe..3b50cfd 100644 --- a/offer.go +++ b/offer.go @@ -1,7 +1,10 @@ package ebay import ( + "context" + "fmt" "net/http" + "time" ) // OfferService handles communication with the Offer API @@ -22,6 +25,11 @@ const ( BuyMarketplaceUSA = "EBAY_US" ) +// Valid values for the "auctionStatus" Bidding field. +const ( + BiddingAuctionStatusEnded = "ENDED" +) + // OptBuyMarketplace adds the header containing the marketplace id: // https://developer.ebay.com/api-docs/buy/static/ref-marketplace-supported.html // @@ -31,3 +39,42 @@ func OptBuyMarketplace(marketplaceID string) func(*http.Request) { req.Header.Set("X-EBAY-C-MARKETPLACE-ID", marketplaceID) } } + +// Bidding represents an eBay item bidding. +type Bidding struct { + AuctionStatus string `json:"auctionStatus"` + AuctionEndDate time.Time `json:"auctionEndDate"` + ItemID string `json:"itemId"` + CurrentPrice struct { + Value string `json:"value"` + Currency string `json:"currency"` + } `json:"currentPrice"` + BidCount int `json:"bidCount"` + HighBidder bool `json:"highBidder"` + ReservePriceMet bool `json:"reservePriceMet"` + SuggestedBidAmounts []struct { + Value string `json:"value"` + Currency string `json:"currency"` + } `json:"suggestedBidAmounts"` + CurrentProxyBid struct { + ProxyBidID string `json:"proxyBidId"` + MaxAmount struct { + Value string `json:"value"` + Currency string `json:"currency"` + } `json:"maxAmount"` + } `json:"currentProxyBid"` +} + +// GetBidding retrieves the buyer's bidding details on an auction. +// +// 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...) + if err != nil { + return Item{}, err + } + var it Item + return it, s.client.Do(ctx, req, &it) +} diff --git a/test/integration/auction_test.go b/test/integration/auction_test.go new file mode 100644 index 0000000..42d631b --- /dev/null +++ b/test/integration/auction_test.go @@ -0,0 +1,163 @@ +package integration + +import ( + "context" + "crypto/rand" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "testing" + "time" + + _ "github.com/joho/godotenv/autoload" + "github.com/jybp/ebay" + "github.com/jybp/ebay/tokensource" + "golang.org/x/oauth2" + oclientcredentials "golang.org/x/oauth2/clientcredentials" +) + +var ( + integration bool + clientID string + clientSecret string +) + +func init() { + flag.BoolVar(&integration, "integration", false, "run integration tests") + flag.Parse() + if !integration { + return + } + clientID = os.Getenv("SANDBOX_CLIENT_ID") + clientSecret = os.Getenv("SANDBOX_CLIENT_SECRET") + if clientID == "" || clientSecret == "" { + panic("No SANDBOX_CLIENT_ID or SANDBOX_CLIENT_SECRET. Tests won't run.") + } +} + +func TestAuction(t *testing.T) { + if !integration { + t.SkipNow() + } + + // Manually create an auction in the sandbox and copy/paste the url: + const auctionURL = "https://www.sandbox.ebay.com/itm/110439278158" + + ctx := context.Background() + + conf := oclientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: "https://api.sandbox.ebay.com/identity/v1/oauth2/token", + Scopes: []string{"https://api.ebay.com/oauth/api_scope"}, + } + + client := ebay.NewSandboxClient(oauth2.NewClient(ctx, tokensource.New(conf.TokenSource(ctx)))) + + lit, err := client.Buy.Browse.GetItemByLegacyID(ctx, auctionURL[strings.LastIndex(auctionURL, "/")+1:]) + if err != nil { + t.Fatalf("%+v", err) + } + it, err := client.Buy.Browse.GetItem(ctx, lit.ItemID) + if err != nil { + t.Fatalf("%+v", err) + } + if testing.Verbose() { + t.Logf("item: %+v\n", it) + } + isAuction := false + for _, opt := range it.BuyingOptions { + if opt == ebay.BrowseBuyingOptionAuction { + isAuction = true + } + } + if !isAuction { + 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.Logf("item %s UniqueBidderCount:%d minimumBidPrice: %+v currentPriceToBid: %+v\n", it.ItemID, it.UniqueBidderCount, it.MinimumPriceToBid, it.CurrentBidPrice) + + // Setup oauth server. + + b := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + t.Fatalf("%+v", err) + } + 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 { + http.Error(rw, fmt.Sprintf("invalid state: %+v", err), http.StatusBadRequest) + return + } + if string(actualState) != state { + http.Error(rw, fmt.Sprintf("invalid state:\nexpected:%s\nactual:%s", state, string(actualState)), http.StatusBadRequest) + 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")) + rw.Write([]byte("Accept. You can safely close this tab.")) + }) + http.HandleFunc("/policy", func(rw http.ResponseWriter, r *http.Request) { + rw.Write([]byte("eBay Sniper Policy")) + }) + http.HandleFunc("/decline", func(rw http.ResponseWriter, r *http.Request) { + rw.Write([]byte("Decline. You can safely close this tab.")) + }) + go func() { + t.Fatal(http.ListenAndServe(":52125", nil)) + }() + + oauthConf := oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://auth.sandbox.ebay.com/oauth2/authorize", + TokenURL: "https://api.sandbox.ebay.com/identity/v1/oauth2/token", + }, + RedirectURL: "Jean-Baptiste_P-JeanBapt-testgo-cowrprk", + Scopes: []string{"https://api.ebay.com/oauth/api_scope/buy.offer.auction"}, + } + + url := oauthConf.AuthCodeURL(state) + fmt.Printf("Visit the URL: %v\n", url) + + authCode := <-authCodeC + + tok, err := oauthConf.Exchange(ctx, authCode) + if err != nil { + 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) + } + t.Logf("item %s bidding: %+v\n", it.ItemID, bidding) +} diff --git a/test/integration/ebay_test.go b/test/integration/ebay_test.go deleted file mode 100644 index 8f339f0..0000000 --- a/test/integration/ebay_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package integration - -import ( - "context" - "flag" - "os" - "strings" - "testing" - "time" - - _ "github.com/joho/godotenv/autoload" - "github.com/jybp/ebay" - "github.com/jybp/ebay/clientcredentials" -) - -var ( - integration bool - client *ebay.Client -) - -func init() { - flag.BoolVar(&integration, "integration", false, "run integration tests") - clientID := os.Getenv("SANDBOX_CLIENT_ID") - clientSecret := os.Getenv("SANDBOX_CLIENT_SECRET") - if clientID == "" || clientSecret == "" { - panic("No SANDBOX_CLIENT_ID or SANDBOX_CLIENT_SECRET. Tests won't run.") - } - conf := clientcredentials.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - TokenURL: "https://api.sandbox.ebay.com/identity/v1/oauth2/token", - Scopes: []string{"https://api.ebay.com/oauth/api_scope"}, - } - client = ebay.NewSandboxClient(conf.Client(context.Background())) -} - -func TestAuction(t *testing.T) { - if !integration { - t.SkipNow() - } - - // Manually create an auction in the sandbox and copy/paste the url: - const url = "https://www.sandbox.ebay.com/itm/110439278158" - - ctx := context.Background() - lit, err := client.Buy.Browse.GetItemByLegacyID(ctx, url[strings.LastIndex(url, "/")+1:]) - if err != nil { - t.Fatalf("%+v", err) - } - it, err := client.Buy.Browse.GetItem(ctx, lit.ItemID) - if err != nil { - t.Fatalf("%+v", err) - } - if testing.Verbose() { - t.Logf("item: %+v", it) - } - isAuction := false - for _, opt := range it.BuyingOptions { - if opt == ebay.BrowseBuyingOptionAuction { - isAuction = true - } - } - if !isAuction { - 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.Logf("item %s UniqueBidderCount:%d minimumBidPrice: %+v currentPriceToBid: %+v", it.ItemID, it.UniqueBidderCount, it.MinimumPriceToBid, it.CurrentBidPrice) -} diff --git a/tokensource/tokensource.go b/tokensource/tokensource.go new file mode 100644 index 0000000..36f2e2b --- /dev/null +++ b/tokensource/tokensource.go @@ -0,0 +1,27 @@ +package tokensource + +import "golang.org/x/oauth2" + +// type TokenSource interface { +// // Token returns a token or an error. +// // Token must be safe for concurrent use by multiple goroutines. +// // The returned Token must not be modified. +// Token() (*Token, error) +// } + +type TokenSource struct { + base oauth2.TokenSource +} + +func New(base oauth2.TokenSource) *TokenSource { + return &TokenSource{base: base} +} + +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 +}