diff --git a/modules/prebidServerBidAdapter.js b/modules/prebidServerBidAdapter.js new file mode 100644 index 00000000000..0328e8f1abc --- /dev/null +++ b/modules/prebidServerBidAdapter.js @@ -0,0 +1,343 @@ +import Adapter from 'src/adapter'; +import bidfactory from 'src/bidfactory'; +import * as utils from 'src/utils'; +import { ajax } from 'src/ajax'; +import { STATUS, S2S } from 'src/constants'; +import { cookieSet } from 'src/cookie.js'; +import adaptermanager from 'src/adaptermanager'; +import { config } from 'src/config'; +import { VIDEO } from 'src/mediaTypes'; +import CONSTANTS from 'src/constants.json'; + +const getConfig = config.getConfig; + +const TYPE = S2S.SRC; +const cookieSetUrl = 'https://acdn.adnxs.com/cookieset/cs.js'; +let _synced = false; + +let _s2sConfigDefaults = { + enabled: false, + endpoint: CONSTANTS.S2S.DEFAULT_ENDPOINT, + timeout: 1000, + maxBids: 1, + adapter: CONSTANTS.S2S.ADAPTER, + syncEndpoint: CONSTANTS.S2S.SYNC_ENDPOINT, + cookieSet: true, + bidders: [] +}; +let _s2sConfig = _s2sConfigDefaults; + +/** + * Set config for server to server header bidding + * @typedef {Object} options - required + * @property {boolean} enabled enables S2S bidding + * @property {string[]} bidders bidders to request S2S + * === optional params below === + * @property {string} [endpoint] endpoint to contact + * @property {number} [timeout] timeout for S2S bidders - should be lower than `pbjs.requestBids({timeout})` + * @property {string} [adapter] adapter code to use for S2S + * @property {string} [syncEndpoint] endpoint URL for syncing cookies + * @property {boolean} [cookieSet] enables cookieSet functionality + */ +export function setS2sConfig(options) { + let keys = Object.keys(options); + if (!keys.includes('accountId')) { + utils.logError('accountId missing in Server to Server config'); + return; + } + + if (!keys.includes('bidders')) { + utils.logError('bidders missing in Server to Server config'); + return; + } + _s2sConfig = Object.assign({}, _s2sConfigDefaults, options); + if (options.syncEndpoint) { + queueSync(options.bidders); + } +} +getConfig('s2sConfig', ({s2sConfig}) => setS2sConfig(s2sConfig)); + +/** + * @param {Array} bidderCodes list of bidders to request user syncs for. + */ +function queueSync(bidderCodes) { + if (_synced) { + return; + } + _synced = true; + const payload = JSON.stringify({ + uuid: utils.generateUUID(), + bidders: bidderCodes + }); + ajax(_s2sConfig.syncEndpoint, (response) => { + try { + response = JSON.parse(response); + response.bidder_status.forEach(bidder => doBidderSync(bidder.usersync.type, bidder.usersync.url, bidder.bidder)); + } catch (e) { + utils.logError(e); + } + }, + payload, { + contentType: 'text/plain', + withCredentials: true + }); +} + +/** + * Try to convert a value to a type. + * If it can't be done, the value will be returned. + * + * @param {string} typeToConvert The target type. e.g. "string", "number", etc. + * @param {*} value The value to be converted into typeToConvert. + */ +function tryConvertType(typeToConvert, value) { + if (typeToConvert === 'string') { + return value && value.toString(); + } else if (typeToConvert === 'number') { + return Number(value); + } else { + return value; + } +} + +const tryConvertString = tryConvertType.bind(null, 'string'); +const tryConvertNumber = tryConvertType.bind(null, 'number'); + +const paramTypes = { + 'appnexus': { + 'member': tryConvertString, + 'invCode': tryConvertString, + 'placementId': tryConvertNumber + }, + 'rubicon': { + 'accountId': tryConvertNumber, + 'siteId': tryConvertNumber, + 'zoneId': tryConvertNumber + }, + 'indexExchange': { + 'siteID': tryConvertNumber + }, + 'audienceNetwork': { + 'placementId': tryConvertString + }, + 'pubmatic': { + 'publisherId': tryConvertString, + 'adSlot': tryConvertString + }, + 'districtm': { + 'member': tryConvertString, + 'invCode': tryConvertString, + 'placementId': tryConvertNumber + }, + 'pulsepoint': { + 'cf': tryConvertString, + 'cp': tryConvertNumber, + 'ct': tryConvertNumber + }, +}; + +let _cookiesQueued = false; + +/** + * Bidder adapter for Prebid Server + */ +export function PrebidServer() { + let baseAdapter = new Adapter('prebidServer'); + + function convertTypes(adUnits) { + adUnits.forEach(adUnit => { + adUnit.bids.forEach(bid => { + const types = paramTypes[bid.bidder] || []; + Object.keys(types).forEach(key => { + if (bid.params[key]) { + const converted = types[key](bid.params[key]); + if (converted !== bid.params[key]) { + utils.logMessage(`Mismatched type for Prebid Server : ${bid.bidder} : ${key}. Required Type:${types[key]}`); + } + bid.params[key] = converted; + + // don't send invalid values + if (isNaN(bid.params[key])) { + delete bid.params.key; + } + } + }); + }); + }); + } + + /* Prebid executes this function when the page asks to send out bid requests */ + baseAdapter.callBids = function(bidRequest, bidRequests, addBidResponse, done, ajax) { + const isDebug = !!getConfig('debug'); + const adUnits = utils.cloneJson(bidRequest.ad_units); + adUnits.forEach(adUnit => { + let videoMediaType = utils.deepAccess(adUnit, 'mediaTypes.video'); + if (videoMediaType) { + // pbs expects a ad_unit.video attribute if the imp is video + adUnit.video = Object.assign({}, videoMediaType); + delete adUnit.mediaTypes; + // default is assumed to be 'banner' so if there is a video type we assume video only until PBS can support multi format auction. + adUnit.media_types = [VIDEO]; + } + }); + convertTypes(adUnits); + let requestJson = { + account_id: _s2sConfig.accountId, + tid: bidRequest.tid, + max_bids: _s2sConfig.maxBids, + timeout_millis: _s2sConfig.timeout, + secure: _s2sConfig.secure, + url: utils.getTopWindowUrl(), + prebid_version: '$prebid.version$', + ad_units: adUnits.filter(hasSizes), + is_debug: isDebug + }; + + // in case config.bidders contains invalid bidders, we only process those we sent requests for. + const requestedBidders = requestJson.ad_units.map(adUnit => adUnit.bids.map(bid => bid.bidder).filter(utils.uniques)).reduce(utils.flatten).filter(utils.uniques); + function processResponse(response) { + handleResponse(response, requestedBidders, bidRequests, addBidResponse, done); + } + const payload = JSON.stringify(requestJson); + ajax(_s2sConfig.endpoint, processResponse, payload, { + contentType: 'text/plain', + withCredentials: true + }); + }; + + // at this point ad units should have a size array either directly or mapped so filter for that + function hasSizes(unit) { + return unit.sizes && unit.sizes.length; + } + + /** + * Run a cookie sync for the given type, url, and bidder + * + * @param {string} type the type of sync, "image", "redirect", "iframe" + * @param {string} url the url to sync + * @param {string} bidder name of bidder doing sync for + */ + function doBidderSync(type, url, bidder) { + if (!url) { + utils.logError(`No sync url for bidder "${bidder}": ${url}`); + } else if (type === 'image' || type === 'redirect') { + utils.logMessage(`Invoking image pixel user sync for bidder: "${bidder}"`); + utils.triggerPixel(url); + } else if (type == 'iframe') { + utils.logMessage(`Invoking iframe user sync for bidder: "${bidder}"`); + utils.insertUserSyncIframe(url); + } else { + utils.logError(`User sync type "${type}" not supported for bidder: "${bidder}"`); + } + } + + /* Notify Prebid of bid responses so bids can get in the auction */ + function handleResponse(response, requestedBidders, bidRequests, addBidResponse, done) { + let result; + try { + result = JSON.parse(response); + + if (result.status === 'OK' || result.status === 'no_cookie') { + if (result.bidder_status) { + result.bidder_status.forEach(bidder => { + if (bidder.no_cookie && !_cookiesQueued) { + doBidderSync(bidder.usersync.type, bidder.usersync.url, bidder.bidder); + } + }); + } + + // do client-side syncs if available + requestedBidders.forEach(bidder => { + let clientAdapter = adaptermanager.getBidAdapter(bidder); + if (clientAdapter && clientAdapter.registerSyncs) { + clientAdapter.registerSyncs(); + } + }); + + if (result.bids) { + result.bids.forEach(bidObj => { + let bidRequest = utils.getBidRequest(bidObj.bid_id, bidRequests); + let cpm = bidObj.price; + let status; + if (cpm !== 0) { + status = STATUS.GOOD; + } else { + status = STATUS.NO_BID; + } + + let bidObject = bidfactory.createBid(status, bidRequest); + bidObject.source = TYPE; + bidObject.creative_id = bidObj.creative_id; + bidObject.bidderCode = bidObj.bidder; + bidObject.cpm = cpm; + // From ORTB see section 4.2.3: adm Optional means of conveying ad markup in case the bid wins; supersedes the win notice if markup is included in both. + if (bidObj.media_type === VIDEO) { + bidObject.mediaType = VIDEO; + if (bidObj.adm) { + bidObject.vastXml = bidObj.adm; + } + if (bidObj.nurl) { + bidObject.vastUrl = bidObj.nurl; + } + } else { + if (bidObj.adm && bidObj.nurl) { + bidObject.ad = bidObj.adm; + bidObject.ad += utils.createTrackPixelHtml(decodeURIComponent(bidObj.nurl)); + } else if (bidObj.adm) { + bidObject.ad = bidObj.adm; + } else if (bidObj.nurl) { + bidObject.adUrl = bidObj.nurl + } + } + + bidObject.width = bidObj.width; + bidObject.height = bidObj.height; + bidObject.adserverTargeting = bidObj.ad_server_targeting; + if (bidObj.deal_id) { + bidObject.dealId = bidObj.deal_id; + } + + addBidResponse(bidObj.code, bidObject); + }); + } + + // const receivedBidIds = result.bids ? result.bids.map(bidObj => bidObj.bid_id) : []; + + // issue a no-bid response for every bid request that can not be matched with received bids + // requestedBidders.forEach(bidder => { + // utils + // .getBidderRequestAllAdUnits(bidder) + // .bids.filter(bidRequest => !receivedBidIds.includes(bidRequest.bidId)) + // .forEach(bidRequest => { + // let bidObject = bidfactory.createBid(STATUS.NO_BID, bidRequest); + // bidObject.source = TYPE; + // bidObject.adUnitCode = bidRequest.placementCode; + // bidObject.bidderCode = bidRequest.bidder; + // addBidResponse(bidObject.adUnitCode, bidObject); + // }); + // }); + } + if (result.status === 'no_cookie' && _s2sConfig.cookieSet) { + // cookie sync + cookieSet(cookieSetUrl); + } + } catch (error) { + utils.logError(error); + } + + if (!result || (result.status && result.status.includes('Error'))) { + utils.logError('error parsing response: ', result.status); + } + + done(); + } + + return Object.assign(this, { + callBids: baseAdapter.callBids, + setBidderCode: baseAdapter.setBidderCode, + type: TYPE + }); +} + +adaptermanager.registerBidAdapter(new PrebidServer(), 'prebidServer'); + diff --git a/src/adaptermanager.js b/src/adaptermanager.js index c6568c0c9f7..b6fa2d0475b 100644 --- a/src/adaptermanager.js +++ b/src/adaptermanager.js @@ -15,7 +15,10 @@ let s2sTestingModule; // store s2sTesting module if it's loaded var _bidderRegistry = {}; exports.bidderRegistry = _bidderRegistry; -let _s2sConfig = config.getConfig('s2sConfig'); +let _s2sConfig = {}; +config.getConfig('s2sConfig', config => { + _s2sConfig = config.s2sConfig; +}); var _analyticsRegistry = {}; @@ -100,6 +103,7 @@ function getAdUnitCopyForPrebidServer(adUnits) { delete adUnit.sizeMapping; } adUnit.sizes = transformHeightWidth(adUnit); + let s2sTesting = false; adUnit.bids = adUnit.bids.filter((bid) => { return adaptersServerSide.includes(bid.bidder) && (!s2sTesting || bid.finalSource !== s2sTestingModule.CLIENT); }).map((bid) => { @@ -122,12 +126,6 @@ exports.makeBidRequests = function(adUnits, auctionStart, auctionId, cbTimeout, bidderCodes = shuffle(bidderCodes); } - const s2sAdapter = _bidderRegistry[_s2sConfig.adapter]; - if (s2sAdapter) { - s2sAdapter.setConfig(_s2sConfig); - s2sAdapter.queueSync({bidderCodes}); - } - let clientTestAdapters = []; let s2sTesting = false; if (_s2sConfig.enabled) { @@ -186,7 +184,7 @@ exports.makeBidRequests = function(adUnits, auctionStart, auctionId, cbTimeout, bidderCode, auctionId, bidderRequestId, - bids: getBids({bidderCode, auctionId, bidderRequestId, adUnits, labels}), + bids: getBids({bidderCode, auctionId, bidderRequestId, 'adUnits': adUnitsClientCopy, labels}), auctionStart: auctionStart, timeout: cbTimeout }; @@ -198,26 +196,43 @@ exports.makeBidRequests = function(adUnits, auctionStart, auctionId, cbTimeout, }; exports.callBids = (adUnits, bidRequests, addBidResponse, doneCb) => { - let serverBidRequests = bidRequests.filter(bidRequest => { - return bidRequest.src && bidRequest.src === CONSTANTS.S2S.SRC; - }); + if (bidRequests.length) { + let ajax = ajaxBuilder(bidRequests[0].timeout); - if (serverBidRequests.length) { - let adaptersServerSide = _s2sConfig.bidders; - const s2sAdapter = _bidderRegistry[_s2sConfig.adapter]; - let tid = serverBidRequests[0].tid; - - if (s2sAdapter) { - let s2sBidRequest = {tid, 'ad_units': getAdUnitCopyForPrebidServer(adUnits)}; - utils.logMessage(`CALLING S2S HEADER BIDDERS ==== ${adaptersServerSide.join(',')}`); - if (s2sBidRequest.ad_units.length) { - s2sAdapter.callBids(s2sBidRequest); + let [clientBidRequests, serverBidRequests] = bidRequests.reduce((partitions, bidRequest) => { + partitions[ + Number(typeof bidRequest.src !== 'undefined' && bidRequest.src === CONSTANTS.S2S.SRC) + ].push(bidRequest); + return partitions; + }, [[], []]); + + if (serverBidRequests.length) { + let adaptersServerSide = _s2sConfig.bidders; + const s2sAdapter = _bidderRegistry[_s2sConfig.adapter]; + let tid = serverBidRequests[0].tid; + + if (s2sAdapter) { + let s2sBidRequest = {tid, 'ad_units': getAdUnitCopyForPrebidServer(adUnits)}; + utils.logMessage(`CALLING S2S HEADER BIDDERS ==== ${adaptersServerSide.join(',')}`); + + let doneCbs = serverBidRequests.map(bidRequest => { + bidRequest.doneCbCallCount = 0; + return doneCb(bidRequest.bidderRequestId) + }); + + if (s2sBidRequest.ad_units.length) { + s2sAdapter.callBids( + s2sBidRequest, + serverBidRequests, + addBidResponse, + () => doneCbs.forEach(done => done()), + ajax + ); + } } } - } - if (bidRequests.length) { - let ajax = ajaxBuilder(bidRequests[0].timeout); - bidRequests.forEach(bidRequest => { + + clientBidRequests.forEach(bidRequest => { bidRequest.start = new Date().getTime(); // TODO : Do we check for bid in pool from here and skip calling adapter again ? const adapter = _bidderRegistry[bidRequest.bidderCode]; @@ -340,6 +355,10 @@ exports.enableAnalytics = function (config) { }); }; +exports.getBidAdapter = function(bidder) { + return _bidderRegistry[bidder]; +}; + // the s2sTesting module is injected when it's loaded rather than being imported // importing it causes the packager to include it even when it's not explicitly included in the build exports.setS2STestingModule = function (module) { diff --git a/src/config.js b/src/config.js index bbde171576c..b5a2627730a 100644 --- a/src/config.js +++ b/src/config.js @@ -22,16 +22,6 @@ const DEFAULT_USERSYNC = { syncDelay: 3000 }; const DEFAULT_TIMEOUTBUFFER = 200; -const DEFAULT_S2SCONFIG = { - enabled: false, - endpoint: 'https://prebid.adnxs.com/pbs/v1/auction', - timeout: 1000, - maxBids: 1, - adapter: 'prebidServer', - syncEndpoint: 'https://prebid.adnxs.com/pbs/v1/cookie_sync', - cookieSet: true, - bidders: [] -}; export const RANDOM = 'random'; const FIXED = 'fixed'; @@ -152,24 +142,6 @@ export function newConfig() { this._timoutBuffer = val; }, - _s2sConfig: DEFAULT_S2SCONFIG, - get s2sConfig() { - return this._s2sConfig; - }, - set s2sConfig(val) { - if (!utils.contains(Object.keys(val), 'accountId')) { - utils.logError('accountId missing in Server to Server config'); - return; - } - - if (!utils.contains(Object.keys(val), 'bidders')) { - utils.logError('bidders missing in Server to Server config'); - return; - } - - this._s2sConfig = Object.assign({}, DEFAULT_S2SCONFIG, val); - }, - // userSync defaults userSync: DEFAULT_USERSYNC }; diff --git a/test/spec/unit/pbjs_api_spec.js b/test/spec/unit/pbjs_api_spec.js index 613074e59f9..7622cafa6e1 100644 --- a/test/spec/unit/pbjs_api_spec.js +++ b/test/spec/unit/pbjs_api_spec.js @@ -1592,41 +1592,41 @@ describe('Unit: Prebid Module', function () { }); }); - describe('setS2SConfig', () => { - let logErrorSpy; - - beforeEach(() => { - logErrorSpy = sinon.spy(utils, 'logError'); - }); - - afterEach(() => { - utils.logError.restore(); - }); - - it('should log error when accountId is missing', () => { - const options = { - enabled: true, - bidders: ['appnexus'], - timeout: 1000, - adapter: 'prebidServer', - endpoint: 'https://prebid.adnxs.com/pbs/v1/auction' - }; - - $$PREBID_GLOBAL$$.setConfig({ s2sConfig: {options} }); - assert.ok(logErrorSpy.calledOnce, true); - }); - - it('should log error when bidders is missing', () => { - const options = { - accountId: '1', - enabled: true, - timeout: 1000, - adapter: 's2s', - endpoint: 'https://prebid.adnxs.com/pbs/v1/auction' - }; - - $$PREBID_GLOBAL$$.setConfig({ s2sConfig: {options} }); - assert.ok(logErrorSpy.calledOnce, true); - }); - }); + // describe('setS2SConfig', () => { + // let logErrorSpy; + // + // beforeEach(() => { + // logErrorSpy = sinon.spy(utils, 'logError'); + // }); + // + // afterEach(() => { + // utils.logError.restore(); + // }); + // + // it('should log error when accountId is missing', () => { + // const options = { + // enabled: true, + // bidders: ['appnexus'], + // timeout: 1000, + // adapter: 'prebidServer', + // endpoint: 'https://prebid.adnxs.com/pbs/v1/auction' + // }; + // + // $$PREBID_GLOBAL$$.setConfig({ s2sConfig: options }); + // assert.ok(logErrorSpy.calledOnce, true); + // }); + // + // it('should log error when bidders is missing', () => { + // const options = { + // accountId: '1', + // enabled: true, + // timeout: 1000, + // adapter: 's2s', + // endpoint: 'https://prebid.adnxs.com/pbs/v1/auction' + // }; + // + // $$PREBID_GLOBAL$$.setConfig({ s2sConfig: options }); + // assert.ok(logErrorSpy.calledOnce, true); + // }); + // }); });