From afc9ffef330103fb6939fcd09383d22980338785 Mon Sep 17 00:00:00 2001 From: Mathieu Pheulpin Date: Wed, 5 Jun 2019 10:54:39 -0700 Subject: [PATCH] [Sharethrough] Add new Sharethrough Adapter (#903) * wip * wip * wip * exploration for sharethrough prebid-server * WIP: updating sharethrough adapter to latest prebid version Co-authored-by: Chris Nguyen * WIP: adding butler params to the request #164291358 Co-authored-by: Josh Becker * Manage bid sizes [#164291358] Co-authored-by: Josh Becker * Manage GDPR [#164291358] Co-authored-by: Josh Becker * Populate prebid-server version if provided [#164291358] Co-authored-by: Josh Becker * Refactor gdpr data extraction from request [#164291358] Co-authored-by: Eddy Pechuzal * Add instant play capability [#164291358] Co-authored-by: Eddy Pechuzal * Split in multiple files [#164291358] Co-authored-by: Eddy Pechuzal * Add s2s-win beacon todo? replace server name by server id? [#164291358] Co-authored-by: Eddy Pechuzal * Removing `server` param in s2s-win beacon (will be added in imp req) [#164291358] * Clean up code + enable syncer (#4) [#165257793] * Proper error handling (#6) * Proper error handling [#165745574] * Address review (baby clean up) + catch error that was missed [#165745574] * Implement Unit Tests (#7) * Proper error handling [#165745574] * Address review (baby clean up) + catch error that was missed [#165745574] * Implement unit tests for utils [#165891351] * Add UT for utils + butler [#165891351] Co-authored-by: Michael Duran * Attempt for testing Bidder interface function implementations [#165891351] Co-authored-by: Michael Duran * Finalizing Unit tests [#165891351] Co-authored-by: Chris Nguyen Co-authored-by: Josh Becker * Fixing sharethrough.yaml capabilities [#165891351] Co-authored-by: Josh Becker * Send supplyId to imp req instead of hbSource (#5) [#165477915] * Finalize PR (#8) [#164911891] Co-authored-by: Josh Becker * Remove test setting * Add Sharethrough in syncer_test * Update deserializing of third party partners * Refactor/optimize UserAgent parsing (#9) following josephveach's review in prebid/prebid-server#903 * Addressing June 3rd review from prebid/prebid-server#903 Optimizations, clean up suggested by @mansinahar * Addressing June 4th review from prebid/prebid-server#903 (#10) * Addressing June 4th review from prebid/prebid-server#903 Clean up canAutoPlayVideo + hardcode bhVersion to unknown for now... * Removing hbVersion butler param since it's not accessible * Fix adMarkup error handling --- adapters/sharethrough/butler.go | 191 ++++++++ adapters/sharethrough/butler_test.go | 399 +++++++++++++++++ adapters/sharethrough/params_test.go | 58 +++ adapters/sharethrough/sharethrough.go | 72 +++ adapters/sharethrough/sharethrough_test.go | 245 +++++++++++ .../sharethroughtest/params/race/banner.json | 5 + .../sharethroughtest/params/race/native.json | 5 + adapters/sharethrough/usersync.go | 11 + adapters/sharethrough/usersync_test.go | 19 + adapters/sharethrough/utils.go | 193 +++++++++ adapters/sharethrough/utils_test.go | 410 ++++++++++++++++++ config/config.go | 2 + exchange/adapter_map.go | 2 + openrtb_ext/bidders.go | 2 + openrtb_ext/imp_sharethrough.go | 102 +++++ static/bidder-info/sharethrough.yaml | 11 + static/bidder-params/sharethrough.json | 28 ++ usersync/usersyncers/syncer.go | 2 + usersync/usersyncers/syncer_test.go | 1 + 19 files changed, 1758 insertions(+) create mode 100644 adapters/sharethrough/butler.go create mode 100644 adapters/sharethrough/butler_test.go create mode 100644 adapters/sharethrough/params_test.go create mode 100644 adapters/sharethrough/sharethrough.go create mode 100644 adapters/sharethrough/sharethrough_test.go create mode 100644 adapters/sharethrough/sharethroughtest/params/race/banner.json create mode 100644 adapters/sharethrough/sharethroughtest/params/race/native.json create mode 100644 adapters/sharethrough/usersync.go create mode 100644 adapters/sharethrough/usersync_test.go create mode 100644 adapters/sharethrough/utils.go create mode 100644 adapters/sharethrough/utils_test.go create mode 100644 openrtb_ext/imp_sharethrough.go create mode 100644 static/bidder-info/sharethrough.yaml create mode 100644 static/bidder-params/sharethrough.json diff --git a/adapters/sharethrough/butler.go b/adapters/sharethrough/butler.go new file mode 100644 index 00000000000..f16be7a9f17 --- /dev/null +++ b/adapters/sharethrough/butler.go @@ -0,0 +1,191 @@ +package sharethrough + +import ( + "encoding/json" + "fmt" + "github.com/mxmCherry/openrtb" + "github.com/prebid/prebid-server/adapters" + "github.com/prebid/prebid-server/errortypes" + "github.com/prebid/prebid-server/openrtb_ext" + "net/http" + "net/url" + "regexp" + "strconv" +) + +type StrAdSeverParams struct { + Pkey string + BidID string + ConsentRequired bool + ConsentString string + InstantPlayCapable bool + Iframe bool + Height uint64 + Width uint64 +} + +type StrOpenRTBInterface interface { + requestFromOpenRTB(openrtb.Imp, *openrtb.BidRequest) (*adapters.RequestData, error) + responseToOpenRTB(openrtb_ext.ExtImpSharethroughResponse, *adapters.RequestData) (*adapters.BidderResponse, []error) +} + +type StrAdServerUriInterface interface { + buildUri(StrAdSeverParams) string + parseUri(string) (*StrAdSeverParams, error) +} + +type UserAgentParsers struct { + ChromeVersion *regexp.Regexp + ChromeiOSVersion *regexp.Regexp + SafariVersion *regexp.Regexp +} + +type StrUriHelper struct { + BaseURI string +} + +type StrOpenRTBTranslator struct { + UriHelper StrAdServerUriInterface + Util UtilityInterface + UserAgentParsers UserAgentParsers +} + +func (s StrOpenRTBTranslator) requestFromOpenRTB(imp openrtb.Imp, request *openrtb.BidRequest) (*adapters.RequestData, error) { + headers := http.Header{} + headers.Add("Content-Type", "text/plain;charset=utf-8") + headers.Add("Accept", "application/json") + + var strImpExt adapters.ExtImpBidder + if err := json.Unmarshal(imp.Ext, &strImpExt); err != nil { + return nil, err + } + var strImpParams openrtb_ext.ExtImpSharethroughExt + if err := json.Unmarshal(strImpExt.Bidder, &strImpParams); err != nil { + return nil, err + } + + pKey := strImpParams.Pkey + + var height, width uint64 + if len(strImpParams.IframeSize) >= 2 { + height, width = uint64(strImpParams.IframeSize[0]), uint64(strImpParams.IframeSize[1]) + } else { + height, width = s.Util.getPlacementSize(imp.Banner.Format) + } + + return &adapters.RequestData{ + Method: "POST", + Uri: s.UriHelper.buildUri(StrAdSeverParams{ + Pkey: pKey, + BidID: imp.ID, + ConsentRequired: s.Util.gdprApplies(request), + ConsentString: s.Util.gdprConsentString(request), + Iframe: strImpParams.Iframe, + Height: height, + Width: width, + InstantPlayCapable: s.Util.canAutoPlayVideo(request.Device.UA, s.UserAgentParsers), + }), + Body: nil, + Headers: headers, + }, nil +} + +func (s StrOpenRTBTranslator) responseToOpenRTB(strResp openrtb_ext.ExtImpSharethroughResponse, btlrReq *adapters.RequestData) (*adapters.BidderResponse, []error) { + var errs []error + bidResponse := adapters.NewBidderResponse() + + bidResponse.Currency = "USD" + typedBid := &adapters.TypedBid{BidType: openrtb_ext.BidTypeNative} + + if len(strResp.Creatives) == 0 { + errs = append(errs, &errortypes.BadInput{Message: "No creative provided"}) + return nil, errs + } + creative := strResp.Creatives[0] + + btlrParams, parseHBUriErr := s.UriHelper.parseUri(btlrReq.Uri) + if parseHBUriErr != nil { + errs = append(errs, &errortypes.BadInput{Message: parseHBUriErr.Error()}) + return nil, errs + } + + adm, admErr := s.Util.getAdMarkup(strResp, btlrParams) + if admErr != nil { + errs = append(errs, &errortypes.BadServerResponse{Message: admErr.Error()}) + return nil, errs + } + + bid := &openrtb.Bid{ + AdID: strResp.AdServerRequestID, + ID: strResp.BidID, + ImpID: btlrParams.BidID, + Price: creative.CPM, + CID: creative.Metadata.CampaignKey, + CrID: creative.Metadata.CreativeKey, + DealID: creative.Metadata.DealID, + AdM: adm, + H: btlrParams.Height, + W: btlrParams.Width, + } + + typedBid.Bid = bid + bidResponse.Bids = append(bidResponse.Bids, typedBid) + + return bidResponse, errs +} + +func (h StrUriHelper) buildUri(params StrAdSeverParams) string { + v := url.Values{} + v.Set("placement_key", params.Pkey) + v.Set("bidId", params.BidID) + v.Set("consent_required", fmt.Sprintf("%t", params.ConsentRequired)) + v.Set("consent_string", params.ConsentString) + + v.Set("instant_play_capable", fmt.Sprintf("%t", params.InstantPlayCapable)) + v.Set("stayInIframe", fmt.Sprintf("%t", params.Iframe)) + v.Set("height", strconv.FormatUint(params.Height, 10)) + v.Set("width", strconv.FormatUint(params.Width, 10)) + + v.Set("supplyId", supplyId) + v.Set("strVersion", strVersion) + + return h.BaseURI + "?" + v.Encode() +} + +func (h StrUriHelper) parseUri(uri string) (*StrAdSeverParams, error) { + btlrUrl, err := url.Parse(uri) + if err != nil { + return nil, err + } + + params := btlrUrl.Query() + height, err := strconv.ParseUint(params.Get("height"), 10, 64) + if err != nil { + return nil, err + } + + width, err := strconv.ParseUint(params.Get("width"), 10, 64) + if err != nil { + return nil, err + } + + stayInIframe, err := strconv.ParseBool(params.Get("stayInIframe")) + if err != nil { + stayInIframe = false + } + + consentRequired, err := strconv.ParseBool(params.Get("consent_required")) + if err != nil { + consentRequired = false + } + + return &StrAdSeverParams{ + Pkey: params.Get("placement_key"), + BidID: params.Get("bidId"), + Iframe: stayInIframe, + Height: height, + Width: width, + ConsentRequired: consentRequired, + ConsentString: params.Get("consent_string"), + }, nil +} diff --git a/adapters/sharethrough/butler_test.go b/adapters/sharethrough/butler_test.go new file mode 100644 index 00000000000..2e258ecf775 --- /dev/null +++ b/adapters/sharethrough/butler_test.go @@ -0,0 +1,399 @@ +package sharethrough + +import ( + "fmt" + "github.com/mxmCherry/openrtb" + "github.com/prebid/prebid-server/adapters" + "github.com/prebid/prebid-server/errortypes" + "github.com/prebid/prebid-server/openrtb_ext" + "net/http" + "regexp" + "strings" + "testing" +) + +type MockUtil struct { + mockCanAutoPlayVideo func() bool + mockGdprApplies func() bool + mockGdprConsentString func() string + mockGenerateHBUri func() string + mockGetPlacementSize func() (uint64, uint64) + UtilityInterface +} + +func (m MockUtil) canAutoPlayVideo(userAgent string) bool { + return m.mockCanAutoPlayVideo() +} + +func (m MockUtil) gdprApplies(request *openrtb.BidRequest) bool { + return m.mockGdprApplies() +} + +func (m MockUtil) gdprConsentString(bidRequest *openrtb.BidRequest) string { + return m.mockGdprConsentString() +} + +func (m MockUtil) generateHBUri(baseUrl string, params StrAdSeverParams, app *openrtb.App) string { + return m.mockGenerateHBUri() +} + +func (m MockUtil) getPlacementSize(formats []openrtb.Format) (height uint64, width uint64) { + return m.mockGetPlacementSize() +} + +func assertRequestDataEquals(t *testing.T, testName string, expected *adapters.RequestData, actual *adapters.RequestData) { + t.Logf("Test case: %s\n", testName) + if expected.Method != actual.Method { + t.Errorf("Method mismatch: expected %s got %s\n", expected.Method, actual.Method) + } + if expected.Uri != actual.Uri { + t.Errorf("Uri mismatch: expected %s got %s\n", expected.Uri, actual.Uri) + } + if len(expected.Body) != len(actual.Body) { + t.Errorf("Body mismatch: expected %s got %s\n", expected.Body, actual.Body) + } + for headerIndex, expectedHeader := range expected.Headers { + if expectedHeader[0] != actual.Headers[headerIndex][0] { + t.Errorf("Header %s mismatch: expected %s got %s\n", headerIndex, expectedHeader[0], actual.Headers[headerIndex][0]) + } + } +} + +func TestSuccessRequestFromOpenRTB(t *testing.T) { + tests := map[string]struct { + inputImp openrtb.Imp + inputReq *openrtb.BidRequest + expected *adapters.RequestData + }{ + "Generates the correct AdServer request from Imp": { + inputImp: openrtb.Imp{ + ID: "abc", + Ext: []byte(`{ "bidder": {"pkey": "pkey", "iframe": true, "iframeSize": [10, 20]} }`), + Banner: &openrtb.Banner{ + Format: []openrtb.Format{{H: 30, W: 40}}, + }, + }, + inputReq: &openrtb.BidRequest{ + App: &openrtb.App{Ext: []byte(`{}`)}, + Device: &openrtb.Device{ + UA: "Android Chome/60", + }, + }, + expected: &adapters.RequestData{ + Method: "POST", + Uri: "http://abc.com", + Body: nil, + Headers: http.Header{ + "Content-Type": []string{"text/plain;charset=utf-8"}, + "Accept": []string{"application/json"}, + }, + }, + }, + } + + mockUriHelper := MockStrUriHelper{ + mockBuildUri: func() string { + return "http://abc.com" + }, + } + + adServer := StrOpenRTBTranslator{UriHelper: mockUriHelper, Util: Util{}, UserAgentParsers: UserAgentParsers{ + ChromeVersion: regexp.MustCompile(`Chrome\/(?P\d+)`), + ChromeiOSVersion: regexp.MustCompile(`CriOS\/(?P\d+)`), + SafariVersion: regexp.MustCompile(`Version\/(?P\d+)`), + }} + for testName, test := range tests { + outputSuccess, outputError := adServer.requestFromOpenRTB(test.inputImp, test.inputReq) + assertRequestDataEquals(t, testName, test.expected, outputSuccess) + if outputError != nil { + t.Errorf("Expected no errors, got %s\n", outputError) + } + } +} + +func assertBidderResponseEquals(t *testing.T, testName string, expected adapters.BidderResponse, actual adapters.BidderResponse) { + t.Logf("Test case: %s\n", testName) + if len(expected.Bids) != len(actual.Bids) { + t.Errorf("Expected %d bids in BidResponse, got %d\n", len(expected.Bids), len(actual.Bids)) + return + } + for index, expectedTypedBid := range expected.Bids { + if expectedTypedBid.BidType != actual.Bids[index].BidType { + t.Errorf("Bid[%d]: Type mismatch, expected %s got %s\n", index, expectedTypedBid.BidType, actual.Bids[index].BidType) + } + if expectedTypedBid.Bid.AdID != actual.Bids[index].Bid.AdID { + t.Errorf("Bid[%d]: AdID mismatch, expected %s got %s\n", index, expectedTypedBid.Bid.AdID, actual.Bids[index].Bid.AdID) + } + if expectedTypedBid.Bid.ID != actual.Bids[index].Bid.ID { + t.Errorf("Bid[%d]: ID mismatch, expected %s got %s\n", index, expectedTypedBid.Bid.ID, actual.Bids[index].Bid.ID) + } + if expectedTypedBid.Bid.ImpID != actual.Bids[index].Bid.ImpID { + t.Errorf("Bid[%d]: ImpID mismatch, expected %s got %s\n", index, expectedTypedBid.Bid.ImpID, actual.Bids[index].Bid.ImpID) + } + if expectedTypedBid.Bid.Price != actual.Bids[index].Bid.Price { + t.Errorf("Bid[%d]: Price mismatch, expected %f got %f\n", index, expectedTypedBid.Bid.Price, actual.Bids[index].Bid.Price) + } + if expectedTypedBid.Bid.CID != actual.Bids[index].Bid.CID { + t.Errorf("Bid[%d]: CID mismatch, expected %s got %s\n", index, expectedTypedBid.Bid.CID, actual.Bids[index].Bid.CID) + } + if expectedTypedBid.Bid.CrID != actual.Bids[index].Bid.CrID { + t.Errorf("Bid[%d]: CrID mismatch, expected %s got %s\n", index, expectedTypedBid.Bid.CrID, actual.Bids[index].Bid.CrID) + } + if expectedTypedBid.Bid.DealID != actual.Bids[index].Bid.DealID { + t.Errorf("Bid[%d]: DealID mismatch, expected %s got %s\n", index, expectedTypedBid.Bid.DealID, actual.Bids[index].Bid.DealID) + } + if expectedTypedBid.Bid.H != actual.Bids[index].Bid.H { + t.Errorf("Bid[%d]: H mismatch, expected %d got %d\n", index, expectedTypedBid.Bid.H, actual.Bids[index].Bid.H) + } + if expectedTypedBid.Bid.W != actual.Bids[index].Bid.W { + t.Errorf("Bid[%d]: W mismatch, expected %d got %d\n", index, expectedTypedBid.Bid.W, actual.Bids[index].Bid.W) + } + } +} + +func TestSuccessResponseToOpenRTB(t *testing.T) { + tests := map[string]struct { + inputButlerReq *adapters.RequestData + inputStrResp openrtb_ext.ExtImpSharethroughResponse + expectedSuccess *adapters.BidderResponse + expectedErrors []error + }{ + "Generates expected openRTB bid response": { + inputButlerReq: &adapters.RequestData{ + Uri: "http://uri.com?placement_key=pkey&bidId=bidid&height=20&width=30", + }, + inputStrResp: openrtb_ext.ExtImpSharethroughResponse{ + AdServerRequestID: "arid", + BidID: "bid", + Creatives: []openrtb_ext.ExtImpSharethroughCreative{{ + CPM: 10, + Metadata: openrtb_ext.ExtImpSharethroughCreativeMetadata{ + CampaignKey: "cmpKey", + CreativeKey: "creaKey", + DealID: "dealId", + }, + }}, + }, + expectedSuccess: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{{ + BidType: openrtb_ext.BidTypeNative, + Bid: &openrtb.Bid{ + AdID: "arid", + ID: "bid", + ImpID: "bidid", + Price: 10, + CID: "cmpKey", + CrID: "creaKey", + DealID: "dealId", + H: 20, + W: 30, + }, + }}, + }, + expectedErrors: []error{}, + }, + } + + adServer := StrOpenRTBTranslator{Util: Util{}, UriHelper: StrUriHelper{}} + for testName, test := range tests { + outputSuccess, outputErrors := adServer.responseToOpenRTB(test.inputStrResp, test.inputButlerReq) + assertBidderResponseEquals(t, testName, *test.expectedSuccess, *outputSuccess) + if len(outputErrors) != len(test.expectedErrors) { + t.Errorf("Expected %d errors, got %d\n", len(test.expectedErrors), len(outputErrors)) + } + } +} + +func TestFailResponseToOpenRTB(t *testing.T) { + tests := map[string]struct { + inputButlerReq *adapters.RequestData + inputStrResp openrtb_ext.ExtImpSharethroughResponse + expectedSuccess *adapters.BidderResponse + expectedErrors []error + }{ + "Returns nil if no creatives provided": { + inputButlerReq: &adapters.RequestData{}, + inputStrResp: openrtb_ext.ExtImpSharethroughResponse{ + Creatives: []openrtb_ext.ExtImpSharethroughCreative{}, + }, + expectedSuccess: nil, + expectedErrors: []error{ + &errortypes.BadInput{Message: "No creative provided"}, + }, + }, + "Returns nil if failed to parse Uri": { + inputButlerReq: &adapters.RequestData{ + Uri: "wrong format url", + }, + inputStrResp: openrtb_ext.ExtImpSharethroughResponse{ + Creatives: []openrtb_ext.ExtImpSharethroughCreative{{}}, + }, + expectedSuccess: nil, + expectedErrors: []error{ + &errortypes.BadInput{Message: `strconv.ParseUint: parsing "": invalid syntax`}, + }, + }, + } + + adServer := StrOpenRTBTranslator{UriHelper: StrUriHelper{}} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + outputSuccess, outputErrors := adServer.responseToOpenRTB(test.inputStrResp, test.inputButlerReq) + + if test.expectedSuccess != outputSuccess { + t.Errorf("Expected result %+v, got %+v\n", test.expectedSuccess, outputSuccess) + } + + if len(outputErrors) != len(test.expectedErrors) { + t.Errorf("Expected %d errors, got %d\n", len(test.expectedErrors), len(outputErrors)) + } + + for index, expectedError := range test.expectedErrors { + if fmt.Sprintf("%T", expectedError) != fmt.Sprintf("%T", outputErrors[index]) { + t.Errorf("Error type mismatch, expected %T, got %T\n", expectedError, outputErrors[index]) + } + if expectedError.Error() != outputErrors[index].Error() { + t.Errorf("Expected error %s, got %s\n", expectedError.Error(), outputErrors[index].Error()) + } + } + } +} + +func TestBuildUri(t *testing.T) { + tests := map[string]struct { + inputParams StrAdSeverParams + inputApp *openrtb.App + expected []string + }{ + "Generates expected URL, appending all params": { + inputParams: StrAdSeverParams{ + Pkey: "pkey", + BidID: "bid", + ConsentRequired: true, + ConsentString: "consent", + InstantPlayCapable: true, + Iframe: false, + Height: 20, + Width: 30, + }, + expected: []string{ + "http://abc.com?", + "placement_key=pkey", + "bidId=bid", + "consent_required=true", + "consent_string=consent", + "instant_play_capable=true", + "stayInIframe=false", + "height=20", + "width=30", + "supplyId=FGMrCMMc", + "strVersion=1.0.0", + }, + }, + } + + uriHelper := StrUriHelper{BaseURI: "http://abc.com"} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + output := uriHelper.buildUri(test.inputParams) + + for _, uriParam := range test.expected { + if !strings.Contains(output, uriParam) { + t.Errorf("Expected %s to be found in URL, got %s\n", uriParam, output) + } + } + } +} + +func assertStrAdServerParamsEquals(t *testing.T, testName string, expected *StrAdSeverParams, actual *StrAdSeverParams) { + t.Logf("Test case: %s\n", testName) + if expected.Pkey != actual.Pkey { + t.Errorf("Expected Pkey to be %s, got %s\n", expected.Pkey, actual.Pkey) + } + if expected.BidID != actual.BidID { + t.Errorf("Expected BidID to be %s, got %s\n", expected.BidID, actual.BidID) + } + if expected.Iframe != actual.Iframe { + t.Errorf("Expected Iframe to be %t, got %t\n", expected.Iframe, actual.Iframe) + } + if expected.Height != actual.Height { + t.Errorf("Expected Height to be %d, got %d\n", expected.Height, actual.Height) + } + if expected.Width != actual.Width { + t.Errorf("Expected Width to be %d, got %d\n", expected.Width, actual.Width) + } + if expected.ConsentRequired != actual.ConsentRequired { + t.Errorf("Expected ConsentRequired to be %t, got %t\n", expected.ConsentRequired, actual.ConsentRequired) + } + if expected.ConsentString != actual.ConsentString { + t.Errorf("Expected ConsentString to be %s, got %s\n", expected.ConsentString, actual.ConsentString) + } +} + +func TestSuccessParseUri(t *testing.T) { + tests := map[string]struct { + input string + expectedSuccess *StrAdSeverParams + }{ + "Decodes URI successfully": { + input: "http://abc.com?placement_key=pkey&bidId=bid&consent_required=true&consent_string=consent&instant_play_capable=true&stayInIframe=false&height=20&width=30&hbVersion=1&supplyId=FGMrCMMc&strVersion=1.0.0", + expectedSuccess: &StrAdSeverParams{ + Pkey: "pkey", + BidID: "bid", + Iframe: false, + Height: 20, + Width: 30, + ConsentRequired: true, + ConsentString: "consent", + }, + }, + } + + uriHelper := StrUriHelper{} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + output, actualError := uriHelper.parseUri(test.input) + + assertStrAdServerParamsEquals(t, testName, test.expectedSuccess, output) + if actualError != nil { + t.Errorf("Expected no errors, got %s\n", actualError) + } + } +} + +func TestFailParseUri(t *testing.T) { + tests := map[string]struct { + input string + expectedError string + }{ + "Fails decoding if unable to parse URI": { + input: "wrong URI", + expectedError: `strconv.ParseUint: parsing "": invalid syntax`, + }, + "Fails decoding if height not provided": { + input: "http://abc.com?width=10", + expectedError: `strconv.ParseUint: parsing "": invalid syntax`, + }, + "Fails decoding if width not provided": { + input: "http://abc.com?height=10", + expectedError: `strconv.ParseUint: parsing "": invalid syntax`, + }, + } + + uriHelper := StrUriHelper{} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + output, actualError := uriHelper.parseUri(test.input) + + if output != nil { + t.Errorf("Expected return value nil, got %+v\n", output) + } + if actualError == nil { + t.Errorf("Expected error not to be nil\n") + break + } + if actualError.Error() != test.expectedError { + t.Errorf("Expected error '%s', got '%s'\n", test.expectedError, actualError.Error()) + } + } +} diff --git a/adapters/sharethrough/params_test.go b/adapters/sharethrough/params_test.go new file mode 100644 index 00000000000..416f459341d --- /dev/null +++ b/adapters/sharethrough/params_test.go @@ -0,0 +1,58 @@ +package sharethrough + +import ( + "encoding/json" + "testing" + + "github.com/prebid/prebid-server/openrtb_ext" +) + +func TestValidParams(t *testing.T) { + validator, err := openrtb_ext.NewBidderParamsValidator("../../static/bidder-params") + if err != nil { + t.Fatalf("Failed to fetch the json-schemas. %v", err) + } + + for _, validParam := range validParams { + if err := validator.Validate(openrtb_ext.BidderSharethrough, json.RawMessage(validParam)); err != nil { + t.Errorf("Schema rejected Sharethrough params: %s", validParam) + } + } +} + +func TestInvalidParams(t *testing.T) { + validator, err := openrtb_ext.NewBidderParamsValidator("../../static/bidder-params") + if err != nil { + t.Fatalf("Failed to fetch the json-schemas. %v", err) + } + + for _, invalidParam := range invalidParams { + if err := validator.Validate(openrtb_ext.BidderSharethrough, json.RawMessage(invalidParam)); err == nil { + t.Errorf("Schema allowed unexpected params: %s", invalidParam) + } + } +} + +var validParams = []string{ + `{"pkey": "123"}`, + `{"pkey": "123", "iframe": true}`, + `{"pkey": "abc", "iframe": false}`, + `{"pkey": "abc123", "iframe": true, "iframeSize": [20, 20]}`, +} + +var invalidParams = []string{ + ``, + `null`, + `true`, + `5`, + `4.2`, + `[]`, + `{}`, + `{"pkey": 123}`, + `{"iframe": 123}`, + `{"iframeSize": [20, 20]}`, + `{"pkey": 123, "iframe": 123}`, + `{"pkey": 123, "iframe": true, "iframeSize": [20]}`, + `{"pkey": 123, "iframe": true, "iframeSize": []}`, + `{"pkey": 123, "iframe": true, "iframeSize": 123}`, +} diff --git a/adapters/sharethrough/sharethrough.go b/adapters/sharethrough/sharethrough.go new file mode 100644 index 00000000000..ea6a35cf619 --- /dev/null +++ b/adapters/sharethrough/sharethrough.go @@ -0,0 +1,72 @@ +package sharethrough + +import ( + "encoding/json" + "fmt" + "github.com/mxmCherry/openrtb" + "github.com/prebid/prebid-server/adapters" + "github.com/prebid/prebid-server/errortypes" + "github.com/prebid/prebid-server/openrtb_ext" + "net/http" + "regexp" +) + +const supplyId = "FGMrCMMc" +const strVersion = "1.0.0" + +func NewSharethroughBidder(endpoint string) *SharethroughAdapter { + return &SharethroughAdapter{ + AdServer: StrOpenRTBTranslator{ + UriHelper: StrUriHelper{BaseURI: endpoint}, + Util: Util{}, + UserAgentParsers: UserAgentParsers{ + ChromeVersion: regexp.MustCompile(`Chrome\/(?P\d+)`), + ChromeiOSVersion: regexp.MustCompile(`CriOS\/(?P\d+)`), + SafariVersion: regexp.MustCompile(`Version\/(?P\d+)`), + }, + }, + } +} + +type SharethroughAdapter struct { + AdServer StrOpenRTBInterface +} + +func (a SharethroughAdapter) MakeRequests(request *openrtb.BidRequest) ([]*adapters.RequestData, []error) { + var reqs []*adapters.RequestData + + for i := 0; i < len(request.Imp); i++ { + req, err := a.AdServer.requestFromOpenRTB(request.Imp[i], request) + + if err != nil { + return nil, []error{err} + } + reqs = append(reqs, req) + } + + // We never add to the errs slice (early return), so we just create an empty one to return + return reqs, []error{} +} + +func (a SharethroughAdapter) MakeBids(internalRequest *openrtb.BidRequest, externalRequest *adapters.RequestData, response *adapters.ResponseData) (*adapters.BidderResponse, []error) { + if response.StatusCode == http.StatusNoContent { + return nil, nil + } + + if response.StatusCode == http.StatusBadRequest { + return nil, []error{&errortypes.BadInput{ + Message: fmt.Sprintf("Unexpected status code: %d. Run with request.debug = 1 for more info", response.StatusCode), + }} + } + + if response.StatusCode != http.StatusOK { + return nil, []error{fmt.Errorf("unexpected status code: %d. Run with request.debug = 1 for more info", response.StatusCode)} + } + + var strBidResp openrtb_ext.ExtImpSharethroughResponse + if err := json.Unmarshal(response.Body, &strBidResp); err != nil { + return nil, []error{err} + } + + return a.AdServer.responseToOpenRTB(strBidResp, externalRequest) +} diff --git a/adapters/sharethrough/sharethrough_test.go b/adapters/sharethrough/sharethrough_test.go new file mode 100644 index 00000000000..b1d252d36d5 --- /dev/null +++ b/adapters/sharethrough/sharethrough_test.go @@ -0,0 +1,245 @@ +package sharethrough + +import ( + "fmt" + "github.com/mxmCherry/openrtb" + "github.com/prebid/prebid-server/adapters" + "github.com/prebid/prebid-server/errortypes" + "github.com/prebid/prebid-server/openrtb_ext" + "net/http" + "testing" +) + +type MockStrAdServer struct { + mockRequestFromOpenRTB func() (*adapters.RequestData, error) + mockResponseToOpenRTB func() (*adapters.BidderResponse, []error) + + StrOpenRTBInterface +} + +func (m MockStrAdServer) requestFromOpenRTB(imp openrtb.Imp, request *openrtb.BidRequest) (*adapters.RequestData, error) { + return m.mockRequestFromOpenRTB() +} + +func (m MockStrAdServer) responseToOpenRTB(strResp openrtb_ext.ExtImpSharethroughResponse, btlrReq *adapters.RequestData) (*adapters.BidderResponse, []error) { + return m.mockResponseToOpenRTB() +} + +type MockStrUriHelper struct { + mockBuildUri func() string + mockParseUri func() (*StrAdSeverParams, error) + + StrAdServerUriInterface +} + +func (m MockStrUriHelper) buildUri(params StrAdSeverParams) string { + return m.mockBuildUri() +} + +func (m MockStrUriHelper) parseUri(uri string) (*StrAdSeverParams, error) { + return m.mockParseUri() +} + +func TestSuccessMakeRequests(t *testing.T) { + stubReq := &adapters.RequestData{ + Method: "POST", + Uri: "http://test.com", + Body: nil, + Headers: http.Header{ + "Content-Type": []string{"text/plain;charset=utf-8"}, + "Accept": []string{"application/json"}, + }, + } + + tests := map[string]struct { + input *openrtb.BidRequest + expected []*adapters.RequestData + }{ + "Generates expected Request": { + input: &openrtb.BidRequest{ + App: &openrtb.App{Ext: []byte(`{}`)}, + Device: &openrtb.Device{ + UA: "Android Chome/60", + }, + Imp: []openrtb.Imp{{ + ID: "abc", + Ext: []byte(`{"pkey": "pkey", "iframe": true, "iframeSize": [10, 20]}`), + Banner: &openrtb.Banner{ + Format: []openrtb.Format{{H: 30, W: 40}}, + }, + }}, + }, + expected: []*adapters.RequestData{stubReq}, + }, + } + + mockAdServer := MockStrAdServer{ + mockRequestFromOpenRTB: func() (*adapters.RequestData, error) { + return stubReq, nil + }, + } + + adapter := SharethroughAdapter{AdServer: mockAdServer} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + output, actualErrors := adapter.MakeRequests(test.input) + + if len(output) != 1 { + t.Errorf("Expected one request in result, got %d\n", len(output)) + return + } + + assertRequestDataEquals(t, testName, test.expected[0], output[0]) + if len(actualErrors) != 0 { + t.Errorf("Expected no errors, got %d\n", len(actualErrors)) + } + } +} + +func TestFailureMakeRequests(t *testing.T) { + tests := map[string]struct { + input *openrtb.BidRequest + expected string + }{ + "Returns nil if failed to generate request": { + input: &openrtb.BidRequest{ + App: &openrtb.App{Ext: []byte(`{}`)}, + Device: &openrtb.Device{ + UA: "Android Chome/60", + }, + Imp: []openrtb.Imp{{ + ID: "abc", + Ext: []byte(`{"pkey": "pkey", "iframe": true, "iframeSize": [10, 20]}`), + Banner: &openrtb.Banner{ + Format: []openrtb.Format{{H: 30, W: 40}}, + }, + }}, + }, + expected: "error generating request", + }, + } + + mockAdServer := MockStrAdServer{ + mockRequestFromOpenRTB: func() (*adapters.RequestData, error) { + return nil, fmt.Errorf("error generating request") + }, + } + + adapter := SharethroughAdapter{AdServer: mockAdServer} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + output, actualErrors := adapter.MakeRequests(test.input) + + if output != nil { + t.Errorf("Expected result to be nil, got %d elements\n", len(output)) + } + if len(actualErrors) != 1 { + t.Errorf("Expected one error, got %d\n", len(actualErrors)) + } + if actualErrors[0].Error() != test.expected { + t.Errorf("Error mismatch: expected '%s' got '%s'\n", test.expected, actualErrors[0].Error()) + } + } +} + +func TestSuccessMakeBids(t *testing.T) { + stubBidderResponse := adapters.BidderResponse{} + + tests := map[string]struct { + inputResponse *adapters.ResponseData + expected *adapters.BidderResponse + }{ + "Returns nil,nil if ad server responded with no content": { + inputResponse: &adapters.ResponseData{ + StatusCode: http.StatusNoContent, + }, + expected: nil, + }, + "Generates response if ad server responded with 200": { + inputResponse: &adapters.ResponseData{ + StatusCode: http.StatusOK, + Body: []byte(`{}`), + }, + expected: &stubBidderResponse, + }, + } + + mockAdServer := MockStrAdServer{ + mockResponseToOpenRTB: func() (*adapters.BidderResponse, []error) { + return &stubBidderResponse, []error{} + }, + } + + adapter := SharethroughAdapter{AdServer: mockAdServer} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + response, errors := adapter.MakeBids(&openrtb.BidRequest{}, &adapters.RequestData{}, test.inputResponse) + if len(errors) > 0 { + t.Errorf("Expected no errors, got %d\n", len(errors)) + } + if response != test.expected { + t.Errorf("Response mismatch: expected '%+v' got '%+v'\n", test.expected, response) + } + } +} + +func TestFailureMakeBids(t *testing.T) { + tests := map[string]struct { + inputResponse *adapters.ResponseData + expected []error + }{ + "Returns BadInput error if ad server responds with BadRequest": { + inputResponse: &adapters.ResponseData{ + StatusCode: http.StatusBadRequest, + }, + expected: []error{&errortypes.BadInput{ + Message: fmt.Sprintf("Unexpected status code: %d. Run with request.debug = 1 for more info", http.StatusBadRequest), + }}, + }, + "Returns default error if ad server does not respond with Status OK": { + inputResponse: &adapters.ResponseData{ + StatusCode: http.StatusInternalServerError, + }, + expected: []error{fmt.Errorf("unexpected status code: %d. Run with request.debug = 1 for more info", http.StatusInternalServerError)}, + }, + "Returns error if failed parsing body": { + inputResponse: &adapters.ResponseData{ + StatusCode: http.StatusOK, + Body: []byte(`{ wrong json`), + }, + expected: []error{fmt.Errorf("invalid character 'w' looking for beginning of object key string")}, + }, + "Passes by errors from responseToOpenRTB": { + inputResponse: &adapters.ResponseData{ + StatusCode: http.StatusOK, + Body: []byte(`{}`), + }, + expected: []error{fmt.Errorf("failed in responseToOpenRTB")}, + }, + } + + mockAdServer := MockStrAdServer{ + mockResponseToOpenRTB: func() (*adapters.BidderResponse, []error) { + return nil, []error{fmt.Errorf("failed in responseToOpenRTB")} + }, + } + + adapter := SharethroughAdapter{AdServer: mockAdServer} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + response, errors := adapter.MakeBids(&openrtb.BidRequest{}, &adapters.RequestData{}, test.inputResponse) + if response != nil { + t.Errorf("Expected response to be nil, got %+v\n", response) + } + if len(errors) != 1 { + t.Errorf("Expected no errors, got %d\n", len(errors)) + } + if errors[0].Error() != test.expected[0].Error() { + t.Errorf("Error mismatch: expected '%s' got '%s'\n", test.expected[0].Error(), errors[0].Error()) + } + } +} diff --git a/adapters/sharethrough/sharethroughtest/params/race/banner.json b/adapters/sharethrough/sharethroughtest/params/race/banner.json new file mode 100644 index 00000000000..6702f4c2965 --- /dev/null +++ b/adapters/sharethrough/sharethroughtest/params/race/banner.json @@ -0,0 +1,5 @@ +{ + "pkey": "abc123", + "iframe": true, + "iframeSize": [50, 50] +} \ No newline at end of file diff --git a/adapters/sharethrough/sharethroughtest/params/race/native.json b/adapters/sharethrough/sharethroughtest/params/race/native.json new file mode 100644 index 00000000000..6702f4c2965 --- /dev/null +++ b/adapters/sharethrough/sharethroughtest/params/race/native.json @@ -0,0 +1,5 @@ +{ + "pkey": "abc123", + "iframe": true, + "iframeSize": [50, 50] +} \ No newline at end of file diff --git a/adapters/sharethrough/usersync.go b/adapters/sharethrough/usersync.go new file mode 100644 index 00000000000..a951fcd6a0a --- /dev/null +++ b/adapters/sharethrough/usersync.go @@ -0,0 +1,11 @@ +package sharethrough + +import ( + "github.com/prebid/prebid-server/adapters" + "github.com/prebid/prebid-server/usersync" + "text/template" +) + +func NewSharethroughSyncer(temp *template.Template) usersync.Usersyncer { + return adapters.NewSyncer("sharethrough", 80, temp, adapters.SyncTypeRedirect) +} diff --git a/adapters/sharethrough/usersync_test.go b/adapters/sharethrough/usersync_test.go new file mode 100644 index 00000000000..0cfc177f254 --- /dev/null +++ b/adapters/sharethrough/usersync_test.go @@ -0,0 +1,19 @@ +package sharethrough + +import ( + "github.com/stretchr/testify/assert" + "testing" + "text/template" +) + +func TestSharethroughSyncer(t *testing.T) { + temp := template.Must(template.New("sync-template").Parse("https://match.sharethrough.com?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}")) + syncer := NewSharethroughSyncer(temp) + syncInfo, err := syncer.GetUsersyncInfo("0", "") + assert.NoError(t, err) + assert.Equal(t, "https://match.sharethrough.com?gdpr=0&gdpr_consent=", syncInfo.URL) + assert.Equal(t, "redirect", syncInfo.Type) + assert.EqualValues(t, 80, syncer.GDPRVendorID()) + assert.Equal(t, false, syncInfo.SupportCORS) + assert.Equal(t, "sharethrough", syncer.FamilyName()) +} diff --git a/adapters/sharethrough/utils.go b/adapters/sharethrough/utils.go new file mode 100644 index 00000000000..08f0ae3ae39 --- /dev/null +++ b/adapters/sharethrough/utils.go @@ -0,0 +1,193 @@ +package sharethrough + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/buger/jsonparser" + "github.com/mxmCherry/openrtb" + "github.com/prebid/prebid-server/openrtb_ext" + "html/template" + "regexp" + "strconv" +) + +const minChromeVersion = 53 +const minSafariVersion = 10 + +type UtilityInterface interface { + gdprApplies(*openrtb.BidRequest) bool + gdprConsentString(*openrtb.BidRequest) string + + getAdMarkup(openrtb_ext.ExtImpSharethroughResponse, *StrAdSeverParams) (string, error) + getPlacementSize([]openrtb.Format) (uint64, uint64) + + canAutoPlayVideo(string, UserAgentParsers) bool + isAndroid(string) bool + isiOS(string) bool + isAtMinChromeVersion(string, *regexp.Regexp) bool + isAtMinSafariVersion(string, *regexp.Regexp) bool +} + +type Util struct{} + +func (u Util) getAdMarkup(strResp openrtb_ext.ExtImpSharethroughResponse, params *StrAdSeverParams) (string, error) { + strRespId := fmt.Sprintf("str_response_%s", strResp.BidID) + jsonPayload, err := json.Marshal(strResp) + if err != nil { + return "", err + } + + tmplBody := ` + + +
+ + ` + + if params.Iframe { + tmplBody = tmplBody + ` + + ` + } else { + tmplBody = tmplBody + ` + + + ` + } + + tmpl, err := template.New("sfpjs").Parse(tmplBody) + if err != nil { + return "", err + } + + var buf []byte + templatedBuf := bytes.NewBuffer(buf) + + b64EncodedJson := base64.StdEncoding.EncodeToString(jsonPayload) + err = tmpl.Execute(templatedBuf, struct { + Arid template.JS + Pkey string + StrRespId template.JS + B64EncodedJson string + }{ + template.JS(strResp.AdServerRequestID), + params.Pkey, + template.JS(strRespId), + b64EncodedJson, + }) + if err != nil { + return "", err + } + + return templatedBuf.String(), nil +} + +func (u Util) getPlacementSize(formats []openrtb.Format) (height uint64, width uint64) { + biggest := struct { + Height uint64 + Width uint64 + }{ + Height: 1, + Width: 1, + } + + for i := 0; i < len(formats); i++ { + format := formats[i] + if (format.H * format.W) > (biggest.Height * biggest.Width) { + biggest.Height = format.H + biggest.Width = format.W + } + } + + return biggest.Height, biggest.Width +} + +func (u Util) canAutoPlayVideo(userAgent string, parsers UserAgentParsers) bool { + if u.isAndroid(userAgent) { + return u.isAtMinChromeVersion(userAgent, parsers.ChromeVersion) + } else if u.isiOS(userAgent) { + return u.isAtMinSafariVersion(userAgent, parsers.SafariVersion) || u.isAtMinChromeVersion(userAgent, parsers.ChromeiOSVersion) + } + return true +} + +func (u Util) isAndroid(userAgent string) bool { + isAndroid, err := regexp.MatchString("(?i)Android", userAgent) + if err != nil { + return false + } + return isAndroid +} + +func (u Util) isiOS(userAgent string) bool { + isiOS, err := regexp.MatchString("(?i)iPhone|iPad|iPod", userAgent) + if err != nil { + return false + } + return isiOS +} + +func (u Util) isAtMinVersion(userAgent string, versionParser *regexp.Regexp, minVersion int64) bool { + var version int64 + var err error + + versionMatch := versionParser.FindStringSubmatch(userAgent) + if len(versionMatch) > 1 { + version, err = strconv.ParseInt(versionMatch[1], 10, 64) + } + if err != nil { + return false + } + + return version >= minVersion +} + +func (u Util) isAtMinChromeVersion(userAgent string, parser *regexp.Regexp) bool { + return u.isAtMinVersion(userAgent, parser, minChromeVersion) +} + +func (u Util) isAtMinSafariVersion(userAgent string, parser *regexp.Regexp) bool { + return u.isAtMinVersion(userAgent, parser, minSafariVersion) +} + +func (u Util) gdprApplies(request *openrtb.BidRequest) bool { + var gdprApplies int64 + + if request.Regs != nil { + if jsonExtRegs, err := request.Regs.Ext.MarshalJSON(); err == nil { + // 0 is the return value if error, so no need to handle + gdprApplies, _ = jsonparser.GetInt(jsonExtRegs, "gdpr") + } + } + + return gdprApplies != 0 +} + +func (u Util) gdprConsentString(request *openrtb.BidRequest) string { + var consentString string + + if request.User != nil { + if jsonExtUser, err := request.User.Ext.MarshalJSON(); err == nil { + // empty string is the return value if error, so no need to handle + consentString, _ = jsonparser.GetString(jsonExtUser, "consent") + } + } + + return consentString +} diff --git a/adapters/sharethrough/utils_test.go b/adapters/sharethrough/utils_test.go new file mode 100644 index 00000000000..a5ee882707e --- /dev/null +++ b/adapters/sharethrough/utils_test.go @@ -0,0 +1,410 @@ +package sharethrough + +import ( + "github.com/mxmCherry/openrtb" + "github.com/prebid/prebid-server/openrtb_ext" + "regexp" + "strings" + "testing" +) + +func TestGetAdMarkup(t *testing.T) { + tests := map[string]struct { + inputResponse openrtb_ext.ExtImpSharethroughResponse + inputParams *StrAdSeverParams + expectedSuccess []string + expectedError error + }{ + "Sets template variables": { + inputResponse: openrtb_ext.ExtImpSharethroughResponse{BidID: "bid", AdServerRequestID: "arid"}, + inputParams: &StrAdSeverParams{Pkey: "pkey"}, + expectedSuccess: []string{ + ``, + `
`, + ``, + }, + expectedError: nil, + }, + "Includes sfp.js without iFrame busting logic if iFrame param is true": { + inputResponse: openrtb_ext.ExtImpSharethroughResponse{BidID: "bid", AdServerRequestID: "arid"}, + inputParams: &StrAdSeverParams{Pkey: "pkey", Iframe: true}, + expectedSuccess: []string{ + ``, + }, + expectedError: nil, + }, + "Includes sfp.js with iFrame busting logic if iFrame param is false": { + inputResponse: openrtb_ext.ExtImpSharethroughResponse{BidID: "bid", AdServerRequestID: "arid"}, + inputParams: &StrAdSeverParams{Pkey: "pkey", Iframe: false}, + expectedSuccess: []string{ + ``, + }, + expectedError: nil, + }, + "Includes sfp.js with iFrame busting logic if iFrame param is not provided": { + inputResponse: openrtb_ext.ExtImpSharethroughResponse{BidID: "bid", AdServerRequestID: "arid"}, + inputParams: &StrAdSeverParams{Pkey: "pkey"}, + expectedSuccess: []string{ + ``, + }, + expectedError: nil, + }, + } + + util := Util{} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + outputSuccess, outputError := util.getAdMarkup(test.inputResponse, test.inputParams) + for _, markup := range test.expectedSuccess { + if !strings.Contains(outputSuccess, markup) { + t.Errorf("Expected Ad Markup to contain: %s, got %s\n", markup, outputSuccess) + } + } + if outputError != test.expectedError { + t.Errorf("Expected Error to be: %s, got %s\n", test.expectedError, outputError) + } + } +} + +func TestGetPlacementSize(t *testing.T) { + tests := map[string]struct { + input []openrtb.Format + expectedHeight uint64 + expectedWidth uint64 + }{ + "Returns default size if empty input": { + input: []openrtb.Format{}, + expectedHeight: 1, + expectedWidth: 1, + }, + "Returns size if only one is passed": { + input: []openrtb.Format{{H: 100, W: 100}}, + expectedHeight: 100, + expectedWidth: 100, + }, + "Returns biggest size if multiple are passed": { + input: []openrtb.Format{{H: 100, W: 100}, {H: 200, W: 200}, {H: 50, W: 50}}, + expectedHeight: 200, + expectedWidth: 200, + }, + } + + util := Util{} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + outputHeight, outputWidth := util.getPlacementSize(test.input) + if outputHeight != test.expectedHeight { + t.Errorf("Expected Height: %d, got %d\n", test.expectedHeight, outputHeight) + } + if outputWidth != test.expectedWidth { + t.Errorf("Expected Width: %d, got %d\n", test.expectedWidth, outputWidth) + } + } +} + +type userAgentTest struct { + input string + expected bool +} + +func runUserAgentTests(tests map[string]userAgentTest, fn func(string) bool, t *testing.T) { + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + output := fn(test.input) + if output != test.expected { + t.Errorf("Expected: %t, got %t\n", test.expected, output) + } + } +} + +func TestCanAutoPlayVideo(t *testing.T) { + uaParsers := UserAgentParsers{ + ChromeVersion: regexp.MustCompile(`Chrome\/(?P\d+)`), + ChromeiOSVersion: regexp.MustCompile(`CriOS\/(?P\d+)`), + SafariVersion: regexp.MustCompile(`Version\/(?P\d+)`), + } + + ableAgents := map[string]string{ + "Android at min Chrome version": "Android Chrome/60.0", + "iOS at min Chrome version": "iPhone CriOS/60.0", + "iOS at min Safari version": "iPad Version/14.0", + "Neither Android or iOS": "Some User Agent", + } + unableAgents := map[string]string{ + "Android not at min Chrome version": "Android Chrome/12", + "iOS not at min Chrome version": "iPod Chrome/12", + "iOS not at min Safari version": "iPod Version/8", + } + + tests := map[string]userAgentTest{} + for testName, agent := range ableAgents { + tests[testName] = userAgentTest{ + input: agent, + expected: true, + } + } + for testName, agent := range unableAgents { + tests[testName] = userAgentTest{ + input: agent, + expected: false, + } + } + + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + output := Util{}.canAutoPlayVideo(test.input, uaParsers) + if output != test.expected { + t.Errorf("Expected: %t, got %t\n", test.expected, output) + } + } +} + +func TestIsAndroid(t *testing.T) { + goodUserAgent := "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 6P Build/MMB29P)" + badUserAgent := "fake user agent" + + // This is an alternate way to do testing if you have many test cases that only change the input and output + tests := map[string]userAgentTest{ + "Match the Android user agent": { + input: goodUserAgent, + expected: true, + }, + "Does not match Android user agent": { + input: badUserAgent, + expected: false, + }, + } + + runUserAgentTests(tests, Util{}.isAndroid, t) +} + +func TestIsiOS(t *testing.T) { + iPhoneUserAgent := "Some string containing iPhone" + iPadUserAgent := "Some string containing iPad" + iPodUserAgent := "Some string containing iPOD" + badUserAgent := "Fake User Agent" + + tests := map[string]userAgentTest{ + "Match the iPhone user agent": { + input: iPhoneUserAgent, + expected: true, + }, + "Match the iPad user agent": { + input: iPadUserAgent, + expected: true, + }, + "Match the iPod user agent": { + input: iPodUserAgent, + expected: true, + }, + "Does not match Android user agent": { + input: badUserAgent, + expected: false, + }, + } + + runUserAgentTests(tests, Util{}.isiOS, t) +} + +func TestIsAtMinChromeVersion(t *testing.T) { + regex := regexp.MustCompile(`Chrome\/(?P\d+)`) + v60ChromeUA := "Mozilla/5.0 Chrome/60.0.3112.113" + v12ChromeUA := "Mozilla/5.0 Chrome/12.0.3112.113" + badUA := "Fake User Agent" + + tests := map[string]userAgentTest{ + "Return true if greater than min (53)": { + input: v60ChromeUA, + expected: true, + }, + "Return false if lower than min (53)": { + input: v12ChromeUA, + expected: false, + }, + "Return false if no version found": { + input: badUA, + expected: false, + }, + } + + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + output := Util{}.isAtMinChromeVersion(test.input, regex) + if output != test.expected { + t.Errorf("Expected: %t, got %t\n", test.expected, output) + } + } +} + +func TestIsAtMinChromeIosVersion(t *testing.T) { + regex := regexp.MustCompile(`CriOS\/(?P\d+)`) + v60ChrIosUA := "Mozilla/5.0 CriOS/60.0.3112.113" + v12ChrIosUA := "Mozilla/5.0 CriOS/12.0.3112.113" + badUA := "Fake User Agent" + + tests := map[string]userAgentTest{ + "Return true if greater than min (53)": { + input: v60ChrIosUA, + expected: true, + }, + "Return false if lower than min (53)": { + input: v12ChrIosUA, + expected: false, + }, + "Return false if no version found": { + input: badUA, + expected: false, + }, + } + + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + output := Util{}.isAtMinChromeVersion(test.input, regex) + if output != test.expected { + t.Errorf("Expected: %t, got %t\n", test.expected, output) + } + } +} + +func TestIsAtMinSafariVersion(t *testing.T) { + regex := regexp.MustCompile(`Version\/(?P\d+)`) + v12SafariUA := "Mozilla/5.0 Version/12.0.3112.113" + v07SafariUA := "Mozilla/5.0 Version/07.0.3112.113" + badUA := "Fake User Agent" + + tests := map[string]userAgentTest{ + "Return true if greater than min (10)": { + input: v12SafariUA, + expected: true, + }, + "Return false if lower than min (10)": { + input: v07SafariUA, + expected: false, + }, + "Return false if no version found": { + input: badUA, + expected: false, + }, + } + + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + output := Util{}.isAtMinSafariVersion(test.input, regex) + if output != test.expected { + t.Errorf("Expected: %t, got %t\n", test.expected, output) + } + } +} + +func TestGdprApplies(t *testing.T) { + bidRequestGdpr := openrtb.BidRequest{ + Regs: &openrtb.Regs{ + Ext: []byte(`{"gdpr": 1}`), + }, + } + bidRequestNonGdpr := openrtb.BidRequest{ + Regs: &openrtb.Regs{ + Ext: []byte(`{"gdpr": 0}`), + }, + } + bidRequestEmptyGdpr := openrtb.BidRequest{ + Regs: &openrtb.Regs{ + Ext: []byte(``), + }, + } + bidRequestEmptyRegs := openrtb.BidRequest{ + Regs: &openrtb.Regs{}, + } + + tests := map[string]struct { + input *openrtb.BidRequest + expected bool + }{ + "Return true if gdpr set to 1": { + input: &bidRequestGdpr, + expected: true, + }, + "Return false if gdpr set to 0": { + input: &bidRequestNonGdpr, + expected: false, + }, + "Return false if no gdpr set": { + input: &bidRequestEmptyGdpr, + expected: false, + }, + "Return false if no Regs set": { + input: &bidRequestEmptyRegs, + expected: false, + }, + } + + util := Util{} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + output := util.gdprApplies(test.input) + if output != test.expected { + t.Errorf("Expected: %t, got %t\n", test.expected, output) + } + } +} + +func TestGdprConsentString(t *testing.T) { + bidRequestWithConsent := openrtb.BidRequest{ + User: &openrtb.User{ + Ext: []byte(`{"consent": "abc"}`), + }, + } + bidRequestWithEmptyConsent := openrtb.BidRequest{ + User: &openrtb.User{ + Ext: []byte(`{"consent": ""}`), + }, + } + bidRequestWithoutConsent := openrtb.BidRequest{ + User: &openrtb.User{ + Ext: []byte(`{"other": "abc"}`), + }, + } + bidRequestWithUserExt := openrtb.BidRequest{ + User: &openrtb.User{}, + } + + tests := map[string]struct { + input *openrtb.BidRequest + expected string + }{ + "Return consent string if provided": { + input: &bidRequestWithConsent, + expected: "abc", + }, + "Return empty string if consent string empty": { + input: &bidRequestWithEmptyConsent, + expected: "", + }, + "Return empty string if no consent string provided": { + input: &bidRequestWithoutConsent, + expected: "", + }, + "Return empty string if User set": { + input: &bidRequestWithUserExt, + expected: "", + }, + } + + util := Util{} + for testName, test := range tests { + t.Logf("Test case: %s\n", testName) + + output := util.gdprConsentString(test.input) + if output != test.expected { + t.Errorf("Expected: %s, got %s\n", test.expected, output) + } + } +} diff --git a/config/config.go b/config/config.go index 655ef0b470a..d0f30d1c5d1 100644 --- a/config/config.go +++ b/config/config.go @@ -439,6 +439,7 @@ func (cfg *Configuration) setDerivedDefaults() { setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderPulsepoint, "https://bh.contextweb.com/rtset?pid=561205&ev=1&rurl="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dpulsepoint%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%25%25VGUID%25%25") setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderRhythmone, "https://sync.1rx.io/usersync2/rmphb?gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&redir="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Drhythmone%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%5BRX_UUID%5D") // openrtb_ext.BidderRubicon doesn't have a good default. + setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderSharethrough, "https://sharethrough.adnxs.com/getuid?"+url.QueryEscape(externalURL)+"/setuid?bidder=sharethrough&gdpr={{.GDPR}}&gdpr_consent={{.GDPRConsent}}&uid=$UID") setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderSomoaudience, "https://publisher-east.mobileadtrading.com/usersync?ru="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dsomoaudience%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%24%7BUID%7D") setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderSovrn, "https://ap.lijit.com/pixel?redir="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dsovrn%26gdpr%3D{{.GDPR}}%26gdpr_consent%3D{{.GDPRConsent}}%26uid%3D%24UID") setDefaultUsersync(cfg.Adapters, openrtb_ext.BidderSonobi, "https://sync.go.sonobi.com/us.gif?loc="+url.QueryEscape(externalURL)+"%2Fsetuid%3Fbidder%3Dsonobi%26consent_string%3D{{.GDPR}}%26gdpr%3D{{.GDPRConsent}}%26uid%3D%5BUID%5D") @@ -597,6 +598,7 @@ func SetupViper(v *viper.Viper, filename string) { v.SetDefault("adapters.rhythmone.endpoint", "http://tag.1rx.io/rmp") v.SetDefault("adapters.gumgum.endpoint", "https://g2.gumgum.com/providers/prbds2s/bid") v.SetDefault("adapters.grid.endpoint", "http://grid.bidswitch.net/sp_bid?sp=prebid") + v.SetDefault("adapters.sharethrough.endpoint", "http://btlr.sharethrough.com/FGMrCMMc/v1") v.SetDefault("adapters.sonobi.endpoint", "https://apex.go.sonobi.com/prebid?partnerid=71d9d3d8af") v.SetDefault("adapters.yieldmo.endpoint", "http://ads.yieldmo.com/exchange/prebid-server") v.SetDefault("adapters.gamoshi.endpoint", "https://rtb.gamoshi.io") diff --git a/exchange/adapter_map.go b/exchange/adapter_map.go index 61d3cf16b42..ba0a961792f 100644 --- a/exchange/adapter_map.go +++ b/exchange/adapter_map.go @@ -28,6 +28,7 @@ import ( "github.com/prebid/prebid-server/adapters/pulsepoint" "github.com/prebid/prebid-server/adapters/rhythmone" "github.com/prebid/prebid-server/adapters/rubicon" + "github.com/prebid/prebid-server/adapters/sharethrough" "github.com/prebid/prebid-server/adapters/somoaudience" "github.com/prebid/prebid-server/adapters/sonobi" "github.com/prebid/prebid-server/adapters/sovrn" @@ -60,6 +61,7 @@ func newAdapterMap(client *http.Client, cfg *config.Configuration, infos adapter cfg.Adapters[string(openrtb_ext.BidderRubicon)].XAPI.Username, cfg.Adapters[string(openrtb_ext.BidderRubicon)].XAPI.Password, cfg.Adapters[string(openrtb_ext.BidderRubicon)].XAPI.Tracker), + openrtb_ext.BidderSharethrough: sharethrough.NewSharethroughBidder(cfg.Adapters[string(openrtb_ext.BidderSharethrough)].Endpoint), openrtb_ext.BidderSomoaudience: somoaudience.NewSomoaudienceBidder(cfg.Adapters[string(openrtb_ext.BidderSomoaudience)].Endpoint), openrtb_ext.BidderSovrn: sovrn.NewSovrnBidder(client, cfg.Adapters[string(openrtb_ext.BidderSovrn)].Endpoint), openrtb_ext.Bidder33Across: ttx.New33AcrossBidder(cfg.Adapters[string(openrtb_ext.Bidder33Across)].Endpoint), diff --git a/openrtb_ext/bidders.go b/openrtb_ext/bidders.go index 6f8bcffa7f7..41320f7f7e7 100644 --- a/openrtb_ext/bidders.go +++ b/openrtb_ext/bidders.go @@ -42,6 +42,7 @@ const ( BidderPulsepoint BidderName = "pulsepoint" BidderRhythmone BidderName = "rhythmone" BidderRubicon BidderName = "rubicon" + BidderSharethrough BidderName = "sharethrough" BidderSomoaudience BidderName = "somoaudience" BidderSovrn BidderName = "sovrn" BidderSonobi BidderName = "sonobi" @@ -72,6 +73,7 @@ var BidderMap = map[string]BidderName{ "pulsepoint": BidderPulsepoint, "rhythmone": BidderRhythmone, "rubicon": BidderRubicon, + "sharethrough": BidderSharethrough, "somoaudience": BidderSomoaudience, "sovrn": BidderSovrn, "sonobi": BidderSonobi, diff --git a/openrtb_ext/imp_sharethrough.go b/openrtb_ext/imp_sharethrough.go new file mode 100644 index 00000000000..21c6b4fd140 --- /dev/null +++ b/openrtb_ext/imp_sharethrough.go @@ -0,0 +1,102 @@ +package openrtb_ext + +import "encoding/json" + +type ExtImpSharethrough struct { + PlacementKey string `json:"pkey"` + Iframe bool `json:"iframe"` +} + +// ExtImpSharethrough defines the contract for bidrequest.imp[i].ext.sharethrough +type ExtImpSharethroughResponse struct { + AdServerRequestID string `json:"adserverRequestId"` + BidID string `json:"bidId"` + CookieSyncUrls []string `json:"cookieSyncUrls"` + Creatives []ExtImpSharethroughCreative `json:"creatives"` + Placement ExtImpSharethroughPlacement `json:"placement"` + StxUserID string `json:"stxUserId"` +} +type ExtImpSharethroughCreative struct { + AuctionWinID string `json:"auctionWinId"` + CPM float64 `json:"cpm"` + Metadata ExtImpSharethroughCreativeMetadata `json:"creative"` + Version int `json:"version"` +} + +type ExtImpSharethroughCreativeMetadata struct { + Action string `json:"action"` + Advertiser string `json:"advertiser"` + AdvertiserKey string `json:"advertiser_key"` + Beacons ExtImpSharethroughCreativeBeacons `json:"beacons"` + BrandLogoURL string `json:"brand_logo_url"` + CampaignKey string `json:"campaign_key"` + CreativeKey string `json:"creative_key"` + CustomEngagementAction string `json:"custom_engagement_action"` + CustomEngagementLabel string `json:"custom_engagement_label"` + CustomEngagementURL string `json:"custom_engagement_url"` + DealID string `json:"deal_id"` + Description string `json:"description"` + ForceClickToPlay bool `json:"force_click_to_play"` + IconURL string `json:"icon_url"` + ImpressionHTML string `json:"impression_html"` + InstantPlayMobileCount int `json:"instant_play_mobile_count"` + InstantPlayMobileURL string `json:"instant_play_mobile_url"` + MediaURL string `json:"media_url"` + ShareURL string `json:"share_url"` + SourceID string `json:"source_id"` + ThumbnailURL string `json:"thumbnail_url"` + Title string `json:"title"` + VariantKey string `json:"variant_key"` +} + +type ExtImpSharethroughCreativeBeacons struct { + Click []string `json:"click"` + Impression []string `json:"impression"` + Play []string `json:"play"` + Visible []string `json:"visible"` + WinNotification []string `json:"win-notification"` +} + +type ExtImpSharethroughPlacement struct { + AllowInstantPlay bool `json:"allow_instant_play"` + ArticlesBeforeFirstAd int `json:"articles_before_first_ad"` + ArticlesBetweenAds int `json:"articles_between_ads"` + Layout string `json:"layout"` + Metadata json.RawMessage `json:"metadata"` + PlacementAttributes ExtImpSharethroughPlacementAttributes `json:"placementAttributes"` + Status string `json:"status"` +} + +type ExtImpSharethroughPlacementThirdPartyPartner struct { + Key string `json:"key"` + Tag string `json:"tag"` +} + +type ExtImpSharethroughPlacementAttributes struct { + AdServerKey string `json:"ad_server_key"` + AdServerPath string `json:"ad_server_path"` + AllowDynamicCropping bool `json:"allow_dynamic_cropping"` + AppThirdPartyPartners []string `json:"app_third_party_partners"` + CustomCardCSS string `json:"custom_card_css"` + DFPPath string `json:"dfp_path"` + DirectSellPromotedByText string `json:"direct_sell_promoted_by_text"` + Domain string `json:"domain"` + EnableLinkRedirection bool `json:"enable_link_redirection"` + FeaturedContent json.RawMessage `json:"featured_content"` + MaxHeadlineLength int `json:"max_headline_length"` + MultiAdPlacement bool `json:"multi_ad_placement"` + PromotedByText string `json:"promoted_by_text"` + PublisherKey string `json:"publisher_key"` + RenderingPixelOffset int `json:"rendering_pixel_offset"` + SafeFrameSize []int `json:"safe_frame_size"` + SiteKey string `json:"site_key"` + StrOptOutURL string `json:"str_opt_out_url"` + Template string `json:"template"` + ThirdPartyPartners []ExtImpSharethroughPlacementThirdPartyPartner `json:"third_party_partners"` +} + +type ExtImpSharethroughExt struct { + Pkey string `json:"pkey"` + Iframe bool `json:"iframe"` + IframeSize []int `json:"iframeSize"` +} diff --git a/static/bidder-info/sharethrough.yaml b/static/bidder-info/sharethrough.yaml new file mode 100644 index 00000000000..09530be508c --- /dev/null +++ b/static/bidder-info/sharethrough.yaml @@ -0,0 +1,11 @@ +maintainer: + email: "pubgrowth.engineering@sharethrough.com" +capabilities: + app: + mediaTypes: + - native + - banner + site: + mediaTypes: + - native + - banner diff --git a/static/bidder-params/sharethrough.json b/static/bidder-params/sharethrough.json new file mode 100644 index 00000000000..03f4ec293ec --- /dev/null +++ b/static/bidder-params/sharethrough.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Sharethrough Adapter Params", + "description": "A schema which validates params accepted by the Sharethrough adapter", + "type": "object", + "properties": { + "pkey": { + "type": "string", + "description": "placement key to use." + }, + "iframe": { + "type": "boolean", + "description": "whether or not to stay in iframe", + "default": false + }, + "iframeSize": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "integer" + }, + "description": "iframe dimensions", + "default": [0, 0] + } + }, + "required": ["pkey"] +} diff --git a/usersync/usersyncers/syncer.go b/usersync/usersyncers/syncer.go index a0a69e7fb7e..6654c3ba695 100644 --- a/usersync/usersyncers/syncer.go +++ b/usersync/usersyncers/syncer.go @@ -2,6 +2,7 @@ package usersyncers import ( "github.com/prebid/prebid-server/adapters/gamoshi" + "github.com/prebid/prebid-server/adapters/sharethrough" "strings" "text/template" @@ -63,6 +64,7 @@ func NewSyncerMap(cfg *config.Configuration) map[openrtb_ext.BidderName]usersync insertIntoMap(cfg, syncers, openrtb_ext.BidderPulsepoint, pulsepoint.NewPulsepointSyncer) insertIntoMap(cfg, syncers, openrtb_ext.BidderRhythmone, rhythmone.NewRhythmoneSyncer) insertIntoMap(cfg, syncers, openrtb_ext.BidderRubicon, rubicon.NewRubiconSyncer) + insertIntoMap(cfg, syncers, openrtb_ext.BidderSharethrough, sharethrough.NewSharethroughSyncer) insertIntoMap(cfg, syncers, openrtb_ext.BidderSomoaudience, somoaudience.NewSomoaudienceSyncer) insertIntoMap(cfg, syncers, openrtb_ext.BidderSovrn, sovrn.NewSovrnSyncer) insertIntoMap(cfg, syncers, openrtb_ext.BidderSonobi, sonobi.NewSonobiSyncer) diff --git a/usersync/usersyncers/syncer_test.go b/usersync/usersyncers/syncer_test.go index bb42b8622ac..7e44ed34f33 100644 --- a/usersync/usersyncers/syncer_test.go +++ b/usersync/usersyncers/syncer_test.go @@ -34,6 +34,7 @@ func TestNewSyncerMap(t *testing.T) { string(openrtb_ext.BidderPulsepoint): syncConfig, string(openrtb_ext.BidderRhythmone): syncConfig, string(openrtb_ext.BidderRubicon): syncConfig, + string(openrtb_ext.BidderSharethrough): syncConfig, string(openrtb_ext.BidderSomoaudience): syncConfig, string(openrtb_ext.BidderSovrn): syncConfig, string(openrtb_ext.Bidder33Across): syncConfig,