diff --git a/.github/workflows/scripts/codepath-notification b/.github/workflows/scripts/codepath-notification index 8a3bae8daca..72fc62b5b36 100644 --- a/.github/workflows/scripts/codepath-notification +++ b/.github/workflows/scripts/codepath-notification @@ -19,3 +19,4 @@ adapters/ix|imp_ix|ix.json|ix.yaml: pdu-supply-prebid@indexexchange.com medianet: prebid@media.net gumgum: prebid@gumgum.com kargo: kraken@kargo.com +bidwave: prebid@bidwave.net diff --git a/adapters/bidwave/bidwave.go b/adapters/bidwave/bidwave.go new file mode 100644 index 00000000000..087431a9db8 --- /dev/null +++ b/adapters/bidwave/bidwave.go @@ -0,0 +1,264 @@ +package bidwave + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/adapters" + "github.com/prebid/prebid-server/v4/config" + "github.com/prebid/prebid-server/v4/errortypes" + "github.com/prebid/prebid-server/v4/openrtb_ext" + "github.com/prebid/prebid-server/v4/util/jsonutil" +) + +const defaultCurrency = "USD" + +var uuidRegex = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +type adapter struct { + endpoint string +} + +type requestExtBidwave struct { + PID string `json:"pid"` +} + +type impGroup struct { + publisherID string + imps []openrtb2.Imp +} + +func Builder(_ openrtb_ext.BidderName, config config.Adapter, _ config.Server) (adapters.Bidder, error) { + return &adapter{ + endpoint: config.Endpoint, + }, nil +} + +func (a *adapter) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) { + groups, errs := groupImpsByPublisherID(request.Imp, reqInfo) + + requests := make([]*adapters.RequestData, 0, len(groups)) + for _, group := range groups { + outgoingRequest := *request + outgoingRequest.Cur = []string{defaultCurrency} + outgoingRequest.Imp = group.imps + + ext, err := setBidwaveExt(outgoingRequest.Ext, group.publisherID) + if err != nil { + errs = append(errs, err) + continue + } + outgoingRequest.Ext = ext + + requestJSON, err := jsonutil.Marshal(outgoingRequest) + if err != nil { + errs = append(errs, err) + continue + } + + headers := http.Header{} + headers.Add("Content-Type", "application/json;charset=utf-8") + + requests = append(requests, &adapters.RequestData{ + Method: http.MethodPost, + Uri: a.endpoint, + Body: requestJSON, + Headers: headers, + ImpIDs: openrtb_ext.GetImpIDs(group.imps), + }) + } + + return requests, errs +} + +func prepareImpCurrency(imp openrtb2.Imp, reqInfo *adapters.ExtraRequestInfo) (openrtb2.Imp, error) { + if imp.BidFloor > 0 && strings.TrimSpace(imp.BidFloorCur) != "" && !strings.EqualFold(imp.BidFloorCur, defaultCurrency) { + bidFloor, err := reqInfo.ConvertCurrency(imp.BidFloor, imp.BidFloorCur, defaultCurrency) + if err != nil { + return imp, &errortypes.BadInput{ + Message: fmt.Sprintf("expected currency %s for bid floor; unable to convert from %s for impression %s: %s", defaultCurrency, imp.BidFloorCur, imp.ID, err.Error()), + } + } + + imp.BidFloor = bidFloor + imp.BidFloorCur = defaultCurrency + } + + return imp, nil +} + +func groupImpsByPublisherID(imps []openrtb2.Imp, reqInfo *adapters.ExtraRequestInfo) ([]impGroup, []error) { + groupIndexes := make(map[string]int) + groups := make([]impGroup, 0, len(imps)) + errs := make([]error, 0) + + for i := range imps { + publisherID, err := parsePublisherID(imps[i]) + if err != nil { + errs = append(errs, err) + continue + } + + imp, err := prepareImpCurrency(imps[i], reqInfo) + if err != nil { + errs = append(errs, err) + continue + } + + if groupIndex, ok := groupIndexes[publisherID]; ok { + groups[groupIndex].imps = append(groups[groupIndex].imps, imp) + continue + } + + groupIndexes[publisherID] = len(groups) + groups = append(groups, impGroup{ + publisherID: publisherID, + imps: []openrtb2.Imp{imp}, + }) + } + + return groups, errs +} + +func parsePublisherID(imp openrtb2.Imp) (string, error) { + var ext adapters.ExtImpBidder + if err := jsonutil.Unmarshal(imp.Ext, &ext); err != nil { + return "", &errortypes.BadInput{ + Message: fmt.Sprintf("invalid imp.ext for impression %s: %s", imp.ID, err.Error()), + } + } + + var params openrtb_ext.ExtImpBidwave + if err := jsonutil.Unmarshal(ext.Bidder, ¶ms); err != nil { + return "", &errortypes.BadInput{ + Message: fmt.Sprintf("invalid bidwave params for impression %s: %s", imp.ID, err.Error()), + } + } + + if !uuidRegex.MatchString(params.PublisherID) { + return "", &errortypes.BadInput{ + Message: fmt.Sprintf("invalid publisherId for impression %s", imp.ID), + } + } + + return params.PublisherID, nil +} + +func setBidwaveExt(rawExt json.RawMessage, publisherID string) (json.RawMessage, error) { + ext := map[string]json.RawMessage{} + if len(rawExt) > 0 { + if err := jsonutil.Unmarshal(rawExt, &ext); err != nil { + return nil, err + } + } + + bidwaveExt, err := jsonutil.Marshal(requestExtBidwave{PID: publisherID}) + if err != nil { + return nil, err + } + + ext["bidwave"] = bidwaveExt + return jsonutil.Marshal(ext) +} + +func (a *adapter) MakeBids(request *openrtb2.BidRequest, _ *adapters.RequestData, response *adapters.ResponseData) (*adapters.BidderResponse, []error) { + if adapters.IsResponseStatusCodeNoContent(response) { + return nil, nil + } + + if err := adapters.CheckResponseStatusCodeForErrors(response); err != nil { + return nil, []error{err} + } + + var bidResponse openrtb2.BidResponse + if err := jsonutil.Unmarshal(response.Body, &bidResponse); err != nil { + return nil, []error{&errortypes.BadServerResponse{ + Message: "Bad Server Response", + }} + } + + result := adapters.NewBidderResponseWithBidsCapacity(len(request.Imp)) + if bidResponse.Cur != "" { + result.Currency = bidResponse.Cur + } + + var errs []error + for seatBidIndex := range bidResponse.SeatBid { + for bidIndex := range bidResponse.SeatBid[seatBidIndex].Bid { + bid := &bidResponse.SeatBid[seatBidIndex].Bid[bidIndex] + bidType, err := getBidType(bid, request.Imp) + if err != nil { + errs = append(errs, err) + continue + } + + result.Bids = append(result.Bids, &adapters.TypedBid{ + Bid: bid, + BidType: bidType, + }) + } + } + + return result, errs +} + +func getBidType(bid *openrtb2.Bid, imps []openrtb2.Imp) (openrtb_ext.BidType, error) { + switch bid.MType { + case openrtb2.MarkupBanner: + return openrtb_ext.BidTypeBanner, nil + case openrtb2.MarkupVideo: + return openrtb_ext.BidTypeVideo, nil + case 0: + return getBidTypeFromImp(bid.ImpID, imps) + default: + return "", &errortypes.BadServerResponse{ + Message: fmt.Sprintf("Unsupported MType %d for impression with ID: \"%s\"", bid.MType, bid.ImpID), + } + } +} + +func getBidTypeFromImp(impID string, imps []openrtb2.Imp) (openrtb_ext.BidType, error) { + for _, imp := range imps { + if imp.ID == impID { + if isMultiFormatImp(imp) { + return "", &errortypes.BadServerResponse{ + Message: fmt.Sprintf("Bid must have non-zero MType for multi format impression with ID: \"%s\"", impID), + } + } + if imp.Banner != nil { + return openrtb_ext.BidTypeBanner, nil + } + if imp.Video != nil { + return openrtb_ext.BidTypeVideo, nil + } + return "", &errortypes.BadServerResponse{ + Message: fmt.Sprintf("Could not determine MType from impression with ID: \"%s\"", impID), + } + } + } + + return "", &errortypes.BadServerResponse{ + Message: fmt.Sprintf("Failed to find impression for ID: \"%s\"", impID), + } +} + +func isMultiFormatImp(imp openrtb2.Imp) bool { + formatCount := 0 + if imp.Banner != nil { + formatCount++ + } + if imp.Video != nil { + formatCount++ + } + if imp.Audio != nil { + formatCount++ + } + if imp.Native != nil { + formatCount++ + } + return formatCount > 1 +} diff --git a/adapters/bidwave/bidwave_test.go b/adapters/bidwave/bidwave_test.go new file mode 100644 index 00000000000..bf1a1c4b333 --- /dev/null +++ b/adapters/bidwave/bidwave_test.go @@ -0,0 +1,133 @@ +package bidwave + +import ( + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/adapters" + "github.com/prebid/prebid-server/v4/adapters/adapterstest" + "github.com/prebid/prebid-server/v4/config" + "github.com/prebid/prebid-server/v4/currency" + "github.com/prebid/prebid-server/v4/openrtb_ext" + "github.com/stretchr/testify/assert" +) + +func TestJsonSamples(t *testing.T) { + bidder, buildErr := Builder(openrtb_ext.BidderBidwave, config.Adapter{ + Endpoint: "https://rtb.bidwave.net/rtb/v1/bid", + }, config.Server{}) + if buildErr != nil { + t.Fatalf("Builder returned unexpected error %v", buildErr) + } + + adapterstest.RunJSONBidderTest(t, "bidwavetest", bidder) +} + +func TestPrepareImpCurrencyConvertsToUSD(t *testing.T) { + reqInfo := adapters.NewExtraRequestInfo(currency.NewRates(map[string]map[string]float64{ + "EUR": { + "USD": 2, + }, + })) + imp := openrtb2.Imp{ + ID: "imp-1", + BidFloor: 1.5, + BidFloorCur: "EUR", + } + + preparedImp, err := prepareImpCurrency(imp, &reqInfo) + + assert.NoError(t, err) + assert.Equal(t, 3.0, preparedImp.BidFloor) + assert.Equal(t, "USD", preparedImp.BidFloorCur) +} + +func TestPrepareImpCurrencyReturnsErrorWhenBidFloorCurrencyCannotBeConverted(t *testing.T) { + reqInfo := adapters.NewExtraRequestInfo(currency.NewRates(map[string]map[string]float64{})) + imp := openrtb2.Imp{ + ID: "imp-1", + BidFloor: 1.5, + BidFloorCur: "EUR", + } + + preparedImp, err := prepareImpCurrency(imp, &reqInfo) + + assert.Equal(t, imp, preparedImp) + assert.Error(t, err) + assert.Contains(t, err.Error(), "expected currency USD for bid floor; unable to convert from EUR for impression imp-1") +} + +func TestGetBidType(t *testing.T) { + testCases := []struct { + name string + bid openrtb2.Bid + imps []openrtb2.Imp + expected openrtb_ext.BidType + expectedErr string + }{ + { + name: "banner mtype", + bid: openrtb2.Bid{ImpID: "imp-1", MType: openrtb2.MarkupBanner}, + expected: openrtb_ext.BidTypeBanner, + }, + { + name: "video mtype", + bid: openrtb2.Bid{ImpID: "imp-1", MType: openrtb2.MarkupVideo}, + expected: openrtb_ext.BidTypeVideo, + }, + { + name: "missing mtype for banner-only impression", + bid: openrtb2.Bid{ImpID: "imp-1"}, + imps: []openrtb2.Imp{{ + ID: "imp-1", + Banner: &openrtb2.Banner{}, + }}, + expected: openrtb_ext.BidTypeBanner, + }, + { + name: "missing mtype for video-only impression", + bid: openrtb2.Bid{ImpID: "imp-1"}, + imps: []openrtb2.Imp{{ + ID: "imp-1", + Video: &openrtb2.Video{}, + }}, + expected: openrtb_ext.BidTypeVideo, + }, + { + name: "missing mtype for multi-format impression", + bid: openrtb2.Bid{ImpID: "imp-1"}, + imps: []openrtb2.Imp{{ + ID: "imp-1", + Banner: &openrtb2.Banner{}, + Video: &openrtb2.Video{}, + }}, + expectedErr: "Bid must have non-zero MType for multi format impression with ID: \"imp-1\"", + }, + { + name: "missing mtype for unknown impression", + bid: openrtb2.Bid{ImpID: "imp-1"}, + imps: []openrtb2.Imp{{ID: "imp-2", Banner: &openrtb2.Banner{}}}, + expectedErr: "Failed to find impression for ID: \"imp-1\"", + }, + { + name: "unsupported mtype", + bid: openrtb2.Bid{ImpID: "imp-1", MType: openrtb2.MarkupNative}, + expectedErr: "Unsupported MType 4 for impression with ID: \"imp-1\"", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + bidType, err := getBidType(&testCase.bid, testCase.imps) + + if testCase.expectedErr != "" { + assert.EqualError(t, err, testCase.expectedErr) + assert.Empty(t, bidType) + return + } + + assert.NoError(t, err) + assert.Equal(t, testCase.expected, bidType) + }) + } +} diff --git a/adapters/bidwave/bidwavetest/exemplary/group-by-publisher.json b/adapters/bidwave/bidwavetest/exemplary/group-by-publisher.json new file mode 100644 index 00000000000..06a30c27e23 --- /dev/null +++ b/adapters/bidwave/bidwavetest/exemplary/group-by-publisher.json @@ -0,0 +1,170 @@ +{ + "mockBidRequest": { + "id": "bidwave-group-request", + "cur": ["EUR"], + "site": { + "domain": "publisher.example.com" + }, + "device": { + "ip": "203.0.113.43", + "geo": { + "country": "US" + } + }, + "imp": [ + { + "id": "imp-1", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "ext": { + "bidder": { + "publisherId": "11111111-1111-4111-8111-111111111111" + } + } + }, + { + "id": "imp-2", + "video": { + "mimes": ["video/mp4"], + "protocols": [2, 3], + "w": 640, + "h": 360 + }, + "ext": { + "bidder": { + "publisherId": "22222222-2222-4222-8222-222222222222" + } + } + }, + { + "id": "imp-3", + "banner": { + "format": [ + { + "w": 728, + "h": 90 + } + ] + }, + "ext": { + "bidder": { + "publisherId": "11111111-1111-4111-8111-111111111111" + } + } + } + ] + }, + "httpCalls": [ + { + "expectedRequest": { + "uri": "https://rtb.bidwave.net/rtb/v1/bid", + "impIDs": ["imp-1", "imp-3"], + "body": { + "id": "bidwave-group-request", + "cur": ["USD"], + "ext": { + "bidwave": { + "pid": "11111111-1111-4111-8111-111111111111" + } + }, + "site": { + "domain": "publisher.example.com" + }, + "device": { + "ip": "203.0.113.43", + "geo": { + "country": "US" + } + }, + "imp": [ + { + "id": "imp-1", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "ext": { + "bidder": { + "publisherId": "11111111-1111-4111-8111-111111111111" + } + } + }, + { + "id": "imp-3", + "banner": { + "format": [ + { + "w": 728, + "h": 90 + } + ] + }, + "ext": { + "bidder": { + "publisherId": "11111111-1111-4111-8111-111111111111" + } + } + } + ] + } + }, + "mockResponse": { + "status": 204 + } + }, + { + "expectedRequest": { + "uri": "https://rtb.bidwave.net/rtb/v1/bid", + "impIDs": ["imp-2"], + "body": { + "id": "bidwave-group-request", + "cur": ["USD"], + "ext": { + "bidwave": { + "pid": "22222222-2222-4222-8222-222222222222" + } + }, + "site": { + "domain": "publisher.example.com" + }, + "device": { + "ip": "203.0.113.43", + "geo": { + "country": "US" + } + }, + "imp": [ + { + "id": "imp-2", + "video": { + "mimes": ["video/mp4"], + "protocols": [2, 3], + "w": 640, + "h": 360 + }, + "ext": { + "bidder": { + "publisherId": "22222222-2222-4222-8222-222222222222" + } + } + } + ] + } + }, + "mockResponse": { + "status": 204 + } + } + ], + "expectedBidResponses": [] +} diff --git a/adapters/bidwave/bidwavetest/exemplary/simple-banner.json b/adapters/bidwave/bidwavetest/exemplary/simple-banner.json new file mode 100644 index 00000000000..3bf1acc5521 --- /dev/null +++ b/adapters/bidwave/bidwavetest/exemplary/simple-banner.json @@ -0,0 +1,130 @@ +{ + "mockBidRequest": { + "id": "bidwave-test-request", + "site": { + "domain": "publisher.example.com", + "page": "https://publisher.example.com/article" + }, + "device": { + "ip": "203.0.113.42", + "ua": "Mozilla/5.0", + "geo": { + "country": "USA", + "region": "CA", + "city": "San Francisco" + } + }, + "imp": [ + { + "id": "imp-1", + "tagid": "/bidwave/example/banner", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "ext": { + "bidder": { + "publisherId": "11111111-1111-4111-8111-111111111111" + } + } + } + ] + }, + "httpCalls": [ + { + "expectedRequest": { + "uri": "https://rtb.bidwave.net/rtb/v1/bid", + "impIDs": ["imp-1"], + "body": { + "id": "bidwave-test-request", + "site": { + "domain": "publisher.example.com", + "page": "https://publisher.example.com/article" + }, + "device": { + "ip": "203.0.113.42", + "ua": "Mozilla/5.0", + "geo": { + "country": "USA", + "region": "CA", + "city": "San Francisco" + } + }, + "cur": ["USD"], + "imp": [ + { + "id": "imp-1", + "tagid": "/bidwave/example/banner", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "ext": { + "bidder": { + "publisherId": "11111111-1111-4111-8111-111111111111" + } + } + } + ], + "ext": { + "bidwave": { + "pid": "11111111-1111-4111-8111-111111111111" + } + } + } + }, + "mockResponse": { + "status": 200, + "body": { + "id": "bidwave-test-request", + "cur": "USD", + "seatbid": [ + { + "seat": "bidwave", + "bid": [ + { + "id": "bid-1", + "impid": "imp-1", + "price": 1.25, + "adm": "