diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go index 20acc2aedd3..3fd2132143e 100644 --- a/endpoints/openrtb2/auction.go +++ b/endpoints/openrtb2/auction.go @@ -290,6 +290,10 @@ func (deps *endpointDeps) validateRequest(req *openrtb.BidRequest) []error { if err := validateBidAdjustmentFactors(bidExt.Prebid.BidAdjustmentFactors, aliases); err != nil { return []error{err} } + + if err := validateSChains(bidExt); err != nil { + return []error{err} + } } if (req.Site == nil && req.App == nil) || (req.Site != nil && req.App != nil) { @@ -362,6 +366,11 @@ func validateBidAdjustmentFactors(adjustmentFactors map[string]float64, aliases return nil } +func validateSChains(req *openrtb_ext.ExtRequest) error { + _, err := exchange.BidderToPrebidSChains(req) + return err +} + func (deps *endpointDeps) validateImp(imp *openrtb.Imp, aliases map[string]string, index int) []error { if imp.ID == "" { return []error{fmt.Errorf("request.imp[%d] missing required field: \"id\"", index)} diff --git a/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go index 97f0038a392..c697c206483 100644 --- a/endpoints/openrtb2/auction_test.go +++ b/endpoints/openrtb2/auction_test.go @@ -1039,6 +1039,53 @@ func TestCCPAInvalid(t *testing.T) { assert.Empty(t, req.Regs.Ext, "Invalid Consent Removed From Request") } +func TestSChainInvalid(t *testing.T) { + deps := &endpointDeps{ + &nobidExchange{}, + newParamsValidator(t), + &mockStoredReqFetcher{}, + empty_fetcher.EmptyFetcher{}, + empty_fetcher.EmptyFetcher{}, + &config.Configuration{}, + pbsmetrics.NewMetrics(metrics.NewRegistry(), openrtb_ext.BidderList(), config.DisabledMetrics{}), + analyticsConf.NewPBSAnalytics(&config.Analytics{}), + map[string]string{}, + false, + []byte{}, + openrtb_ext.BidderMap, + nil, + nil, + hardcodedResponseIPValidator{response: true}, + } + + ui := uint64(1) + req := openrtb.BidRequest{ + ID: "someID", + Imp: []openrtb.Imp{ + { + ID: "imp-ID", + Banner: &openrtb.Banner{ + W: &ui, + H: &ui, + }, + Ext: json.RawMessage(`{"appnexus": {"placementId": 5667}}`), + }, + }, + Site: &openrtb.Site{ + ID: "myID", + }, + Regs: &openrtb.Regs{ + Ext: json.RawMessage(`{"us_privacy":"abcd"}`), + }, + Ext: json.RawMessage(`{"prebid":{"schains":[{"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller1.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}, {"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller2.com","sid":"00002","rid":"BidRequest2","hp":1}],"ver":"1.0"}}]}}`), + } + + errL := deps.validateRequest(&req) + + expectedError := fmt.Errorf("request.ext.prebid.schains contains multiple schains for bidder appnexus; it must contain no more than one per bidder.") + assert.ElementsMatch(t, errL, []error{expectedError}) +} + func TestSanitizeRequest(t *testing.T) { testCases := []struct { description string diff --git a/exchange/utils.go b/exchange/utils.go index 96c00ec0e36..4de985eca40 100644 --- a/exchange/utils.go +++ b/exchange/utils.go @@ -28,6 +28,29 @@ type cleanMetrics struct { gdprTcfVersion int } +func BidderToPrebidSChains(req *openrtb_ext.ExtRequest) (map[string]*openrtb_ext.ExtRequestPrebidSChainSChain, error) { + bidderToSChains := make(map[string]*openrtb_ext.ExtRequestPrebidSChainSChain) + + if len(req.Prebid.SChains) == 0 { + return bidderToSChains, nil + } + + for _, schainWrapper := range req.Prebid.SChains { + if schainWrapper != nil && len(schainWrapper.Bidders) > 0 { + for _, bidder := range schainWrapper.Bidders { + if _, present := bidderToSChains[bidder]; present { + return nil, fmt.Errorf("request.ext.prebid.schains contains multiple schains for bidder %s; "+ + "it must contain no more than one per bidder.", bidder) + } else { + bidderToSChains[bidder] = &schainWrapper.SChain + } + } + } + } + + return bidderToSChains, nil +} + // cleanOpenRTBRequests splits the input request into requests which are sanitized for each bidder. Intended behavior is: // // 1. BidRequest.Imp[].Ext will only contain the "prebid" field and a "bidder" field which has the params for the intended Bidder. @@ -103,12 +126,35 @@ func cleanOpenRTBRequests(ctx context.Context, return } -func splitBidRequest(req *openrtb.BidRequest, impsByBidder map[string][]openrtb.Imp, aliases map[string]string, usersyncs IdFetcher, blabels map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels, labels pbsmetrics.Labels) (map[openrtb_ext.BidderName]*openrtb.BidRequest, []error) { +func splitBidRequest(req *openrtb.BidRequest, + impsByBidder map[string][]openrtb.Imp, + aliases map[string]string, + usersyncs IdFetcher, + blabels map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels, + labels pbsmetrics.Labels) (map[openrtb_ext.BidderName]*openrtb.BidRequest, []error) { + requestsByBidder := make(map[openrtb_ext.BidderName]*openrtb.BidRequest, len(impsByBidder)) explicitBuyerUIDs, err := extractBuyerUIDs(req.User) if err != nil { return nil, []error{err} } + + var requestExt openrtb_ext.ExtRequest + var sChainsByBidder map[string]*openrtb_ext.ExtRequestPrebidSChainSChain + if len(req.Ext) > 0 { + err := json.Unmarshal(req.Ext, &requestExt) + if err != nil { + return nil, []error{err} + } + + sChainsByBidder, err = BidderToPrebidSChains(&requestExt) + if err != nil { + return nil, []error{err} + } + } else { + sChainsByBidder = make(map[string]*openrtb_ext.ExtRequestPrebidSChainSChain) + } + for bidder, imps := range impsByBidder { reqCopy := *req coreBidder := resolveBidder(bidder, aliases) @@ -128,11 +174,57 @@ func splitBidRequest(req *openrtb.BidRequest, impsByBidder map[string][]openrtb. blabels[coreBidder].CookieFlag = pbsmetrics.CookieFlagYes } reqCopy.Imp = imps + + prepareSource(&reqCopy, bidder, sChainsByBidder) + prepareExt(&reqCopy, &requestExt) + requestsByBidder[openrtb_ext.BidderName(bidder)] = &reqCopy } return requestsByBidder, nil } +func prepareExt(req *openrtb.BidRequest, unpackedExt *openrtb_ext.ExtRequest) { + if len(req.Ext) == 0 { + return + } + extCopy := *unpackedExt + extCopy.Prebid.SChains = nil + reqExt, err := json.Marshal(extCopy) + if err == nil { + req.Ext = reqExt + } +} + +func prepareSource(req *openrtb.BidRequest, bidder string, sChainsByBidder map[string]*openrtb_ext.ExtRequestPrebidSChainSChain) { + const sChainWildCard = "*" + var selectedSChain *openrtb_ext.ExtRequestPrebidSChainSChain + + wildCardSChain := sChainsByBidder[sChainWildCard] + bidderSChain := sChainsByBidder[bidder] + + // source should not be modified + if bidderSChain == nil && wildCardSChain == nil { + return + } + + if bidderSChain != nil { + selectedSChain = bidderSChain + } else { + selectedSChain = wildCardSChain + } + + // set source + var source openrtb.Source + schain := openrtb_ext.ExtRequestPrebidSChain{ + SChain: *selectedSChain, + } + sourceExt, err := json.Marshal(schain) + if err == nil { + source.Ext = sourceExt + req.Source = &source + } +} + // extractBuyerUIDs parses the values from user.ext.prebid.buyeruids, and then deletes those values from the ext. // This prevents a Bidder from using these values to figure out who else is involved in the Auction. func extractBuyerUIDs(user *openrtb.User) (map[string]string, error) { diff --git a/exchange/utils_test.go b/exchange/utils_test.go index e50d0f777f0..6d66e816e7b 100644 --- a/exchange/utils_test.go +++ b/exchange/utils_test.go @@ -135,6 +135,92 @@ func TestCleanOpenRTBRequestsCCPA(t *testing.T) { } } +func TestCleanOpenRTBRequestsSChain(t *testing.T) { + testCases := []struct { + description string + inSourceExt json.RawMessage + inExt json.RawMessage + outSourceExt json.RawMessage + outExt json.RawMessage + hasError bool + }{ + { + description: "Empty root ext and source ext", + inSourceExt: json.RawMessage(``), + inExt: json.RawMessage(``), + outSourceExt: json.RawMessage(``), + outExt: json.RawMessage(``), + hasError: false, + }, + { + description: "No schains in root ext and empty source ext", + inSourceExt: json.RawMessage(``), + inExt: json.RawMessage(`{"prebid":{"schains":[]}}`), + outSourceExt: json.RawMessage(``), + outExt: json.RawMessage(`{"prebid":{}}`), + hasError: false, + }, + { + description: "Use source schain -- no bidder schain or wildcard schain in ext.prebid.schains", + inSourceExt: json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"example.com","sid":"example1","rid":"ExampleReq1","hp":1}],"ver":"1.0"}}`), + inExt: json.RawMessage(`{"prebid":{"schains":[{"bidders":["bidder1"],"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}]}}`), + outSourceExt: json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"example.com","sid":"example1","rid":"ExampleReq1","hp":1}],"ver":"1.0"}}`), + outExt: json.RawMessage(`{"prebid":{}}`), + hasError: false, + }, + { + description: "Use schain for bidder in ext.prebid.schains", + inSourceExt: json.RawMessage(``), + inExt: json.RawMessage(`{"prebid":{"schains":[{"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}]}}`), + outSourceExt: json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}`), + outExt: json.RawMessage(`{"prebid":{}}`), + hasError: false, + }, + { + description: "Use wildcard schain in ext.prebid.schains", + inSourceExt: json.RawMessage(``), + inExt: json.RawMessage(`{"prebid":{"schains":[{"bidders":["*"],"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}]}}`), + outSourceExt: json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}`), + outExt: json.RawMessage(`{"prebid":{}}`), + hasError: false, + }, + { + description: "Use schain for bidder in ext.prebid.schains instead of wildcard", + inSourceExt: json.RawMessage(``), + inExt: json.RawMessage(`{"prebid":{"aliases":{"appnexus":"alias1"},"schains":[{"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}, {"bidders":["*"],"schain":{"complete":1,"nodes":[{"asi":"wildcard.com","sid":"wildcard1","rid":"WildcardReq1","hp":1}],"ver":"1.0"}} ]}}`), + outSourceExt: json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"directseller.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}`), + outExt: json.RawMessage(`{"prebid":{"aliases":{"appnexus":"alias1"}}}`), + hasError: false, + }, + { + description: "Use source schain -- multiple (two) bidder schains in ext.prebid.schains", + inSourceExt: json.RawMessage(`{"schain":{"complete":1,"nodes":[{"asi":"example.com","sid":"example1","rid":"ExampleReq1","hp":1}],"ver":"1.0"}}`), + inExt: json.RawMessage(`{"prebid":{"schains":[{"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller1.com","sid":"00001","rid":"BidRequest1","hp":1}],"ver":"1.0"}}, {"bidders":["appnexus"],"schain":{"complete":1,"nodes":[{"asi":"directseller2.com","sid":"00002","rid":"BidRequest2","hp":1}],"ver":"1.0"}}]}}`), + outSourceExt: nil, + outExt: nil, + hasError: true, + }, + } + + for _, test := range testCases { + req := newBidRequest(t) + req.Source.Ext = test.inSourceExt + req.Ext = test.inExt + + results, _, _, errs := cleanOpenRTBRequests(context.Background(), req, &emptyUsersync{}, map[openrtb_ext.BidderName]*pbsmetrics.AdapterLabels{}, pbsmetrics.Labels{}, &permissionsMock{}, true, config.Privacy{}) + result := results["appnexus"] + + if test.hasError == true { + assert.NotNil(t, errs) + assert.Nil(t, result) + } else { + assert.Nil(t, errs) + assert.Equal(t, test.outSourceExt, result.Source.Ext, test.description+":Source.Ext") + assert.Equal(t, test.outExt, result.Ext, test.description+":Ext") + } + } +} + func TestCleanOpenRTBRequestsLMT(t *testing.T) { var ( enabled int8 = 1 @@ -302,5 +388,99 @@ func TestRandomizeList(t *testing.T) { if len(adapters) != 1 { t.Errorf("RandomizeList, expected a list of 1, found %d", len(adapters)) } +} + +func TestBidderToPrebidChains(t *testing.T) { + input := openrtb_ext.ExtRequest{ + Prebid: openrtb_ext.ExtRequestPrebid{ + SChains: []*openrtb_ext.ExtRequestPrebidSChain{ + { + Bidders: []string{"Bidder1", "Bidder2"}, + SChain: openrtb_ext.ExtRequestPrebidSChainSChain{ + Complete: 1, + Nodes: []*openrtb_ext.ExtRequestPrebidSChainSChainNode{ + { + ASI: "asi1", + SID: "sid1", + Name: "name1", + RID: "rid1", + Domain: "domain1", + HP: 1, + }, + { + ASI: "asi2", + SID: "sid2", + Name: "name2", + RID: "rid2", + Domain: "domain2", + HP: 2, + }, + }, + Ver: "version1", + }, + }, + { + Bidders: []string{"Bidder3", "Bidder4"}, + SChain: openrtb_ext.ExtRequestPrebidSChainSChain{}, + }, + }, + }, + } + + output, err := BidderToPrebidSChains(&input) + + assert.Nil(t, err) + assert.Equal(t, len(output), 4) + assert.Same(t, output["Bidder1"], &input.Prebid.SChains[0].SChain) + assert.Same(t, output["Bidder2"], &input.Prebid.SChains[0].SChain) + assert.Same(t, output["Bidder3"], &input.Prebid.SChains[1].SChain) + assert.Same(t, output["Bidder4"], &input.Prebid.SChains[1].SChain) +} + +func TestBidderToPrebidChainsDiscardMultipleChainsForBidder(t *testing.T) { + input := openrtb_ext.ExtRequest{ + Prebid: openrtb_ext.ExtRequestPrebid{ + SChains: []*openrtb_ext.ExtRequestPrebidSChain{ + { + Bidders: []string{"Bidder1"}, + SChain: openrtb_ext.ExtRequestPrebidSChainSChain{}, + }, + { + Bidders: []string{"Bidder1", "Bidder2"}, + SChain: openrtb_ext.ExtRequestPrebidSChainSChain{}, + }, + }, + }, + } + + output, err := BidderToPrebidSChains(&input) + + assert.NotNil(t, err) + assert.Nil(t, output) +} + +func TestBidderToPrebidChainsNilSChains(t *testing.T) { + input := openrtb_ext.ExtRequest{ + Prebid: openrtb_ext.ExtRequestPrebid{ + SChains: nil, + }, + } + + output, err := BidderToPrebidSChains(&input) + + assert.Nil(t, err) + assert.Equal(t, len(output), 0) +} + +func TestBidderToPrebidChainsZeroLengthSChains(t *testing.T) { + input := openrtb_ext.ExtRequest{ + Prebid: openrtb_ext.ExtRequestPrebid{ + SChains: []*openrtb_ext.ExtRequestPrebidSChain{}, + }, + } + + output, err := BidderToPrebidSChains(&input) + assert.Nil(t, err) + assert.Equal(t, len(output), 0) } diff --git a/openrtb_ext/request.go b/openrtb_ext/request.go index 25b5c881408..86388f60cf4 100644 --- a/openrtb_ext/request.go +++ b/openrtb_ext/request.go @@ -12,12 +12,43 @@ type ExtRequest struct { // ExtRequestPrebid defines the contract for bidrequest.ext.prebid type ExtRequestPrebid struct { - Aliases map[string]string `json:"aliases,omitempty"` - BidAdjustmentFactors map[string]float64 `json:"bidadjustmentfactors,omitempty"` - Cache *ExtRequestPrebidCache `json:"cache,omitempty"` - StoredRequest *ExtStoredRequest `json:"storedrequest,omitempty"` - Targeting *ExtRequestTargeting `json:"targeting,omitempty"` - SupportDeals bool `json:"supportdeals,omitempty"` + Aliases map[string]string `json:"aliases,omitempty"` + BidAdjustmentFactors map[string]float64 `json:"bidadjustmentfactors,omitempty"` + Cache *ExtRequestPrebidCache `json:"cache,omitempty"` + SChains []*ExtRequestPrebidSChain `json:"schains,omitempty"` + StoredRequest *ExtStoredRequest `json:"storedrequest,omitempty"` + Targeting *ExtRequestTargeting `json:"targeting,omitempty"` + SupportDeals bool `json:"supportdeals,omitempty"` +} + +// ExtRequestPrebid defines the contract for bidrequest.ext.prebid.schains +type ExtRequestPrebidSChain struct { + Bidders []string `json:"bidders,omitempty"` + SChain ExtRequestPrebidSChainSChain `json:"schain"` +} + +// ExtRequestPrebidSChainSChain defines the contract for bidrequest.ext.prebid.schains[i].schain +type ExtRequestPrebidSChainSChain struct { + Complete int `json:"complete"` + Nodes []*ExtRequestPrebidSChainSChainNode `json:"nodes"` + Ver string `json:"ver"` + Ext json.RawMessage `json:"ext,omitempty"` +} + +// ExtRequestPrebidSChainSChainNode defines the contract for bidrequest.ext.prebid.schains[i].schain[i].nodes +type ExtRequestPrebidSChainSChainNode struct { + ASI string `json:"asi"` + SID string `json:"sid"` + RID string `json:"rid,omitempty"` + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + HP int `json:"hp"` + Ext json.RawMessage `json:"ext,omitempty"` +} + +// SourceExt defines the contract for bidrequest.source.ext +type SourceExt struct { + SChain ExtRequestPrebidSChainSChain `json:"schain"` } // ExtRequestPrebidCache defines the contract for bidrequest.ext.prebid.cache