diff --git a/adapters/relevantdigital/params_test.go b/adapters/relevantdigital/params_test.go new file mode 100644 index 00000000000..031fee1575b --- /dev/null +++ b/adapters/relevantdigital/params_test.go @@ -0,0 +1,42 @@ +package relevantdigital + +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 schema. %v", err) + } + + for _, p := range validParams { + if err := validator.Validate(openrtb_ext.BidderRelevantDigital, json.RawMessage(p)); err != nil { + t.Errorf("Schema rejected valid params: %s", p) + } + } +} + +func TestInvalidParams(t *testing.T) { + validator, err := openrtb_ext.NewBidderParamsValidator("../../static/bidder-params") + if err != nil { + t.Fatalf("Failed to fetch the json schema. %v", err) + } + + for _, p := range invalidParams { + if err := validator.Validate(openrtb_ext.BidderRelevantDigital, json.RawMessage(p)); err == nil { + t.Errorf("Schema allowed invalid params: %s", p) + } + } +} + +var validParams = []string{ + `{"accountId": "5fcf49f83a64ba6602b5be7e", "placementId" : "63b68275b4f35962c8eec9b1_5fcf49f83a64ba6602b5be9a", "pbsHost" : "some-host" }`, +} + +var invalidParams = []string{ + `{"accountId": 123, "placementId" : 123, "pbsHost" : ""}`, +} diff --git a/adapters/relevantdigital/relevantdigital.go b/adapters/relevantdigital/relevantdigital.go new file mode 100644 index 00000000000..937ff30ef08 --- /dev/null +++ b/adapters/relevantdigital/relevantdigital.go @@ -0,0 +1,265 @@ +package relevantdigital + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "strings" + "text/template" + + "github.com/buger/jsonparser" + "github.com/prebid/openrtb/v19/openrtb2" + "github.com/prebid/prebid-server/adapters" + "github.com/prebid/prebid-server/config" + "github.com/prebid/prebid-server/errortypes" + "github.com/prebid/prebid-server/macros" + "github.com/prebid/prebid-server/openrtb_ext" +) + +type adapter struct { + endpoint *template.Template + name string +} + +const RELEVANT_DOMAIN = ".relevant-digital.com" +const DEFAULT_TIMEOUT = 1000 +const DEFAULT_BUFFER_MS = 250 +const STORED_REQUEST_EXT = "{\"prebid\":{\"debug\":%t,\"storedrequest\":{\"id\":\"%s\"}},\"relevant\":{\"count\":%d,\"adapterType\":\"server\"}}" +const STORED_IMP_EXT = "{\"prebid\":{\"storedrequest\":{\"id\":\"%s\"}}}" + +func Builder(bidderName openrtb_ext.BidderName, config config.Adapter, server config.Server) (adapters.Bidder, error) { + template, err := template.New("endpointTemplate").Parse(config.Endpoint) + if err != nil { + return nil, fmt.Errorf("unable to parse endpoint url template: %v", err) + } + return &adapter{ + endpoint: template, + name: bidderName.String(), + }, nil +} + +func patchBidRequestExt(prebidBidRequest *openrtb2.BidRequest, id string) error { + count, cerr := jsonparser.GetInt(prebidBidRequest.Ext, "relevant", "count") + if cerr != nil { + count = 0 + } + + if count >= 5 { + return &errortypes.FailedToRequestBids{ + Message: "too many requests", + } + } else { + count = count + 1 + } + + debug, derr := jsonparser.GetBoolean(prebidBidRequest.Ext, "prebid", "debug") + if derr != nil { + debug = false + } + + prebidBidRequest.Ext = []byte(fmt.Sprintf(STORED_REQUEST_EXT, debug, id, count)) + return nil +} + +func patchBidImpExt(imp *openrtb2.Imp, id string) { + imp.Ext = []byte(fmt.Sprintf(STORED_IMP_EXT, id)) + if imp.Banner != nil { + imp.Banner.Ext = nil + } + if imp.Video != nil { + imp.Video.Ext = nil + } + if imp.Native != nil { + imp.Native.Ext = nil + } + if imp.Audio != nil { + imp.Audio.Ext = nil + } +} + +func setTMax(prebidBidRequest *openrtb2.BidRequest, pbsBufferMs int) { + timeout := float64(prebidBidRequest.TMax) + if timeout <= 0 { + timeout = DEFAULT_TIMEOUT + } + buffer := float64(pbsBufferMs) + prebidBidRequest.TMax = int64(math.Min(math.Max(timeout-buffer, buffer), timeout)) +} + +func cloneBidRequest(prebidBidRequest *openrtb2.BidRequest) (*openrtb2.BidRequest, error) { + jsonRes, err := json.Marshal(prebidBidRequest) + if err != nil { + return nil, err + } + var copy openrtb2.BidRequest + err = json.Unmarshal(jsonRes, ©) + return ©, err +} + +func (a *adapter) createBidRequest(prebidBidRequest *openrtb2.BidRequest, params []*openrtb_ext.ExtRelevantDigital) (*openrtb2.BidRequest, error) { + bidRequestCopy, err := cloneBidRequest(prebidBidRequest) + if err != nil { + return nil, err + } + + err = patchBidRequestExt(bidRequestCopy, params[0].AccountId) + if err != nil { + return nil, &errortypes.BadInput{ + Message: fmt.Sprintf("failed to create bidRequest, error: %s", err), + } + } + + setTMax(bidRequestCopy, params[0].PbsBufferMs) + + for idx := range bidRequestCopy.Imp { + patchBidImpExt(&bidRequestCopy.Imp[idx], params[idx].PlacementId) + } + return bidRequestCopy, err +} + +func (a *adapter) getImpressionExt(imp *openrtb2.Imp) (*openrtb_ext.ExtRelevantDigital, error) { + var bidderExt adapters.ExtImpBidder + if err := json.Unmarshal(imp.Ext, &bidderExt); err != nil { + return nil, &errortypes.BadInput{ + Message: "imp.ext not provided", + } + } + relevantExt := openrtb_ext.ExtRelevantDigital{PbsBufferMs: DEFAULT_BUFFER_MS} + if err := json.Unmarshal(bidderExt.Bidder, &relevantExt); err != nil { + return nil, &errortypes.BadInput{ + Message: "ext.bidder not provided", + } + } + return &relevantExt, nil +} + +func (a *adapter) buildEndpointURL(params *openrtb_ext.ExtRelevantDigital) (string, error) { + params.Host = strings.ReplaceAll(params.Host, "http://", "") + params.Host = strings.ReplaceAll(params.Host, "https://", "") + params.Host = strings.ReplaceAll(params.Host, RELEVANT_DOMAIN, "") + + endpointParams := macros.EndpointTemplateParams{Host: params.Host} + return macros.ResolveMacros(a.endpoint, endpointParams) +} + +func (adapter *adapter) buildAdapterRequest(prebidBidRequest *openrtb2.BidRequest, params []*openrtb_ext.ExtRelevantDigital) (*adapters.RequestData, error) { + newBidRequest, err := adapter.createBidRequest(prebidBidRequest, params) + + if err != nil { + return nil, err + } + + reqJSON, err := json.Marshal(newBidRequest) + if err != nil { + return nil, err + } + + url, err := adapter.buildEndpointURL(params[0]) + if err != nil { + return nil, err + } + + return &adapters.RequestData{ + Method: "POST", + Uri: url, + Body: reqJSON, + Headers: getHeaders(prebidBidRequest), + }, nil +} + +func (adapter *adapter) MakeRequests(request *openrtb2.BidRequest, reqInfo *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) { + impParams, errs := adapter.getImpressionsInfo(request.Imp) + if len(errs) > 0 { + return nil, errs + } + + bidRequest, err := adapter.buildAdapterRequest(request, impParams) + if err != nil { + errs = []error{err} + } + + if bidRequest != nil { + return []*adapters.RequestData{bidRequest}, errs + } + return nil, errs +} + +func (adapter *adapter) getImpressionsInfo(imps []openrtb2.Imp) (resImps []*openrtb_ext.ExtRelevantDigital, errors []error) { + for _, imp := range imps { + impExt, err := adapter.getImpressionExt(&imp) + if err != nil { + errors = append(errors, err) + continue + } + resImps = append(resImps, impExt) + } + return +} + +func getHeaders(request *openrtb2.BidRequest) http.Header { + headers := http.Header{} + headers.Add("Content-Type", "application/json;charset=utf-8") + headers.Add("Accept", "application/json") + headers.Add("X-Openrtb-Version", "2.5") + + if request.Device != nil { + if len(request.Device.UA) > 0 { + headers.Add("User-Agent", request.Device.UA) + } + if len(request.Device.IPv6) > 0 { + headers.Add("X-Forwarded-For", request.Device.IPv6) + } + if len(request.Device.IP) > 0 { + headers.Add("X-Forwarded-For", request.Device.IP) + } + } + return headers +} + +func getMediaTypeForBid(bid openrtb2.Bid) (openrtb_ext.BidType, error) { + switch bid.MType { + case openrtb2.MarkupBanner: + return openrtb_ext.BidTypeBanner, nil + case openrtb2.MarkupVideo: + return openrtb_ext.BidTypeVideo, nil + case openrtb2.MarkupAudio: + return openrtb_ext.BidTypeAudio, nil + case openrtb2.MarkupNative: + return openrtb_ext.BidTypeNative, nil + default: + return "", fmt.Errorf("unable to fetch mediaType in multi-format: %s", bid.ImpID) + } +} + +func (a *adapter) MakeBids(request *openrtb2.BidRequest, requestData *adapters.RequestData, responseData *adapters.ResponseData) (*adapters.BidderResponse, []error) { + if responseData.StatusCode != http.StatusOK { + return nil, []error{&errortypes.BadInput{ + Message: fmt.Sprintf("Unexpected status code: %d. Bad request from publisher. Run with request.debug = 1 for more info.", responseData.StatusCode), + }} + } + + var response openrtb2.BidResponse + if err := json.Unmarshal(responseData.Body, &response); err != nil { + return nil, []error{err} + } + + bidResponse := adapters.NewBidderResponseWithBidsCapacity(len(response.SeatBid)) + bidResponse.Currency = response.Cur + var errs []error + for _, seatBid := range response.SeatBid { + for i, bid := range seatBid.Bid { + bidType, err := getMediaTypeForBid(bid) + if err != nil { + errs = append(errs, err) + } else { + b := &adapters.TypedBid{ + Bid: &seatBid.Bid[i], + BidType: bidType, + } + bidResponse.Bids = append(bidResponse.Bids, b) + } + } + } + return bidResponse, errs +} diff --git a/adapters/relevantdigital/relevantdigital_test.go b/adapters/relevantdigital/relevantdigital_test.go new file mode 100644 index 00000000000..848f3057ecf --- /dev/null +++ b/adapters/relevantdigital/relevantdigital_test.go @@ -0,0 +1,28 @@ +package relevantdigital + +import ( + "testing" + + "github.com/prebid/prebid-server/adapters/adapterstest" + "github.com/prebid/prebid-server/config" + "github.com/prebid/prebid-server/openrtb_ext" + "github.com/stretchr/testify/assert" +) + +func TestJsonSamples(t *testing.T) { + bidder, buildErr := Builder(openrtb_ext.BidderRelevantDigital, config.Adapter{ + Endpoint: "https://{{.Host}}.relevant-digital.com/openrtb2/auction"}, config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"}) + + if buildErr != nil { + t.Fatalf("Builder returned unexpected error %v", buildErr) + } + + adapterstest.RunJSONBidderTest(t, "relevantdigitaltest", bidder) +} + +func TestEndpointTemplateMalformed(t *testing.T) { + _, buildErr := Builder(openrtb_ext.BidderAceex, config.Adapter{ + Endpoint: "{{Malformed}}"}, config.Server{ExternalUrl: "http://hosturl.com", GvlID: 1, DataCenter: "2"}) + + assert.Error(t, buildErr) +} diff --git a/adapters/relevantdigital/relevantdigitaltest/exemplary/simple-banner.json b/adapters/relevantdigital/relevantdigitaltest/exemplary/simple-banner.json new file mode 100644 index 00000000000..3d23dcf61ef --- /dev/null +++ b/adapters/relevantdigital/relevantdigitaltest/exemplary/simple-banner.json @@ -0,0 +1,198 @@ +{ + "mockBidRequest": { + "id": "3621f78b-abdf-4562-8eca-1c5e893387d0", + "imp": [ + { + "id": "div-1", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + }, + { + "w": 300, + "h": 600 + }, + { + "w": 320, + "h": 320 + } + ] + }, + "ext": { + "bidder": { + "accountId": "620523ae7f4bbe1691bbb815", + "pbsHost": "fakeHost", + "placementId": "620525862d7518bfd4bbb81e_620523b5d1dbed6b0fbbb817" + } + } + } + ], + "site": { + "domain": "somedomain.com", + "page": "https://somedomain.com", + "publisher": { + "id": "1001", + "domain": "somepub.com" + }, + "ext": { + "amp": 0 + } + } + }, + "httpCalls": [ + { + "expectedRequest": { + "uri": "https://fakeHost.relevant-digital.com/openrtb2/auction", + "body": { + "id": "3621f78b-abdf-4562-8eca-1c5e893387d0", + "imp": [ + { + "id": "div-1", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + }, + { + "w": 300, + "h": 600 + }, + { + "w": 320, + "h": 320 + } + ] + }, + "ext": { + "prebid": { + "storedrequest": { + "id": "620525862d7518bfd4bbb81e_620523b5d1dbed6b0fbbb817" + } + } + } + } + ], + "site": { + "domain": "somedomain.com", + "page": "https://somedomain.com", + "publisher": { + "id": "1001", + "domain": "somepub.com" + }, + "ext": { + "amp": 0 + } + }, + "ext": { + "prebid": { + "debug" : false, + "storedrequest": { + "id": "620523ae7f4bbe1691bbb815" + } + }, + "relevant": { + "adapterType": "server", + "count": 1 + } + }, + "tmax": 750 + } + }, + "mockResponse": { + "status": 200, + "body": { + "id": "3621f78b-abdf-4562-8eca-1c5e893387d0", + "seatbid": [ + { + "seat": "relevantdigital", + "bid": [ + { + "id": "8ee514f1-b2b8-4abb-89fd-084437d1e800", + "impid": "div-1", + "price": 0.500000, + "adm": "", + "crid": "crid_10", + "w": 728, + "h": 90, + "mtype": 1, + "ext": { + "bidtype": 0, + "dspid": 6, + "origbidcpm": 11.13998874085194, + "origbidcur": "USD", + "prebid": { + "bidid": "09abd496-5706-4311-a356-d559629a1d17", + "events": { + "win": "https://fakeHost.relevant-digital.com/event?t=win\u0026b=09abd496-5706-4311-a356-d559629a1d17\u0026a=1001\u0026bidder=providerA\u0026ts=1694939785078", + "imp": "https://fakeHost.relevant-digital.com/event?t=imp\u0026b=09abd496-5706-4311-a356-d559629a1d17\u0026a=1001\u0026bidder=providerA\u0026ts=1694939785078" + }, + "meta": { + "adaptercode": "relevantdigital" + }, + "targeting": { + "hb_bidder": "relevantdigital", + "hb_cache_host": "somedomain.com", + "hb_cache_path": "/analytics_cache/read", + "hb_pb": "11.10", + "hb_size": "300x250" + }, + "type": "banner" + } + } + } + ] + } + ], + "cur": "USD" + } + } + } + ], + "expectedBidResponses": [ + { + "currency": "USD", + "bids": [ + { + "bid": { + "id": "8ee514f1-b2b8-4abb-89fd-084437d1e800", + "impid": "div-1", + "price": 0.5, + "adm": "", + "crid": "crid_10", + "w": 728, + "h": 90, + "mtype": 1, + "ext": { + "bidtype": 0, + "dspid": 6, + "origbidcpm": 11.13998874085194, + "origbidcur": "USD", + "prebid": { + "bidid": "09abd496-5706-4311-a356-d559629a1d17", + "events": { + "win": "https://fakeHost.relevant-digital.com/event?t=win\u0026b=09abd496-5706-4311-a356-d559629a1d17\u0026a=1001\u0026bidder=providerA\u0026ts=1694939785078", + "imp": "https://fakeHost.relevant-digital.com/event?t=imp\u0026b=09abd496-5706-4311-a356-d559629a1d17\u0026a=1001\u0026bidder=providerA\u0026ts=1694939785078" + }, + "meta": { + "adaptercode": "relevantdigital" + }, + "targeting": { + "hb_bidder": "relevantdigital", + "hb_cache_host": "somedomain.com", + "hb_cache_path": "/analytics_cache/read", + "hb_pb": "11.10", + "hb_size": "300x250" + }, + "type": "banner" + } + } + }, + "type": "banner" + } + ] + } + ] +} diff --git a/exchange/adapter_builders.go b/exchange/adapter_builders.go index 6ab0dccfc7c..8cda0eff03b 100755 --- a/exchange/adapter_builders.go +++ b/exchange/adapter_builders.go @@ -137,6 +137,7 @@ import ( "github.com/prebid/prebid-server/adapters/pubnative" "github.com/prebid/prebid-server/adapters/pulsepoint" "github.com/prebid/prebid-server/adapters/pwbid" + "github.com/prebid/prebid-server/adapters/relevantdigital" "github.com/prebid/prebid-server/adapters/revcontent" "github.com/prebid/prebid-server/adapters/richaudience" "github.com/prebid/prebid-server/adapters/rise" @@ -334,6 +335,7 @@ func newAdapterBuilders() map[openrtb_ext.BidderName]adapters.Builder { openrtb_ext.BidderPubnative: pubnative.Builder, openrtb_ext.BidderPulsepoint: pulsepoint.Builder, openrtb_ext.BidderPWBid: pwbid.Builder, + openrtb_ext.BidderRelevantDigital: relevantdigital.Builder, openrtb_ext.BidderRevcontent: revcontent.Builder, openrtb_ext.BidderRichaudience: richaudience.Builder, openrtb_ext.BidderRise: rise.Builder, diff --git a/openrtb_ext/bidders.go b/openrtb_ext/bidders.go index 22a915b769f..43b5c427df3 100644 --- a/openrtb_ext/bidders.go +++ b/openrtb_ext/bidders.go @@ -157,6 +157,7 @@ var coreBidderNames []BidderName = []BidderName{ BidderPubnative, BidderPulsepoint, BidderPWBid, + BidderRelevantDigital, BidderRevcontent, BidderRichaudience, BidderRise, @@ -438,6 +439,7 @@ const ( BidderPubnative BidderName = "pubnative" BidderPulsepoint BidderName = "pulsepoint" BidderPWBid BidderName = "pwbid" + BidderRelevantDigital BidderName = "relevantdigital" BidderRevcontent BidderName = "revcontent" BidderRichaudience BidderName = "richaudience" BidderRise BidderName = "rise" diff --git a/openrtb_ext/imp_relevantdigital.go b/openrtb_ext/imp_relevantdigital.go new file mode 100644 index 00000000000..ec250557c2b --- /dev/null +++ b/openrtb_ext/imp_relevantdigital.go @@ -0,0 +1,8 @@ +package openrtb_ext + +type ExtRelevantDigital struct { + AccountId string `json:"accountId"` + PlacementId string `json:"placementId"` + Host string `json:"pbsHost"` + PbsBufferMs int `json:"pbsBufferMs"` +} diff --git a/static/bidder-info/relevantdigital.yaml b/static/bidder-info/relevantdigital.yaml new file mode 100644 index 00000000000..edf6d6705bc --- /dev/null +++ b/static/bidder-info/relevantdigital.yaml @@ -0,0 +1,16 @@ +endpoint: "https://{{.Host}}.relevant-digital.com/openrtb2/auction" +maintainer: + email: "support@relevant-digital.com" +gvlVendorID: 1100 +extra_info: "" +capabilities: + app: + mediaTypes: + - banner + - video + - native + site: + mediaTypes: + - banner + - video + - native diff --git a/static/bidder-params/relevantdigital.json b/static/bidder-params/relevantdigital.json new file mode 100644 index 00000000000..5f88d85fe58 --- /dev/null +++ b/static/bidder-params/relevantdigital.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Relevant Digital Adapter Params", + "description": "A schema which validates params accepted by the Relevant Digital adapter", + "type": "object", + "properties": { + "accountId": { + "type": "string", + "description": "An ID which identifies the Relevant Digital account ID" + }, + "placementId": { + "type": "string", + "description": "An ID which identifies the Relevant Digital placement ID" + }, + "pbsHost": { + "type": "string", + "description": "Prebid Server Host supplied by Relevant Digital" + }, + "pbsBufferMs": { + "type": "number", + "description": "TMax buffer, default is 250" + } + }, + "required": [ + "accountId", + "placementId", + "pbsHost" + ] +}