diff --git a/modules/prebidServerBidAdapter/index.js b/modules/prebidServerBidAdapter/index.js index 81082a85b64..3445af6b72a 100644 --- a/modules/prebidServerBidAdapter/index.js +++ b/modules/prebidServerBidAdapter/index.js @@ -747,6 +747,18 @@ const OPEN_RTB_PROTOCOL = { utils.deepSetValue(request, 'regs.coppa', 1); } + if (!utils.isEmpty(s2sBidRequest.protected)) { + utils.deepSetValue(request, 'ext.prebid.data.bidders', s2sBidRequest.protected.allowedBidders); + + if (s2sBidRequest.protected.context) { + utils.deepSetValue(request, 'site.ext.data', s2sBidRequest.protected.context); + } + + if (s2sBidRequest.protected.user) { + utils.deepSetValue(request, 'user.ext.data', s2sBidRequest.protected.user); + } + } + return request; }, diff --git a/modules/rubiconBidAdapter.js b/modules/rubiconBidAdapter.js index 75dde239df1..f835c8437d4 100644 --- a/modules/rubiconBidAdapter.js +++ b/modules/rubiconBidAdapter.js @@ -240,6 +240,20 @@ export const spec = { utils.deepSetValue(data, 'regs.coppa', 1); } + const siteData = Object.assign({}, bidRequest.params.inventory, bidderRequest.protected.context); + const userData = Object.assign({}, bidRequest.params.visitor, bidderRequest.protected.user); + if (!utils.isEmpty(siteData) || !utils.isEmpty(userData)) { + utils.deepSetValue(data, 'ext.prebid.data.bidders', [ bidderRequest.bidderCode ]); + + if (!utils.isEmpty(siteData)) { + utils.deepSetValue(data, 'site.ext.data', siteData); + } + + if (!utils.isEmpty(userData)) { + utils.deepSetValue(data, 'user.ext.data', userData); + } + } + return { method: 'POST', url: VIDEO_ENDPOINT, @@ -400,7 +414,6 @@ export const spec = { 'tk_flint': `${configIntType || DEFAULT_INTEGRATION}_v$prebid.version$`, 'x_source.tid': bidRequest.transactionId, 'p_screen_res': _getScreenResolution(), - 'kw': Array.isArray(params.keywords) ? params.keywords.join(',') : '', 'tk_user_key': params.userId, 'p_geo.latitude': isNaN(parseFloat(latitude)) ? undefined : parseFloat(latitude).toFixed(4), 'p_geo.longitude': isNaN(parseFloat(longitude)) ? undefined : parseFloat(longitude).toFixed(4), @@ -426,22 +439,30 @@ export const spec = { } // visitor properties - if (params.visitor !== null && typeof params.visitor === 'object') { - Object.keys(params.visitor).forEach((key) => { - if (params.visitor[key] != null) { - data[`tg_v.${key}`] = params.visitor[key].toString(); // initialize array; - } - }); - } + const visitorData = Object.assign({}, params.visitor, bidderRequest.protected.user); + Object.keys(visitorData).forEach((key) => { + if (visitorData[key] != null && key !== 'keywords') { + data[`tg_v.${key}`] = typeof visitorData[key] === 'object' && !Array.isArray(visitorData[key]) + ? JSON.stringify(visitorData[key]) + : visitorData[key].toString(); // initialize array; + } + }); // inventory properties - if (params.inventory !== null && typeof params.inventory === 'object') { - Object.keys(params.inventory).forEach((key) => { - if (params.inventory[key] != null) { - data[`tg_i.${key}`] = params.inventory[key].toString(); - } - }); - } + const inventoryData = Object.assign({}, params.inventory, bidderRequest.protected.context); + Object.keys(inventoryData).forEach((key) => { + if (inventoryData[key] != null && key !== 'keywords') { + data[`tg_i.${key}`] = typeof inventoryData[key] === 'object' && !Array.isArray(inventoryData[key]) + ? JSON.stringify(inventoryData[key]) + : inventoryData[key].toString(); + } + }); + + // keywords + const keywords = (params.keywords || []).concat( + utils.deepAccess(bidderRequest, 'protected.user.keywords') || [], + utils.deepAccess(bidderRequest, 'protected.context.keywords') || []); + data.kw = keywords.length ? keywords.join(',') : ''; // digitrust properties const digitrustParams = _getDigiTrustQueryParams(bidRequest, 'FASTLANE'); diff --git a/src/adapterManager.js b/src/adapterManager.js index 6b1bc9508c8..ecc4c050fae 100644 --- a/src/adapterManager.js +++ b/src/adapterManager.js @@ -5,7 +5,7 @@ import { getLabels, resolveStatus } from './sizeMapping'; import { processNativeAdUnitParams, nativeAdapters } from './native'; import { newBidder } from './adapters/bidderFactory'; import { ajaxBuilder } from './ajax'; -import { config, RANDOM } from './config'; +import { config, PRE_BID_SERVER_KEY, RANDOM } from './config'; import includes from 'core-js/library/fn/array/includes'; import find from 'core-js/library/fn/array/find'; import { adunitCounter } from './adUnits'; @@ -309,6 +309,8 @@ adapterManager.callBids = (adUnits, bidRequests, addBidResponse, doneCb, request events.emit(CONSTANTS.EVENTS.BID_REQUESTED, bidRequest); }); + s2sBidRequest.protected = config.getProtectedData(PRE_BID_SERVER_KEY); + // make bid requests s2sAdapter.callBids( s2sBidRequest, @@ -337,6 +339,7 @@ adapterManager.callBids = (adUnits, bidRequests, addBidResponse, doneCb, request request: requestCallbacks.request.bind(null, bidRequest.bidderCode), done: requestCallbacks.done } : undefined); + bidRequest.protected = config.getProtectedData(bidRequest.bidderCode); adapter.callBids(bidRequest, addBidResponse.bind(bidRequest), doneCb.bind(bidRequest), ajax, onTimelyResponse); }); } diff --git a/src/config.js b/src/config.js index ec26c3d51d0..874c77c92d4 100644 --- a/src/config.js +++ b/src/config.js @@ -38,6 +38,9 @@ const GRANULARITY_OPTIONS = { const ALL_TOPICS = '*'; +export const PRE_BID_SERVER_KEY = 'preBidServer'; +const KNOWN_PROTECTED_KEYS = [ 'context', 'user' ]; + /** * @typedef {object} PrebidConfig * @@ -49,6 +52,7 @@ export function newConfig() { let listeners = []; let defaults; let config; + let protectedConfig; function resetConfig() { defaults = {}; @@ -182,6 +186,7 @@ export function newConfig() { } config = newConfig; + protectedConfig = {}; function hasGranularity(val) { return find(Object.keys(GRANULARITY_OPTIONS), option => val === GRANULARITY_OPTIONS[option]); @@ -223,16 +228,45 @@ export function newConfig() { return subscribe(...args); } + function getProtectedData(bidderCode) { + if (typeof bidderCode !== 'string' || !protectedConfig.hasOwnProperty(bidderCode)) { + return {}; + } + + return protectedConfig[bidderCode]; + } + /* * Sets configuration given an object containing key-value pairs and calls * listeners that were added by the `subscribe` function */ - function setConfig(options) { + function setConfig(options, allowedBiddersObj) { if (typeof options !== 'object') { utils.logError('setConfig options must be an object'); return; } + if (typeof allowedBiddersObj === 'object' && Array.isArray(allowedBiddersObj.allowedBidders) && allowedBiddersObj.allowedBidders.length) { + const allowedBidders = allowedBiddersObj.allowedBidders; + + allowedBidders.forEach(allowedBidder => { + protectedConfig[allowedBidder] = Object.assign({}, protectedConfig[allowedBidder], options); + }); + + const knownProtectedKeys = Object.keys(options).filter(key => includes(KNOWN_PROTECTED_KEYS, key)); + if (knownProtectedKeys.length) { + const knownProtectedConfigs = knownProtectedKeys.reduce((result, key) => Object.assign(result, { [key]: options[key] }), {}); + protectedConfig[PRE_BID_SERVER_KEY] = Object.assign({}, protectedConfig[PRE_BID_SERVER_KEY], knownProtectedConfigs); + const allowedBiddersSet = new Set(); + allowedBidders.concat(protectedConfig[PRE_BID_SERVER_KEY].allowedBidders || []).forEach(allowedBidder => allowedBiddersSet.add(allowedBidder)); + const uniqAllowedBidders = []; + allowedBiddersSet.forEach(uniqueBidder => uniqAllowedBidders.push(uniqueBidder)); + protectedConfig[PRE_BID_SERVER_KEY].allowedBidders = uniqAllowedBidders; + } + + return; + } + let topics = Object.keys(options); let topicalConfig = {}; @@ -331,6 +365,7 @@ export function newConfig() { return { getConfig, + getProtectedData, setConfig, setDefaults, resetConfig diff --git a/test/spec/config_spec.js b/test/spec/config_spec.js index c0f4f6bab89..20a52e9ca04 100644 --- a/test/spec/config_spec.js +++ b/test/spec/config_spec.js @@ -1,10 +1,11 @@ import { expect } from 'chai'; import { assert } from 'chai'; -import { newConfig } from 'src/config'; +import { newConfig, PRE_BID_SERVER_KEY } from 'src/config'; const utils = require('src/utils'); let getConfig; +let getProtectedData; let setConfig; let setDefaults; @@ -14,6 +15,7 @@ describe('config API', function () { beforeEach(function () { const config = newConfig(); getConfig = config.getConfig; + getProtectedData = config.getProtectedData; setConfig = config.setConfig; setDefaults = config.setDefaults; logErrorSpy = sinon.spy(utils, 'logError'); @@ -189,4 +191,40 @@ describe('config API', function () { setConfig({ bidderSequence: 'random' }); expect(logWarnSpy.called).to.equal(false); }); + + it('should store data only allowed for certain bidders separate from normal config object', () => { + setConfig({ foo: 'bar' }, { allowedBidders: [ 'rubicon', 'appnexus' ] }); + expect(getConfig('foo')).to.be.undefined; + expect(getConfig()).to.not.have.property('foo'); + }); + + it('should only return protected data that specified bidder has access to', () => { + const context = { + keywords: ['power tools'], + search: 'drill', + content: { userrating: 4 }, + data: { + pageType: 'article', + category: 'tools' + } + }; + + const user = { + keywords: ['a', 'b'], + gender: 'M', + yob: '1984', + geo: { country: 'ca' }, + data: { + registered: true, + interests: ['cars'] + } + }; + + setConfig({ context, user, foo: 'bar' }, { allowedBidders: [ 'rubicon', 'appnexus' ] }); + setConfig({ oneBidderOnly: 'secret' }, { allowedBidders: [ 'rubicon' ] }); + expect(getProtectedData()).to.be.empty; + expect(getProtectedData('rubicon')).to.deep.equal({ context, user, foo: 'bar', oneBidderOnly: 'secret' }); + expect(getProtectedData('appnexus')).to.deep.equal({ context, user, foo: 'bar', }); + expect(getProtectedData(PRE_BID_SERVER_KEY)).to.deep.equal({ context, user, allowedBidders: [ 'rubicon', 'appnexus' ] }); + }); }); diff --git a/test/spec/modules/prebidServerBidAdapter_spec.js b/test/spec/modules/prebidServerBidAdapter_spec.js index c823e5aa370..b3994de23b7 100644 --- a/test/spec/modules/prebidServerBidAdapter_spec.js +++ b/test/spec/modules/prebidServerBidAdapter_spec.js @@ -60,7 +60,8 @@ const REQUEST = { } ] } - ] + ], + protected: {} }; const VIDEO_REQUEST = { @@ -1261,7 +1262,40 @@ describe('S2S Adapter', function () { adapter.callBids(REQUEST, bidRequests, addBidResponse, done, ajax); const parsedRequestBody = JSON.parse(requests[0].requestBody); expect(parsedRequestBody.source.ext.schain).to.deep.equal(schainObject); - }) + }); + + it('passes first party data in request', () => { + const s2sBidRequest = utils.deepClone(REQUEST); + const bidRequests = utils.deepClone(BID_REQUESTS); + + const context = { + keywords: ['power tools'], + search: 'drill', + content: { userrating: 4 }, + data: { + pageType: 'article', + category: 'tools' + } + }; + const user = { + keywords: ['a', 'b'], + gender: 'M', + yob: '1984', + geo: { country: 'ca' }, + data: { + registered: true, + interests: ['cars'] + } + }; + const allowedBidders = [ 'rubicon', 'appnexus' ]; + + s2sBidRequest.protected = { context, user, allowedBidders }; + adapter.callBids(s2sBidRequest, bidRequests, addBidResponse, done, ajax); + const parsedRequestBody = JSON.parse(requests[0].requestBody); + expect(parsedRequestBody.ext.prebid.data.bidders).to.deep.equal(allowedBidders); + expect(parsedRequestBody.site.ext.data).to.deep.equal(context); + expect(parsedRequestBody.user.ext.data).to.deep.equal(user); + }); }); describe('response handler', function () { diff --git a/test/spec/modules/rubiconBidAdapter_spec.js b/test/spec/modules/rubiconBidAdapter_spec.js index 4688f0f5d32..60f4429f553 100644 --- a/test/spec/modules/rubiconBidAdapter_spec.js +++ b/test/spec/modules/rubiconBidAdapter_spec.js @@ -256,7 +256,8 @@ describe('the rubicon adapter', function () { ], start: 1472239426002, auctionStart: 1472239426000, - timeout: 5000 + timeout: 5000, + protected: {} }; sizeMap = [ @@ -861,6 +862,43 @@ describe('the rubicon adapter', function () { expect(data[key]).to.equal(value); }); }); + + it('should use protected data in bidder request over the bid params, if present', () => { + const context = { + keywords: ['e', 'f'], + rating: '4-star' + }; + const user = { + keywords: ['d'], + gender: 'M', + yob: '1984', + geo: { country: 'ca' } + }; + + const expectedQuery = { + 'kw': 'a,b,c,d,e,f', + 'tg_v.ucat': 'new', + 'tg_v.lastsearch': 'iphone', + 'tg_v.likes': 'sports,video games', + 'tg_v.gender': 'M', + 'tg_v.yob': '1984', + 'tg_v.geo': '{"country":"ca"}', + 'tg_i.rating': '4-star', + 'tg_i.prodtype': 'tech,mobile', + }; + + bidderRequest.protected = { context, user }; + + // get the built request + let [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + let data = parseQuery(request.data); + + // make sure that tg_v, tg_i, and kw values are correct + Object.keys(expectedQuery).forEach(key => { + let value = expectedQuery[key]; + expect(data[key]).to.deep.equal(value); + }); + }); }); describe('singleRequest config', function () { @@ -1393,6 +1431,28 @@ describe('the rubicon adapter', function () { const [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); expect(request.data.regs.coppa).to.equal(1); }); + + it('should include first party data', () => { + createVideoBidderRequest(); + + const context = { + keywords: ['e', 'f'], + rating: '4-star' + }; + const user = { + keywords: ['d'], + gender: 'M', + yob: '1984', + geo: { country: 'ca' } + }; + + bidderRequest.protected = { context, user }; + + const [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); + expect(request.data.ext.prebid.data.bidders).to.deep.equal([ 'rubicon' ]); + expect(request.data.site.ext.data).to.deep.equal(Object.assign({}, bidderRequest.bids[0].params.inventory, context)); + expect(request.data.user.ext.data).to.deep.equal(Object.assign({}, bidderRequest.bids[0].params.visitor, user)); + }); }); describe('combineSlotUrlParams', function () {