From bed560d027241e703dc55ad0d1acf65160be0f82 Mon Sep 17 00:00:00 2001 From: lucor Date: Fri, 7 Feb 2025 15:16:52 +0100 Subject: [PATCH] Support bidder-specific device data This PR introduces support for bidder-specific device data. It merges bidderconfig.ortb2.device fields into request.device, with bidderconfig.ortb2.device taking precedence. Fixes #4147 --- exchange/utils.go | 4 + exchange/utils_test.go | 11 +++ firstpartydata/first_party_data.go | 44 +++++++++- firstpartydata/first_party_data_test.go | 86 +++++++++++++++++++ .../bidder-config-device-data.json | 48 +++++++++++ .../resolvefpd/bidder-fpd-only-device.json | 46 ++++++++++ openrtb_ext/request.go | 9 +- openrtb_ext/request_test.go | 9 ++ 8 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 firstpartydata/tests/extractbidderconfigfpd/bidder-config-device-data.json create mode 100644 firstpartydata/tests/resolvefpd/bidder-fpd-only-device.json diff --git a/exchange/utils.go b/exchange/utils.go index 71f7c20f8c7..415ded13076 100644 --- a/exchange/utils.go +++ b/exchange/utils.go @@ -1009,6 +1009,10 @@ func applyFPD(fpd map[openrtb_ext.BidderName]*firstpartydata.ResolvedFirstPartyD reqWrapper.App = fpdToApply.App } + if fpdToApply.Device != nil { + reqWrapper.Device = fpdToApply.Device + } + if fpdToApply.User != nil { if reqWrapper.User != nil { if len(reqWrapper.User.BuyerUID) > 0 { diff --git a/exchange/utils_test.go b/exchange/utils_test.go index 64f0973ecf4..272b3fd1145 100644 --- a/exchange/utils_test.go +++ b/exchange/utils_test.go @@ -3794,6 +3794,17 @@ func TestApplyFPD(t *testing.T) { expectedRequest: openrtb2.BidRequest{Site: &openrtb2.Site{ID: "SiteId"}, App: &openrtb2.App{ID: "AppId"}, User: &openrtb2.User{ID: "UserId", EIDs: []openrtb2.EID{{Source: "source3"}, {Source: "source4"}}}}, fpdUserEIDsExisted: false, }, + { + description: "req.Device defined; bidderFPD.Device defined; expect request.Device to be overriden by bidderFPD.Device", + inputFpd: map[openrtb_ext.BidderName]*firstpartydata.ResolvedFirstPartyData{ + "bidderNormalized": {Device: &openrtb2.Device{Make: "DeviceMake"}}, + }, + inputBidderName: "bidderFromRequest", + inputBidderCoreName: "bidderNormalized", + inputBidderIsRequestAlias: false, + inputRequest: openrtb2.BidRequest{Device: &openrtb2.Device{Make: "TestDeviceMake"}}, + expectedRequest: openrtb2.BidRequest{Device: &openrtb2.Device{Make: "DeviceMake"}}, + }, } for _, testCase := range testCases { diff --git a/firstpartydata/first_party_data.go b/firstpartydata/first_party_data.go index 44a3241a53c..28c8fc74cbd 100644 --- a/firstpartydata/first_party_data.go +++ b/firstpartydata/first_party_data.go @@ -32,9 +32,10 @@ const ( ) type ResolvedFirstPartyData struct { - Site *openrtb2.Site - App *openrtb2.App - User *openrtb2.User + Site *openrtb2.Site + App *openrtb2.App + User *openrtb2.User + Device *openrtb2.Device } // ExtractGlobalFPD extracts request level FPD from the request and removes req.{site,app,user}.ext.data if exists @@ -159,6 +160,12 @@ func ResolveFPD(bidRequest *openrtb2.BidRequest, fpdBidderConfigData map[openrtb } resolvedFpdConfig.Site = newSite + newDevice, err := resolveDevice(fpdConfig, bidRequest.Device) + if err != nil { + errL = append(errL, err) + } + resolvedFpdConfig.Device = newDevice + if len(errL) == 0 { resolvedFpd[openrtb_ext.BidderName(bidderName)] = resolvedFpdConfig } @@ -166,6 +173,36 @@ func ResolveFPD(bidRequest *openrtb2.BidRequest, fpdBidderConfigData map[openrtb return resolvedFpd, errL } +// resolveDevice merges the device information from the FPD (First Party Data) configuration +// with the device information provided in the bid request. It returns a new Device object +// that contains the merged data. +func resolveDevice(fpdConfig *openrtb_ext.ORTB2, bidRequestDevice *openrtb2.Device) (*openrtb2.Device, error) { + var fpdConfigDevice json.RawMessage + + if fpdConfig != nil && fpdConfig.Device != nil { + fpdConfigDevice = fpdConfig.Device + } + + if bidRequestDevice == nil && fpdConfigDevice == nil { + return nil, nil + } + + var newDevice *openrtb2.Device + if bidRequestDevice != nil { + newDevice = ptrutil.Clone(bidRequestDevice) + } else { + newDevice = &openrtb2.Device{} + } + + if fpdConfigDevice != nil { + if err := jsonutil.MergeClone(newDevice, fpdConfigDevice); err != nil { + return nil, formatMergeCloneError(err) + } + } + + return newDevice, nil +} + func resolveUser(fpdConfig *openrtb_ext.ORTB2, bidRequestUser *openrtb2.User, globalFPD map[string][]byte, openRtbGlobalFPD map[string][]openrtb2.Data, bidderName string) (*openrtb2.User, error) { var fpdConfigUser json.RawMessage @@ -377,6 +414,7 @@ func ExtractBidderConfigFPD(reqExt *openrtb_ext.RequestExt) (map[openrtb_ext.Bid fpdBidderData.Site = bidderConfig.Config.ORTB2.Site fpdBidderData.App = bidderConfig.Config.ORTB2.App fpdBidderData.User = bidderConfig.Config.ORTB2.User + fpdBidderData.Device = bidderConfig.Config.ORTB2.Device } fpd[bidderName] = fpdBidderData diff --git a/firstpartydata/first_party_data_test.go b/firstpartydata/first_party_data_test.go index 3a11e44478b..eae1755482a 100644 --- a/firstpartydata/first_party_data_test.go +++ b/firstpartydata/first_party_data_test.go @@ -519,6 +519,12 @@ func TestExtractBidderConfigFPD(t *testing.T) { } else { assert.Nil(t, results[bidderName].User, "user expected to be nil") } + + if expectedFPD.Device != nil { + assert.JSONEq(t, string(expectedFPD.Device), string(results[bidderName].Device), "device is incorrect") + } else { + assert.Nil(t, results[bidderName].Device, "device expected to be nil") + } } }) } @@ -625,6 +631,14 @@ func TestResolveFPD(t *testing.T) { assert.JSONEq(t, string(expectedUserExt), string(resUserExt), "user.ext is incorrect") assert.Equal(t, outputReq.User, bidderFPD.User, "User is incorrect") } + if outputReq.Device != nil && len(outputReq.Device.Ext) > 0 { + resDeviceExt := bidderFPD.Device.Ext + expectedDeviceExt := outputReq.Device.Ext + bidderFPD.Device.Ext = nil + outputReq.Device.Ext = nil + assert.JSONEq(t, string(expectedDeviceExt), string(resDeviceExt), "device.ext is incorrect") + assert.Equal(t, outputReq.Device, bidderFPD.Device, "Device is incorrect") + } } } else { assert.ElementsMatch(t, errL, testFile.ValidationErrors, "Incorrect first party data warning message") @@ -1212,6 +1226,78 @@ func TestResolveApp(t *testing.T) { } } +func TestResolveDevice(t *testing.T) { + testCases := []struct { + description string + fpdConfig *openrtb_ext.ORTB2 + bidRequestDevice *openrtb2.Device + expectedDevice *openrtb2.Device + expectError string + }{ + { + description: "FPD config and bid request device are not specified", + expectedDevice: nil, + }, + { + description: "FPD config device only is specified", + fpdConfig: &openrtb_ext.ORTB2{Device: json.RawMessage(`{"ua":"test-user-agent"}`)}, + expectedDevice: &openrtb2.Device{ + UA: "test-user-agent", + }, + }, + { + description: "FPD config and bid request device are specified", + fpdConfig: &openrtb_ext.ORTB2{Device: json.RawMessage(`{"ua":"test-user-agent-1"}`)}, + bidRequestDevice: &openrtb2.Device{UA: "test-user-agent-2"}, + expectedDevice: &openrtb2.Device{UA: "test-user-agent-1"}, + }, + { + description: "Bid request device only is specified", + bidRequestDevice: &openrtb2.Device{UA: "test-user-agent"}, + expectedDevice: &openrtb2.Device{UA: "test-user-agent"}, + }, + { + description: "FPD config device with ext, bid request device with ext", + fpdConfig: &openrtb_ext.ORTB2{Device: json.RawMessage(`{"ua":"test-user-agent-1","ext":{"test":1}}`)}, + bidRequestDevice: &openrtb2.Device{ + UA: "test-user-agent-2", + Ext: json.RawMessage(`{"test":2,"key":"value"}`), + }, + expectedDevice: &openrtb2.Device{ + UA: "test-user-agent-1", + Ext: json.RawMessage(`{"key":"value","test":1}`), + }, + }, + { + description: "Bid request device with ext only is specified", + bidRequestDevice: &openrtb2.Device{UA: "test-user-agent", Ext: json.RawMessage(`{"customData":true}`)}, + expectedDevice: &openrtb2.Device{UA: "test-user-agent", Ext: json.RawMessage(`{"customData":true}`)}, + }, + { + description: "FPD config device with malformed ext", + fpdConfig: &openrtb_ext.ORTB2{Device: json.RawMessage(`{"ua":"test-user-agent-1","ext":{malformed}}`)}, + bidRequestDevice: &openrtb2.Device{ + UA: "test-user-agent-2", + Ext: json.RawMessage(`{"test":2,"key":"value"}`), + }, + expectError: "invalid first party data ext", + }, + } + + for _, test := range testCases { + t.Run(test.description, func(t *testing.T) { + resultDevice, err := resolveDevice(test.fpdConfig, test.bidRequestDevice) + + if len(test.expectError) > 0 { + assert.EqualError(t, err, test.expectError) + } else { + assert.NoError(t, err) + assert.Equal(t, test.expectedDevice, resultDevice, "Result device is incorrect") + } + }) + } +} + func TestBuildExtData(t *testing.T) { testCases := []struct { description string diff --git a/firstpartydata/tests/extractbidderconfigfpd/bidder-config-device-data.json b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-device-data.json new file mode 100644 index 00000000000..9cd90188d9e --- /dev/null +++ b/firstpartydata/tests/extractbidderconfigfpd/bidder-config-device-data.json @@ -0,0 +1,48 @@ +{ + "description": "Extracts bidder configs for a bidder, handling device data", + "inputRequestData": { + "data": {}, + "bidderconfig": [ + { + "bidders": [ + "appnexus" + ], + "config": { + "ortb2": { + "device": { + "ext": { + "wurfl": { + "is_mobile": true, + "complete_device_name": "Google Nexus 5", + "form_factor": "Feature Phone", + "model_name": "Nexus 5", + "brand_name": "Google" + } + }, + "make": "Google", + "model": "Nexus 5" + } + } + } + } + ] + }, + "outputRequestData": {}, + "bidderConfigFPD": { + "appnexus": { + "device": { + "ext": { + "wurfl": { + "is_mobile": true, + "complete_device_name": "Google Nexus 5", + "form_factor": "Feature Phone", + "model_name": "Nexus 5", + "brand_name": "Google" + } + }, + "make": "Google", + "model": "Nexus 5" + } + } + } +} diff --git a/firstpartydata/tests/resolvefpd/bidder-fpd-only-device.json b/firstpartydata/tests/resolvefpd/bidder-fpd-only-device.json new file mode 100644 index 00000000000..ce1e0fcedd8 --- /dev/null +++ b/firstpartydata/tests/resolvefpd/bidder-fpd-only-device.json @@ -0,0 +1,46 @@ +{ + "description": "Bidder FPD defined only for device", + "inputRequestData": { + "device": { + "make": "reqMake", + "model": "reqModel" + } + }, + "biddersWithGlobalFPD": [ + "appnexus" + ], + "bidderConfigFPD": { + "appnexus": { + "device": { + "ext": { + "wurfl": { + "is_mobile": true, + "complete_device_name": "Google Nexus 5", + "form_factor": "Feature Phone", + "model_name": "Nexus 5", + "brand_name": "Google" + } + }, + "make": "Google", + "model": "Nexus 5" + } + } + }, + "outputRequestData": { + "appnexus": { + "device": { + "ext": { + "wurfl": { + "is_mobile": true, + "complete_device_name": "Google Nexus 5", + "form_factor": "Feature Phone", + "model_name": "Nexus 5", + "brand_name": "Google" + } + }, + "make": "Google", + "model": "Nexus 5" + } + } + } +} diff --git a/openrtb_ext/request.go b/openrtb_ext/request.go index 46cdb1a674a..74f4267a924 100644 --- a/openrtb_ext/request.go +++ b/openrtb_ext/request.go @@ -118,10 +118,11 @@ type Config struct { ORTB2 *ORTB2 `json:"ortb2,omitempty"` } -type ORTB2 struct { //First party data - Site json.RawMessage `json:"site,omitempty"` - App json.RawMessage `json:"app,omitempty"` - User json.RawMessage `json:"user,omitempty"` +type ORTB2 struct { // First party data + Site json.RawMessage `json:"site,omitempty"` + App json.RawMessage `json:"app,omitempty"` + User json.RawMessage `json:"user,omitempty"` + Device json.RawMessage `json:"device,omitempty"` } type ExtRequestCurrency struct { diff --git a/openrtb_ext/request_test.go b/openrtb_ext/request_test.go index c5f4abe22d4..92f148db403 100644 --- a/openrtb_ext/request_test.go +++ b/openrtb_ext/request_test.go @@ -279,6 +279,10 @@ func TestCloneExtRequestPrebid(t *testing.T) { Bidders: []string{"foo"}, Config: &Config{&ORTB2{User: json.RawMessage(`{"value":"config3"}`)}}, }, + { + Bidders: []string{"Bidder9"}, + Config: &Config{&ORTB2{Device: json.RawMessage(`{"value":"config4"}`)}}, + }, }, }, prebidCopy: &ExtRequestPrebid{ @@ -295,6 +299,10 @@ func TestCloneExtRequestPrebid(t *testing.T) { Bidders: []string{"foo"}, Config: &Config{&ORTB2{User: json.RawMessage(`{"value":"config3"}`)}}, }, + { + Bidders: []string{"Bidder9"}, + Config: &Config{&ORTB2{Device: json.RawMessage(`{"value":"config4"}`)}}, + }, }, }, mutator: func(t *testing.T, prebid *ExtRequestPrebid) { @@ -304,6 +312,7 @@ func TestCloneExtRequestPrebid(t *testing.T) { Config: &Config{nil}, } prebid.BidderConfigs[2].Config.ORTB2.User = json.RawMessage(`{"id": 345}`) + prebid.BidderConfigs[3].Config.ORTB2.Device = json.RawMessage(`{"id": 999}`) prebid.BidderConfigs = append(prebid.BidderConfigs, BidderConfig{ Bidders: []string{"bidder2"}, Config: &Config{&ORTB2{}},