diff --git a/modules/prebidServerBidAdapter/bidderConfig.js b/modules/prebidServerBidAdapter/bidderConfig.js new file mode 100644 index 00000000000..44ab2f90d42 --- /dev/null +++ b/modules/prebidServerBidAdapter/bidderConfig.js @@ -0,0 +1,158 @@ +import {mergeDeep, deepEqual, deepAccess, deepSetValue, deepClone} from '../../src/utils.js'; +import {ORTB_EIDS_PATHS} from '../../src/activities/redactor.js'; + +/** + * Perform a partial pre-merge of bidder config for PBS. + * + * Prebid.js and Prebid Server use different strategies for merging global and bidder-specific config; JS attemps to + * merge arrays (concatenating them, with some deduping, cfr. mergeDeep), while PBS only merges objects - + * a bidder-specific array will replace a global array. + * + * This returns bidder config (from `bidder`) where arrays are replaced with what you get from merging them with `global`, + * so that the result of merging in PBS is the same as in JS. + */ +export function getPBSBidderConfig({global, bidder}) { + return Object.fromEntries( + Object.entries(bidder).map(([bidderCode, bidderConfig]) => { + return [bidderCode, replaceArrays(bidderConfig, mergeDeep({}, global, bidderConfig))] + }) + ) +} + +function replaceArrays(config, mergedConfig) { + return Object.fromEntries( + Object.entries(config).map(([key, value]) => { + const mergedValue = mergedConfig[key]; + if (Array.isArray(value)) { + if (!deepEqual(value, mergedValue) && Array.isArray(mergedValue)) { + value = mergedValue; + } + } else if (value != null && typeof value === 'object') { + value = replaceArrays(value, mergedValue); + } + return [key, value]; + }) + ) +} + +/** + * Extract all EIDs from FPD. + * + * Returns {eids, conflicts}, where: + * + * - `eids` contains an object of the form `{eid, bidders}` for each unique EID object found anywhere in FPD; + * `bidders` is a list of all the bidders that refer to that specific EID object, or false if that EID object is defined globally. + * - `conflicts` is a set containing all EID sources that appear in multiple, otherwise different, EID objects. + */ +export function extractEids({global, bidder}) { + const entries = []; + const bySource = {}; + const conflicts = new Set() + + function getEntry(eid) { + let entry = entries.find((candidate) => deepEqual(candidate.eid, eid)); + if (entry == null) { + entry = {eid, bidders: []} + entries.push(entry); + } + if (bySource[eid.source] == null) { + bySource[eid.source] = entry.eid; + } else if (entry.eid === eid) { + // if this is the first time we see this eid, but not the first time we see its source, we have a conflict + conflicts.add(eid.source); + } + return entry; + } + + ORTB_EIDS_PATHS.forEach(path => { + (deepAccess(global, path) || []).forEach(eid => { + getEntry(eid).bidders = false; + }); + }) + Object.entries(bidder).forEach(([bidderCode, bidderConfig]) => { + ORTB_EIDS_PATHS.forEach(path => { + (deepAccess(bidderConfig, path) || []).forEach(eid => { + const entry = getEntry(eid); + if (entry.bidders !== false) { + entry.bidders.push(bidderCode); + } + }) + }) + }) + return {eids: entries, conflicts}; +} + +/** + * Consolidate extracted EIDs to take advantage of PBS's eidpermissions feature: + * https://docs.prebid.org/prebid-server/endpoints/openrtb2/pbs-endpoint-auction.html#eid-permissions + * + * If different bidders have different EID configurations, in most cases we can avoid repeating it in each bidder's + * specific config. As long as there are no conflicts (different EID objects that refer to the same source constitute a conflict), + * the EID can be set as global, and eidpermissions can restrict its access only to specific bidders. + * + * Returns {global, bidder, permissions}, where: + * - `global` is a list of global EID objects (some of which may be restricted through `permissions` + * - `bidder` is a map from bidder code to EID objects that are specific to that bidder, and cannot be restricted through `permissions` + * - `permissions` is a list of EID permissions as expected by PBS. + */ +export function consolidateEids({eids, conflicts = new Set()}) { + const globalEntries = []; + const bidderEntries = []; + const byBidder = {}; + eids.forEach(eid => { + (eid.bidders === false ? globalEntries : bidderEntries).push(eid); + }); + bidderEntries.forEach(({eid, bidders}) => { + if (!conflicts.has(eid.source)) { + globalEntries.push({eid, bidders}) + } else { + bidders.forEach(bidderCode => { + (byBidder[bidderCode] = byBidder[bidderCode] || []).push(eid) + }) + } + }); + return { + global: globalEntries.map(({eid}) => eid), + permissions: globalEntries.filter(({bidders}) => bidders !== false).map(({eid, bidders}) => ({ + source: eid.source, + bidders + })), + bidder: byBidder + } +} + +function replaceEids({global, bidder}) { + const consolidated = consolidateEids(extractEids({global, bidder})); + global = deepClone(global); + bidder = deepClone(bidder); + function removeEids(target) { + delete target?.user?.eids; + delete target?.user?.ext?.eids; + } + removeEids(global); + Object.values(bidder).forEach(removeEids); + if (consolidated.global.length) { + deepSetValue(global, 'user.ext.eids', consolidated.global); + } + if (consolidated.permissions.length) { + deepSetValue(global, 'ext.prebid.data.eidpermissions', consolidated.permissions); + } + Object.entries(consolidated.bidder).forEach(([bidderCode, bidderEids]) => { + if (bidderEids.length) { + deepSetValue(bidder[bidderCode], 'user.ext.eids', bidderEids); + } + }) + return {global, bidder} +} + +export function premergeFpd(ortb2Fragments) { + if (ortb2Fragments == null || Object.keys(ortb2Fragments.bidder || {}).length === 0) { + return ortb2Fragments; + } else { + ortb2Fragments = replaceEids(ortb2Fragments); + return { + ...ortb2Fragments, + bidder: getPBSBidderConfig(ortb2Fragments) + }; + } +} diff --git a/modules/prebidServerBidAdapter/ortbConverter.js b/modules/prebidServerBidAdapter/ortbConverter.js index 9de7d3f05c9..2cc34586102 100644 --- a/modules/prebidServerBidAdapter/ortbConverter.js +++ b/modules/prebidServerBidAdapter/ortbConverter.js @@ -16,6 +16,7 @@ import {ACTIVITY_TRANSMIT_TID} from '../../src/activities/activities.js'; import {currencyCompare} from '../../libraries/currencyUtils/currency.js'; import {minimum} from '../../src/utils/reducers.js'; import {s2sDefaultConfig} from './index.js'; +import {premergeFpd} from './bidderConfig.js'; const DEFAULT_S2S_TTL = 60; const DEFAULT_S2S_CURRENCY = 'USD'; @@ -296,7 +297,10 @@ export function buildPBSRequest(s2sBidRequest, bidderRequests, adUnits, requeste currency: config.getConfig('currency.adServerCurrency') || DEFAULT_S2S_CURRENCY, ttl: s2sBidRequest.s2sConfig.defaultTtl || DEFAULT_S2S_TTL, requestTimestamp, - s2sBidRequest, + s2sBidRequest: { + ...s2sBidRequest, + ortb2Fragments: premergeFpd(s2sBidRequest.ortb2Fragments) + }, requestedBidders, actualBidderRequests: bidderRequests, nativeRequest: s2sBidRequest.s2sConfig.ortbNative, diff --git a/test/spec/modules/prebidServerBidAdapter_spec.js b/test/spec/modules/prebidServerBidAdapter_spec.js index dcd03a8882b..058175b878c 100644 --- a/test/spec/modules/prebidServerBidAdapter_spec.js +++ b/test/spec/modules/prebidServerBidAdapter_spec.js @@ -37,6 +37,11 @@ import {syncAddFPDToBidderRequest} from '../../helpers/fpd.js'; import {deepSetValue} from '../../../src/utils.js'; import {ACTIVITY_TRANSMIT_UFPD} from '../../../src/activities/activities.js'; import {MODULE_TYPE_PREBID} from '../../../src/activities/modules.js'; +import { + consolidateEids, + extractEids, + getPBSBidderConfig +} from '../../../modules/prebidServerBidAdapter/bidderConfig.js'; let CONFIG = { accountId: '1', @@ -2065,37 +2070,95 @@ describe('S2S Adapter', function () { }); }); - it('should pass user.ext.eids from FPD', function () { - config.setConfig({s2sConfig: CONFIG}); - const req = { - ...REQUEST, - ortb2Fragments: { - global: { - user: { - ext: { - eids: [{id: 1}, {id: 2}] - } - } - }, - bidder: { - appnexus: { + describe('user.ext.eids', () => { + let req; + beforeEach(() => { + const s2sConfig = { + ...CONFIG, + bidders: ['appnexus', 'rubicon'] + } + config.setConfig({s2sConfig}); + req = { + ...REQUEST, + s2sConfig, + ortb2Fragments: { + global: { user: { ext: { - eids: [{id: 3}] + eids: [{source: 'idA', id: 1}, {source: 'idB', id: 2}] + } + } + }, + bidder: { + appnexus: { + user: { + ext: { + eids: [{source: 'idC', id: 3}] + } } } } } } - } - adapter.callBids(req, BID_REQUESTS, addBidResponse, done, ajax); - const payload = JSON.parse(server.requests[0].requestBody); - expect(payload.user.ext.eids).to.eql([{id: 1}, {id: 2}]); - expect(payload.ext.prebid.bidderconfig).to.eql([{ - bidders: ['appnexus'], - config: {ortb2: {user: {ext: {eids: [{id: 3}]}}}} - }]); - }); + }) + it('should get picked up from from FPD', function () { + adapter.callBids(req, BID_REQUESTS, addBidResponse, done, ajax); + const payload = JSON.parse(server.requests[0].requestBody); + expect(payload.user.ext.eids).to.eql([ + {source: 'idA', id: 1}, + {source: 'idB', id: 2}, + {source: 'idC', id: 3} + ]); + expect(payload.ext.prebid.data.eidpermissions).to.eql([{ + bidders: ['appnexus'], + source: 'idC' + }]); + }); + + it('should repeat global EIDs when bidder-specific EIDs conflict', () => { + BID_REQUESTS.push({ + ...BID_REQUESTS[0], + bidderCode: 'rubicon', + bids: [{ + bidder: 'rubicon', + params: {} + }] + }) + req.ortb2Fragments.bidder.rubicon = { + user: { + ext: { + eids: [{source: 'idC', id: 4}] + } + } + } + adapter.callBids(req, BID_REQUESTS, addBidResponse, done, ajax); + const payload = JSON.parse(server.requests[0].requestBody); + const globalEids = [ + {source: 'idA', id: 1}, + {source: 'idB', id: 2}, + ] + expect(payload.user.ext.eids).to.eql(globalEids); + expect(payload.ext.prebid?.data?.eidpermissions).to.not.exist; + expect(payload.ext.prebid.bidderconfig).to.have.deep.members([ + { + bidders: ['appnexus'], + config: { + ortb2: { + user: {ext: {eids: globalEids.concat([{source: 'idC', id: 3}])}} + } + } + }, + { + bidders: ['rubicon'], + config: { + ortb2: { + user: {ext: {eids: globalEids.concat([{source: 'idC', id: 4}])}} + } + } + } + ]) + }) + }) it('when config \'currency.adServerCurrency\' value is a string: ORTB has property \'cur\' value set to a single item array', function () { config.setConfig({ @@ -4305,4 +4368,350 @@ describe('S2S Adapter', function () { expect(requestBid.ext.prebid.floors).to.deep.equal({ enabled: true, floorMin: 1, floorMinCur: 'CUR' }); }); }); + + describe('getPBSBidderConfig', () => { + [ + { + t: 'does not alter config when there are no conflicts', + global: { + k1: 'val' + }, + bidder: { + bidderA: { + k2: 'val' + } + }, + expected: { + bidderA: { + k2: 'val' + } + } + }, + { + t: 'uses bidder config on type mismatch (scalar/object)', + global: { + k1: 'val', + k2: 'val' + }, + bidder: { + bidderA: { + k1: {k3: 'val'} + } + }, + expected: { + bidderA: { + k1: {k3: 'val'} + } + } + }, + { + t: 'uses bidder config on type mismatch (array/object)', + global: { + k: [1, 2] + }, + bidder: { + bidderA: { + k: {inner: 'val'} + } + }, + expected: { + bidderA: { + k: {inner: 'val'} + } + } + }, + { + t: 'uses bidder config on type mismatch (object/array)', + global: { + k: {inner: 'val'} + }, + bidder: { + bidderA: { + k: [1, 2] + } + }, + expected: { + bidderA: { + k: [1, 2] + } + } + }, + { + t: 'uses bidder config on type mismatch (array/null)', + global: { + k: [1, 2] + }, + bidder: { + bidderA: { + k: null + } + }, + expected: { + bidderA: { + k: null + } + } + }, + { + t: 'uses bidder config on type mismatch (null/array)', + global: {}, + bidder: { + bidderA: { + k: [1, 2] + } + }, + expected: { + bidderA: { + k: [1, 2] + } + } + }, + { + t: 'concatenates arrays', + global: { + key: 'value', + array: [1] + }, + bidder: { + bidderA: { + array: [2] + } + }, + expected: { + bidderA: { + array: [1, 2] + } + } + }, + { + t: 'concatenates nested arrays', + global: { + nested: { + array: [1] + } + }, + bidder: { + bidderA: { + key: 'value', + nested: { + array: [2] + } + } + }, + expected: { + bidderA: { + key: 'value', + nested: { + array: [1, 2] + } + } + } + }, + { + t: 'does not repeat equal elements', + global: { + array: [{id: 1}] + }, + bidder: { + bidderA: { + array: [{id: 1}, {id: 2}] + } + }, + expected: { + bidderA: { + array: [{id: 1}, {id: 2}] + } + } + } + ].forEach(({t, global, bidder, expected}) => { + it(t, () => { + expect(getPBSBidderConfig({global, bidder})).to.eql(expected); + }) + }) + }); + describe('EID handling', () => { + function mkEid(source, value = source) { + return {source, value}; + } + + function eidEntry(source, value = source, bidders = false) { + return {eid: {source, value}, bidders}; + } + + describe('extractEids', () => { + [ + { + t: 'no bidder-specific eids', + global: { + user: { + ext: { + eids: [ + mkEid('idA', 'id1'), + mkEid('idA', 'id2') + ] + }, + eids: [mkEid('idB')] + } + }, + expected: { + eids: [ + eidEntry('idA', 'id1'), + eidEntry('idA', 'id2'), + eidEntry('idB') + ], + conflicts: ['idA'] + } + }, + { + t: 'bidder-specific eids', + global: { + user: { + eids: [ + mkEid('idA') + ] + }, + }, + bidder: { + bidderA: { + user: { + ext: { + eids: [ + mkEid('idB') + ] + } + } + } + }, + expected: { + eids: [ + eidEntry('idA'), + eidEntry('idB', 'idB', ['bidderA']) + ] + } + }, + { + t: 'conflicting bidder-specific eids', + global: { + user: { + eids: [mkEid('idA', 'idA1')] + }, + }, + bidder: { + bidderA: { + user: { + eids: [mkEid('idA', 'idA2'), mkEid('idB', 'idB1'), mkEid('idD')] + }, + }, + bidderB: { + user: { + ext: { + eids: [mkEid('idB', 'idB2'), mkEid('idC'), mkEid('idD')] + } + } + }, + }, + expected: { + eids: [ + eidEntry('idA', 'idA1'), + eidEntry('idA', 'idA2', ['bidderA']), + eidEntry('idB', 'idB1', ['bidderA']), + eidEntry('idB', 'idB2', ['bidderB']), + eidEntry('idC', 'idC', ['bidderB']), + eidEntry('idD', 'idD', ['bidderA', 'bidderB']) + ], + conflicts: ['idA', 'idB'] + } + } + ].forEach(({t, global = {}, bidder = {}, expected}) => { + it(t, () => { + const {eids, conflicts} = extractEids({global, bidder}); + expect(eids).to.have.deep.members(expected.eids); + expect(Array.from(conflicts)).to.have.members(expected.conflicts || []); + }) + }); + }); + describe('consolidateEids', () => { + it('returns global EIDs without permissions', () => { + expect(consolidateEids({ + eids: [eidEntry('idA'), eidEntry('idB')] + })).to.eql({ + global: [mkEid('idA'), mkEid('idB')], + permissions: [], + bidder: {} + }) + }); + + it('returns conflicting, but global EIDs', () => { + expect(consolidateEids({ + eids: [eidEntry('idA', 'idA1'), eidEntry('idA', 'idA2')], + conflicts: new Set(['idA']) + })).to.eql({ + global: [mkEid('idA', 'idA1'), mkEid('idA', 'idA2')], + permissions: [], + bidder: {} + }) + }) + + it('sets permissions for bidder-speficic EIDS', () => { + expect(consolidateEids({ + eids: [ + eidEntry('idA'), + eidEntry('idB', 'idB', ['bidderB']) + ] + })).to.eql({ + global: [mkEid('idA'), mkEid('idB')], + permissions: [{source: 'idB', bidders: ['bidderB']}], + bidder: {} + }) + }) + + it('does not consolidate conflicting bidder-specific EIDs', () => { + expect(consolidateEids({ + eids: [ + eidEntry('global'), + eidEntry('idA', 'idA1', ['bidderA']), + eidEntry('idA', 'idA2', ['bidderB']) + ], + conflicts: new Set(['idA']) + })).to.eql({ + global: [mkEid('global')], + permissions: [], + bidder: { + bidderA: [mkEid('idA', 'idA1')], + bidderB: [mkEid('idA', 'idA2')] + } + }) + }) + + it('does not set permissions for conflicting bidder-specific eids', () => { + expect(consolidateEids({ + eids: [eidEntry('idA', 'idA1'), eidEntry('idA', 'idA2', ['bidderA'])], + conflicts: new Set(['idA']) + })).to.eql({ + global: [mkEid('idA', 'idA1')], + permissions: [], + bidder: { + bidderA: [mkEid('idA', 'idA2')] + } + }) + }); + + it('can do partial consolidation when only some IDs are conflicting', () => { + expect(consolidateEids({ + eids: [ + eidEntry('idA', 'idA1'), + eidEntry('idB', 'idB', ['bidderB']), + eidEntry('idA', 'idA2', ['bidderA']) + ], + conflicts: new Set(['idA']) + })).to.eql({ + global: [mkEid('idA', 'idA1'), mkEid('idB')], + permissions: [{source: 'idB', bidders: ['bidderB']}], + bidder: { + bidderA: [mkEid('idA', 'idA2')] + } + }) + }) + }) + }); });