From 22c6e9d1bd55ca29e3b7b2feba7e131906c5d01b Mon Sep 17 00:00:00 2001 From: JBP <2850825+jybp@users.noreply.github.com> Date: Fri, 16 Aug 2019 21:28:25 +0200 Subject: [PATCH 1/4] wip integration bidding --- browse.go | 4 +- ebay.go | 16 +-- ebay_test.go | 5 +- offer.go | 47 +++++++++ test/integration/auction_test.go | 163 +++++++++++++++++++++++++++++++ test/integration/ebay_test.go | 70 ------------- tokensource/tokensource.go | 27 +++++ 7 files changed, 251 insertions(+), 81 deletions(-) create mode 100644 test/integration/auction_test.go delete mode 100644 test/integration/ebay_test.go create mode 100644 tokensource/tokensource.go 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 +} From 1fb075c056567638566198dac348cb47c36b9b62 Mon Sep 17 00:00:00 2001 From: JBP <2850825+jybp@users.noreply.github.com> Date: Sat, 17 Aug 2019 12:18:23 +0200 Subject: [PATCH 2/4] Add more GetBidding test and IsError --- Makefile | 2 +- browse.go | 6 +-- ebay.go | 80 +++++++++++++++++++++++--------- ebay_test.go | 48 +++++++++++++++---- offer.go | 68 ++++++++++++++++++++++++++- offer_test.go | 16 +++++++ test/integration/auction_test.go | 32 ++++--------- tokensource/tokensource.go | 1 - 8 files changed, 193 insertions(+), 60 deletions(-) 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 From ceeafe4fd6c3936df29df6ad6c35365802e159ca Mon Sep 17 00:00:00 2001 From: JBP <2850825+jybp@users.noreply.github.com> Date: Sat, 17 Aug 2019 15:05:34 +0200 Subject: [PATCH 3/4] Add PlaceProxyBid --- Makefile | 2 +- browse_test.go | 9 ++ clientcredentials/clientcredentials.go | 121 ------------------------- ebay.go | 14 ++- oauth2.go | 24 +++++ offer.go | 52 ++++++----- offer_test.go | 34 +++++++ test/integration/auction_test.go | 50 ++++++---- tokensource/tokensource.go | 26 ------ 9 files changed, 138 insertions(+), 194 deletions(-) delete mode 100644 clientcredentials/clientcredentials.go create mode 100644 oauth2.go delete mode 100644 tokensource/tokensource.go diff --git a/Makefile b/Makefile index 7f5384a..5587572 100644 --- a/Makefile +++ b/Makefile @@ -4,4 +4,4 @@ test: .PHONY: integration integration: - go test -count=1 -v -run "Auction" ./test/integration -integration=true -timeout=999999s \ No newline at end of file + go test -count=1 -v -run "Auction" ./test/integration -integration=true \ No newline at end of file diff --git a/browse_test.go b/browse_test.go index feedf1a..911786c 100644 --- a/browse_test.go +++ b/browse_test.go @@ -28,6 +28,9 @@ func TestGetLegacyItem(t *testing.T) { defer teardown() mux.HandleFunc("/buy/browse/v1/item/get_item_by_legacy_id", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET method, got: %s", r.Method) + } fmt.Fprintf(w, `{"itemId": "v1|%s|0"}`, r.URL.Query().Get("legacy_item_id")) }) @@ -41,6 +44,9 @@ func TestGetCompactItem(t *testing.T) { defer teardown() mux.HandleFunc("/buy/browse/v1/item/v1|202117468662|0", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET method, got: %s", r.Method) + } fmt.Fprintf(w, `{"itemId": "%s"}`, r.URL.Query().Get("fieldgroups")) }) @@ -54,6 +60,9 @@ func TestGettItem(t *testing.T) { defer teardown() mux.HandleFunc("/buy/browse/v1/item/v1|202117468662|0", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET method, got: %s", r.Method) + } fmt.Fprintf(w, `{"itemId": "%s"}`, r.URL.Query().Get("fieldgroups")) }) diff --git a/clientcredentials/clientcredentials.go b/clientcredentials/clientcredentials.go deleted file mode 100644 index 076a4bf..0000000 --- a/clientcredentials/clientcredentials.go +++ /dev/null @@ -1,121 +0,0 @@ -// Package clientcredentials implements the eBay client credentials grant flow -// to generate "Application access" tokens. -// eBay doc: https://developer.ebay.com/api-docs/static/oauth-client-credentials-grant.html -// -// The only difference from the original golang.org/x/oauth2/clientcredentials is the token type -// being forced to "Bearer". The eBay api /identity/v1/oauth2/token endpoint returns -// "Application Access Token" as token type which is then reused: -// https://github.com/golang/oauth2/blob/aaccbc9213b0974828f81aaac109d194880e3014/token.go#L68-L70 -package clientcredentials - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "strings" - "time" - - "golang.org/x/oauth2" -) - -type Config struct { - // ClientID is the application's ID. - ClientID string - - // ClientSecret is the application's secret. - ClientSecret string - - // TokenURL is the resource server's token endpoint - // URL. This is a constant specific to each server. - TokenURL string - - // Scope specifies optional requested permissions. - Scopes []string - - // HTTPClient used to make requests. - HTTPClient *http.Client -} - -// Token uses client credentials to retrieve a token. -func (c *Config) Token(ctx context.Context) (*oauth2.Token, error) { - return c.TokenSource(ctx).Token() -} - -// Client returns an HTTP client using the provided token. -// The token will auto-refresh as necessary. -// The returned client and its Transport should not be modified. -func (c *Config) Client(ctx context.Context) *http.Client { - return oauth2.NewClient(ctx, c.TokenSource(ctx)) -} - -// TokenSource returns a TokenSource that returns t until t expires, -// automatically refreshing it as necessary using the provided context and the -// client ID and client secret. -// -// Most users will use Config.Client instead. -func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource { - source := &tokenSource{ - ctx: ctx, - conf: c, - } - return oauth2.ReuseTokenSource(nil, source) -} - -type tokenSource struct { - ctx context.Context - conf *Config -} - -// Token refreshes the token by using a new client credentials request. -// tokens received this way do not include a refresh token -func (c *tokenSource) Token() (*oauth2.Token, error) { - v := url.Values{ - "grant_type": {"client_credentials"}, - } - if len(c.conf.Scopes) > 0 { - v.Set("scope", strings.Join(c.conf.Scopes, " ")) - } - req, err := http.NewRequest("POST", c.conf.TokenURL, strings.NewReader(v.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetBasicAuth(url.QueryEscape(c.conf.ClientID), url.QueryEscape(c.conf.ClientSecret)) - client := c.conf.HTTPClient - if client == nil { - client = http.DefaultClient - } - r, err := client.Do(req) - if err != nil { - return nil, err - } - defer r.Body.Close() - body, err := ioutil.ReadAll(r.Body) - if err != nil { - return nil, fmt.Errorf("cannot fetch token: %v", err) - } - if code := r.StatusCode; code < 200 || code > 299 { - return nil, fmt.Errorf("%s (%d)", req.URL, r.StatusCode) - } - token := struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - }{} - if err = json.NewDecoder(bytes.NewReader(body)).Decode(&token); err != nil { - return nil, err - } - var expiry time.Time - if secs := token.ExpiresIn; secs > 0 { - expiry = time.Now().Add(time.Duration(secs) * time.Second) - } - t := oauth2.Token{ - AccessToken: token.AccessToken, - Expiry: expiry, - TokenType: "Bearer", - } - return &t, nil -} diff --git a/ebay.go b/ebay.go index 3a540d2..440cc2e 100644 --- a/ebay.go +++ b/ebay.go @@ -88,16 +88,20 @@ func (c *Client) NewRequest(method, url string, body interface{}, opts ...Opt) ( if err != nil { return nil, errors.WithStack(err) } - var buf io.ReadWriter + var bodyR io.Reader if body != nil { - buf = new(bytes.Buffer) + buf := new(bytes.Buffer) enc := json.NewEncoder(buf) enc.SetEscapeHTML(false) if err := enc.Encode(body); err != nil { return nil, err } + b := buf.Bytes() + print("body:" + string(b)) // TODO + bodyR = bytes.NewReader(b) } - req, err := http.NewRequest(method, u.String(), buf) + + req, err := http.NewRequest(method, u.String(), bodyR) if err != nil { return nil, errors.WithStack(err) } @@ -152,7 +156,7 @@ type ErrorData struct { } func (e *ErrorData) Error() string { - return fmt.Sprintf("%d %s: %+v", e.response.StatusCode, e.requestDump, e.Errors) + return fmt.Sprintf("%d\n%s\n%+v", e.response.StatusCode, e.requestDump, e.Errors) } // CheckResponse checks the API response for errors, and returns them if present. @@ -166,7 +170,7 @@ func CheckResponse(req *http.Request, resp *http.Response) error { return errorData } -// IsError allows to check if err is a specific error codes returned by the eBay API. +// IsError allows to check if err contains 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 { diff --git a/oauth2.go b/oauth2.go new file mode 100644 index 0000000..0ee357a --- /dev/null +++ b/oauth2.go @@ -0,0 +1,24 @@ +package ebay + +import "golang.org/x/oauth2" + +// BearerTokenSource forces the type of the token returned by the 'base' TokenSource to 'Bearer'. +// The eBay API will return "Application Access Token" or "User Access Token" as token_type but +// it must be set to 'Bearer' for subsequent requests. +type BearerTokenSource struct { + base oauth2.TokenSource +} + +// TokenSource returns a new BearerTokenSource. +func TokenSource(base oauth2.TokenSource) *BearerTokenSource { + return &BearerTokenSource{base: base} +} + +// Token allows BearerTokenSource to implement oauth2.TokenSource. +func (ts *BearerTokenSource) Token() (*oauth2.Token, error) { + t, err := ts.base.Token() + if t != nil { + t.TokenType = "Bearer" + } + return t, err +} diff --git a/offer.go b/offer.go index fe920c9..8d6d570 100644 --- a/offer.go +++ b/offer.go @@ -76,15 +76,15 @@ const ( // 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) { +func (s *OfferService) GetBidding(ctx context.Context, itemID, marketplaceID string, opts ...Opt) (Bidding, error) { u := fmt.Sprintf("buy/offer/v1_beta/bidding/%s", itemID) opts = append(opts, OptBuyMarketplace(marketplaceID)) req, err := s.client.NewRequest(http.MethodGet, u, nil, opts...) if err != nil { - return Item{}, err + return Bidding{}, err } - var it Item - return it, s.client.Do(ctx, req, &it) + var bid Bidding + return bid, s.client.Do(ctx, req, &bid) } // ProxyBid represents an eBay proxy bid. @@ -94,7 +94,7 @@ type ProxyBid struct { // 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 +// eBay API docs: https://developer.ebay.com/api-docs/buy/offer/resources/bidding/methods/placeProxyBid#h2-error-codes const ( ErrPlaceProxyBidAuctionEndedBecauseOfBuyItNow = 120002 ErrPlaceProxyBidBidCannotBeGreaterThanBuyItNowPrice = 120005 @@ -111,34 +111,38 @@ const ( // PlaceProxyBid places a proxy bid for the buyer on a specific auction item. // +// Curency is the three-letter ISO 4217 code representing the currency. +// For one hundred US dollars, MaxAmout is "100.00" and currency is "USD". +// // 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. +// An item is adult-only if the AdultOnly field returned by the Browse API is set to true. // -// eBay API docs: https://developer.ebay.com/api-docs/buy/offer/resources/bidding/methods/getBidding +// eBay API docs: https://developer.ebay.com/api-docs/buy/offer/resources/bidding/methods/placeProxyBid 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}, + type userConsent struct { + AdultOnlyItem bool `json:"adultOnlyItem,omitempty"` + } + type amount struct { + Currency string `json:"currency"` + Value string `json:"value"` + } + type payload struct { + MaxAmount amount `json:"maxAmount"` + UserConsent *userConsent `json:"userConsent,omitempty"` + } + pl := payload{ + MaxAmount: amount{Currency: currency, Value: maxAmount}, + } + if userConsentAdultOnlyItem { + pl.UserConsent = &userConsent{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) + var bid ProxyBid + return bid, s.client.Do(ctx, req, &bid) } diff --git a/offer_test.go b/offer_test.go index c57c305..5895bbf 100644 --- a/offer_test.go +++ b/offer_test.go @@ -3,7 +3,9 @@ package ebay_test import ( "context" "fmt" + "io/ioutil" "net/http" + "strconv" "testing" "github.com/jybp/ebay" @@ -21,6 +23,9 @@ func TestGetBidding(t *testing.T) { defer teardown() mux.HandleFunc("/buy/offer/v1_beta/bidding/v1|202117468662|0", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET method, got: %s", r.Method) + } marketplaceID := r.Header.Get("X-EBAY-C-MARKETPLACE-ID") fmt.Fprintf(w, `{"itemId": "%s"}`, marketplaceID) }) @@ -29,3 +34,32 @@ func TestGetBidding(t *testing.T) { assert.Nil(t, err) assert.Equal(t, ebay.BuyMarketplaceUSA, bidding.ItemID) } + +func TestPlaceProxyBid(t *testing.T) { + client, mux, teardown := setup(t) + defer teardown() + + mux.HandleFunc("/buy/offer/v1_beta/bidding/v1|202117468662|0/place_proxy_bid", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("expected POST method, got: %s", r.Method) + } + marketplaceID := r.Header.Get("X-EBAY-C-MARKETPLACE-ID") + body, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatalf("%+v", err) + http.Error(w, err.Error(), 500) + return + } + escapedBody := strconv.Quote(string(body)) + escapedBody = escapedBody[1 : len(escapedBody)-1] + fmt.Fprintf(w, `{"proxyBidId": "%s_%s"}`, escapedBody, marketplaceID) + }) + + bid, err := client.Buy.Offer.PlaceProxyBid(context.Background(), "v1|202117468662|0", ebay.BuyMarketplaceUSA, "1.23", "USD", false) + assert.Nil(t, err) + assert.Equal(t, "{\"maxAmount\":{\"currency\":\"USD\",\"value\":\"1.23\"}}\n_EBAY_US", bid.ProxyBidID) + + bid, err = client.Buy.Offer.PlaceProxyBid(context.Background(), "v1|202117468662|0", ebay.BuyMarketplaceUSA, "1.23", "USD", true) + assert.Nil(t, err) + assert.Equal(t, "{\"maxAmount\":{\"currency\":\"USD\",\"value\":\"1.23\"},\"userConsent\":{\"adultOnlyItem\":true}}\n_EBAY_US", bid.ProxyBidID) +} diff --git a/test/integration/auction_test.go b/test/integration/auction_test.go index fe7f6ae..48a754c 100644 --- a/test/integration/auction_test.go +++ b/test/integration/auction_test.go @@ -9,21 +9,22 @@ import ( "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" + "golang.org/x/oauth2/clientcredentials" ) var ( integration bool clientID string clientSecret string + auctionURL string ) func init() { @@ -34,8 +35,10 @@ func init() { } 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.") + // You have to manually create an auction in the sandbox. Auctions can't be created using the rest api (yet?). + auctionURL = os.Getenv("SANDOX_AUCTION_URL") + if clientID == "" || clientSecret == "" || auctionURL == "" { + panic("Please set SANDBOX_CLIENT_ID, SANDBOX_CLIENT_SECRET and SANDOX_AUCTION_URL.") } } @@ -44,20 +47,16 @@ func TestAuction(t *testing.T) { t.SkipNow() } - // 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() - conf := oclientcredentials.Config{ + 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(oauth2.NewClient(ctx, tokensource.New(conf.TokenSource(ctx)))) + client := ebay.NewSandboxClient(oauth2.NewClient(ctx, ebay.TokenSource(conf.TokenSource(ctx)))) lit, err := client.Buy.Browse.GetItemByLegacyID(ctx, auctionURL[strings.LastIndex(auctionURL, "/")+1:]) if err != nil { @@ -84,8 +83,6 @@ func TestAuction(t *testing.T) { } 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) @@ -139,13 +136,32 @@ func TestAuction(t *testing.T) { t.Fatal(err) } - client = ebay.NewSandboxClient(oauth2.NewClient(ctx, tokensource.New(oauthConf.TokenSource(ctx, tok)))) + client = ebay.NewSandboxClient(oauth2.NewClient(ctx, ebay.TokenSource(oauthConf.TokenSource(ctx, tok)))) - _, err = client.Buy.Offer.GetBidding(ctx, it.ItemID, ebay.BuyMarketplaceUSA) - if !ebay.IsError(err, ebay.ErrGetBiddingNoBiddingActivity) { - t.Logf("Expected ErrNoBiddingActivity, got %+v.", err) + bid, err := client.Buy.Offer.GetBidding(ctx, it.ItemID, ebay.BuyMarketplaceUSA) + if err != nil && !ebay.IsError(err, ebay.ErrGetBiddingNoBiddingActivity) { + t.Fatalf("Expected error code %d, got %+v.", ebay.ErrGetBiddingNoBiddingActivity, err) } - // err := client.Buy.Offer.PlaceProxyBid(ctx) + var bidValue, bidCurrency string + if len(bid.SuggestedBidAmounts) > 0 { + bidValue = bid.SuggestedBidAmounts[0].Value + bidCurrency = bid.SuggestedBidAmounts[0].Currency + } else { + bidValue = it.CurrentBidPrice.Value + v, err := strconv.ParseFloat(bidValue, 64) + if err != nil { + t.Fatal(err) + } + v += 2 + bidValue = fmt.Sprintf("%.2f", v) + bidCurrency = it.CurrentBidPrice.Currency + } + _, err = client.Buy.Offer.PlaceProxyBid(ctx, it.ItemID, ebay.BuyMarketplaceUSA, bidValue, bidCurrency, false) + if err != nil { + t.Fatal(err) + } + + t.Logf("Successfully bid %+v.", bid.SuggestedBidAmounts[0]) } diff --git a/tokensource/tokensource.go b/tokensource/tokensource.go deleted file mode 100644 index 8bc0291..0000000 --- a/tokensource/tokensource.go +++ /dev/null @@ -1,26 +0,0 @@ -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 { - t.TokenType = "Bearer" - } - return t, err -} From 1af95b0307dd62515f67608c22742a02d875b854 Mon Sep 17 00:00:00 2001 From: JBP <2850825+jybp@users.noreply.github.com> Date: Sat, 17 Aug 2019 18:20:55 +0200 Subject: [PATCH 4/4] setup tls --- ebay.go | 9 ++-- test/integration/auction_test.go | 22 ++++---- test/integration/tls.go | 87 ++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 test/integration/tls.go diff --git a/ebay.go b/ebay.go index 440cc2e..7c5e383 100644 --- a/ebay.go +++ b/ebay.go @@ -88,20 +88,17 @@ func (c *Client) NewRequest(method, url string, body interface{}, opts ...Opt) ( if err != nil { return nil, errors.WithStack(err) } - var bodyR io.Reader + var buf io.ReadWriter if body != nil { - buf := new(bytes.Buffer) + buf = new(bytes.Buffer) enc := json.NewEncoder(buf) enc.SetEscapeHTML(false) if err := enc.Encode(body); err != nil { return nil, err } - b := buf.Bytes() - print("body:" + string(b)) // TODO - bodyR = bytes.NewReader(b) } - req, err := http.NewRequest(method, u.String(), bodyR) + req, err := http.NewRequest(method, u.String(), buf) if err != nil { return nil, errors.WithStack(err) } diff --git a/test/integration/auction_test.go b/test/integration/auction_test.go index 48a754c..c50eec5 100644 --- a/test/integration/auction_test.go +++ b/test/integration/auction_test.go @@ -35,10 +35,8 @@ func init() { } clientID = os.Getenv("SANDBOX_CLIENT_ID") clientSecret = os.Getenv("SANDBOX_CLIENT_SECRET") - // You have to manually create an auction in the sandbox. Auctions can't be created using the rest api (yet?). - auctionURL = os.Getenv("SANDOX_AUCTION_URL") - if clientID == "" || clientSecret == "" || auctionURL == "" { - panic("Please set SANDBOX_CLIENT_ID, SANDBOX_CLIENT_SECRET and SANDOX_AUCTION_URL.") + if clientID == "" || clientSecret == "" { + panic("Please set SANDBOX_CLIENT_ID and SANDBOX_CLIENT_SECRET.") } } @@ -49,6 +47,9 @@ func TestAuction(t *testing.T) { ctx := context.Background() + // You have to manually create an auction in the sandbox. Auctions can't be created using the rest api (yet?). + auctionURL = os.Getenv("SANDOX_AUCTION_URL") + conf := clientcredentials.Config{ ClientID: clientID, ClientSecret: clientSecret, @@ -66,9 +67,6 @@ func TestAuction(t *testing.T) { 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 { @@ -89,7 +87,8 @@ func TestAuction(t *testing.T) { } state := url.QueryEscape(string(b)) authCodeC := make(chan string) - http.HandleFunc("/accept", func(rw http.ResponseWriter, r *http.Request) { + mux := setupTLS() + mux.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) @@ -105,15 +104,12 @@ func TestAuction(t *testing.T) { 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) { + mux.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) { + mux.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, diff --git a/test/integration/tls.go b/test/integration/tls.go new file mode 100644 index 0000000..e9c1f6a --- /dev/null +++ b/test/integration/tls.go @@ -0,0 +1,87 @@ +package integration + +// From https://gist.github.com/shivakar/cd52b5594d4912fbeb46 + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "log" + "math/big" + "net" + "net/http" + "time" +) + +// From https://golang.org/src/net/http/server.go +// tcpKeepAliveListener sets TCP keep-alive timeouts on accepted +// connections. It's used by ListenAndServe and ListenAndServeTLS so +// dead TCP connections (e.g. closing laptop mid-download) eventually +// go away. +type tcpKeepAliveListener struct { + *net.TCPListener + keepAlivePeriod time.Duration +} + +func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) { + tc, err := ln.AcceptTCP() + if err != nil { + return + } + tc.SetKeepAlive(true) + tc.SetKeepAlivePeriod(ln.keepAlivePeriod) + return tc, nil +} + +func tlsCert(name string, dur time.Duration) (tls.Certificate, error) { + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{CommonName: name}, + NotBefore: now, + NotAfter: now.Add(dur), + BasicConstraintsValid: true, + IsCA: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + } + + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return tls.Certificate{}, err + } + + cert, err := x509.CreateCertificate(rand.Reader, template, template, priv.Public(), priv) + if err != nil { + return tls.Certificate{}, err + } + + var outCert tls.Certificate + outCert.Certificate = append(outCert.Certificate, cert) + outCert.PrivateKey = priv + return outCert, nil +} + +func setupTLS() *http.ServeMux { + cert, err := tlsCert("eSniper", time.Hour) + if err != nil { + log.Fatal(err) + } + mux := http.NewServeMux() + srv := &http.Server{Addr: ":52125", Handler: mux} + cfg := &tls.Config{} + cfg.NextProtos = []string{"http/1.1"} + cfg.Certificates = make([]tls.Certificate, 1) + cfg.Certificates[0] = cert + ln, err := net.Listen("tcp", ":52125") + if err != nil { + log.Fatal(err) + } + tlsListener := tls.NewListener(tcpKeepAliveListener{ln.(*net.TCPListener), time.Minute}, cfg) + go func() { + srv.Serve(tlsListener) + }() + return mux +}