diff --git a/modules/prebidServerBidAdapter.js b/modules/prebidServerBidAdapter.js index 748d91f9444..bc9b65cbd5b 100644 --- a/modules/prebidServerBidAdapter.js +++ b/modules/prebidServerBidAdapter.js @@ -207,6 +207,27 @@ const paramTypes = { }, }; +/* + * Modify an adunit's bidder parameters to match the expected parameter types + */ +function convertTypes(adUnits) { + adUnits.forEach(adUnit => { + adUnit.bids.forEach(bid => { + const types = paramTypes[bid.bidder] || []; + Object.keys(types).forEach(key => { + if (bid.params[key]) { + bid.params[key] = types[key](bid.params[key]); + + // don't send invalid values + if (isNaN(bid.params[key])) { + delete bid.params.key; + } + } + }); + }); + }); +} + function _getDigiTrustQueryParams() { function getDigiTrustId() { let digiTrustUser = window.DigiTrust && (config.getConfig('digiTrustId') || window.DigiTrust.getUser({member: 'T9QSFKPDN9'})); @@ -224,46 +245,26 @@ function _getDigiTrustQueryParams() { }; } -/** - * Bidder adapter for Prebid Server +/* + * Protocol spec for legacy endpoint + * e.g., https:///v1/auction */ -export function PrebidServer() { - let baseAdapter = new Adapter('prebidServer'); +const LEGACY_PROTOCOL = { - function convertTypes(adUnits) { + buildRequest(s2sBidRequest, adUnits) { + // pbs expects an ad_unit.video attribute if the imp is video adUnits.forEach(adUnit => { - adUnit.bids.forEach(bid => { - const types = paramTypes[bid.bidder] || []; - Object.keys(types).forEach(key => { - if (bid.params[key]) { - bid.params[key] = types[key](bid.params[key]); - - // 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(s2sBidRequest, bidRequests, addBidResponse, done, ajax) { - const isDebug = !!getConfig('debug'); - const adUnits = utils.deepClone(s2sBidRequest.ad_units); - adUnits.forEach(adUnit => { - let videoMediaType = utils.deepAccess(adUnit, 'mediaTypes.video'); + const 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. + // 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 = { + + const request = { account_id: _s2sConfig.accountId, tid: s2sBidRequest.tid, max_bids: _s2sConfig.maxBids, @@ -272,126 +273,319 @@ export function PrebidServer() { cache_markup: _s2sConfig.cacheMarkup, url: utils.getTopWindowUrl(), prebid_version: '$prebid.version$', - ad_units: adUnits.filter(hasSizes), - is_debug: isDebug + ad_units: adUnits, + is_debug: !!getConfig('debug'), }; - let digiTrust = _getDigiTrustQueryParams(); - // grab some global config and pass it along ['app', 'device'].forEach(setting => { let value = getConfig(setting); if (typeof value === 'object') { - requestJson[setting] = value; + request[setting] = value; } }); + let digiTrust = _getDigiTrustQueryParams(); if (digiTrust) { - requestJson.digiTrust = digiTrust; - } - - // 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); + request.digiTrust = digiTrust; } - 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; - } - /* 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); + return request; + }, - if (result.status === 'OK' || result.status === 'no_cookie') { - if (result.bidder_status) { - result.bidder_status.forEach(bidder => { - if (bidder.no_cookie) { - doBidderSync(bidder.usersync.type, bidder.usersync.url, bidder.bidder); - } - }); - } + interpretResponse(result, bidRequests, requestedBidders) { + const bids = []; - // do client-side syncs if available - requestedBidders.forEach(bidder => { - let clientAdapter = adaptermanager.getBidAdapter(bidder); - if (clientAdapter && clientAdapter.registerSyncs) { - clientAdapter.registerSyncs([]); + if (result.status === 'OK' || result.status === 'no_cookie') { + if (result.bidder_status) { + result.bidder_status.forEach(bidder => { + if (bidder.no_cookie) { + doBidderSync(bidder.usersync.type, bidder.usersync.url, bidder.bidder); } }); + } - 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; - } + // do client-side syncs if available + requestedBidders.forEach(bidder => { + let clientAdapter = adaptermanager.getBidAdapter(bidder); + if (clientAdapter && clientAdapter.registerSyncs) { + clientAdapter.registerSyncs([]); + } + }); - let bidObject = bidfactory.createBid(status, bidRequest); - bidObject.source = TYPE; - bidObject.creative_id = bidObj.creative_id; - bidObject.bidderCode = bidObj.bidder; - bidObject.cpm = cpm; - if (bidObj.cache_id) { - bidObject.cache_id = bidObj.cache_id; + if (result.bids) { + result.bids.forEach(bidObj => { + const bidRequest = utils.getBidRequest(bidObj.bid_id, bidRequests); + const cpm = bidObj.price; + const status = cpm !== 0 ? STATUS.GOOD : 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; + if (bidObj.cache_id) { + bidObject.cache_id = bidObj.cache_id; + } + if (bidObj.cache_url) { + bidObject.cache_url = bidObj.cache_url; + } + // 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.cache_url) { - bidObject.cache_url = bidObj.cache_url; + if (bidObj.nurl) { + bidObject.vastUrl = bidObj.nurl; } - // 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 - } + } 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; - } - bidObject.requestId = bidObj.bid_id; - bidObject.creativeId = bidObj.creative_id; + bidObject.width = bidObj.width; + bidObject.height = bidObj.height; + bidObject.adserverTargeting = bidObj.ad_server_targeting; + if (bidObj.deal_id) { + bidObject.dealId = bidObj.deal_id; + } + bidObject.requestId = bidObj.bid_id; + bidObject.creativeId = bidObj.creative_id; + + // TODO: Remove when prebid-server returns ttl, currency and netRevenue + bidObject.ttl = (bidObj.ttl) ? bidObj.ttl : DEFAULT_S2S_TTL; + bidObject.currency = (bidObj.currency) ? bidObj.currency : DEFAULT_S2S_CURRENCY; + bidObject.netRevenue = (bidObj.netRevenue) ? bidObj.netRevenue : DEFAULT_S2S_NETREVENUE; + + bids.push({ adUnit: bidObj.code, bid: bidObject }); + }); + } + } + + return bids; + } +}; + +/* + * Protocol spec for OpenRTB endpoint + * e.g., https:///v1/openrtb2/auction + */ +const OPEN_RTB_PROTOCOL = { + + bidMap: {}, + + buildRequest(s2sBidRequest, adUnits) { + let imps = []; + + // transform ad unit into array of OpenRTB impression objects + adUnits.forEach(adUnit => { + // OpenRTB response contains the adunit code and bidder name. These are + // combined to create a unique key for each bid since an id isn't returned + adUnit.bids.forEach(bid => { + const key = `${adUnit.code}${bid.bidder}`; + this.bidMap[key] = bid; + }); + + let banner; + // default to banner if mediaTypes isn't defined + if (utils.isEmpty(adUnit.mediaTypes)) { + const sizeObjects = adUnit.sizes.map(size => ({ w: size.w, h: size.h })); + banner = {format: sizeObjects}; + } - // TODO: Remove when prebid-server returns ttl, currency and netRevenue - bidObject.ttl = (bidObj.ttl) ? bidObj.ttl : DEFAULT_S2S_TTL; - bidObject.currency = (bidObj.currency) ? bidObj.currency : DEFAULT_S2S_CURRENCY; - bidObject.netRevenue = (bidObj.netRevenue) ? bidObj.netRevenue : DEFAULT_S2S_NETREVENUE; + const bannerParams = utils.deepAccess(adUnit, 'mediaTypes.banner'); + if (bannerParams && bannerParams.sizes) { + const sizes = utils.parseSizesInput(bannerParams.sizes); - if (isValid(bidObj.code, bidObject, bidRequests)) { - addBidResponse(bidObj.code, bidObject); + // get banner sizes in form [{ w: , h: }, ...] + const format = sizes.map(size => { + const [ width, height ] = size.split('x'); + const w = parseInt(width, 10); + const h = parseInt(height, 10); + return { w, h }; + }); + + banner = {format}; + } + + let video; + const videoParams = utils.deepAccess(adUnit, 'mediaTypes.video'); + if (!utils.isEmpty(videoParams)) { + video = videoParams; + } + + // get bidder params in form { : {...params} } + const ext = adUnit.bids.reduce((acc, bid) => { + acc[bid.bidder] = bid.params; + return acc; + }, {}); + + const imp = { id: adUnit.code, ext, secure: _s2sConfig.secure }; + + if (banner) { imp.banner = banner; } + if (video) { imp.video = video; } + + imps.push(imp); + }); + + const request = { + id: s2sBidRequest.tid, + site: {publisher: {id: _s2sConfig.accountId}}, + source: {tid: s2sBidRequest.tid}, + tmax: _s2sConfig.timeout, + imp: imps, + test: getConfig('debug') ? 1 : 0, + }; + + ['app', 'device'].forEach(setting => { + let value = getConfig(setting); + if (typeof value === 'object') { + request[setting] = value; + } + }); + + const digiTrust = _getDigiTrustQueryParams(); + if (digiTrust) { + request.user = { ext: { digitrust: digiTrust } }; + } + + return request; + }, + + interpretResponse(response, bidRequests, requestedBidders) { + const bids = []; + + if (response.seatbid) { + // a seatbid object contains a `bid` array and a `seat` string + response.seatbid.forEach(seatbid => { + (seatbid.bid || []).forEach(bid => { + const bidRequest = utils.getBidRequest( + this.bidMap[`${bid.impid}${seatbid.seat}`], + bidRequests + ); + + const cpm = bid.price; + const status = cpm !== 0 ? STATUS.GOOD : STATUS.NO_BID; + let bidObject = bidfactory.createBid(status, bidRequest); + + bidObject.source = TYPE; + bidObject.bidderCode = seatbid.seat; + bidObject.cpm = cpm; + + if (utils.deepAccess(bid, 'ext.prebid.type') === VIDEO) { + bidObject.mediaType = VIDEO; + if (bid.adm) { bidObject.vastXml = bid.adm; } + } else { // banner + if (bid.adm && bid.nurl) { + bidObject.ad = bid.adm; + bidObject.ad += utils.createTrackPixelHtml(decodeURIComponent(bid.nurl)); + } else if (bid.adm) { + bidObject.ad = bid.adm; + } else if (bid.nurl) { + bidObject.adUrl = bid.nurl; } - }); + } + + bidObject.width = bid.w; + bidObject.height = bid.h; + if (bid.dealid) { bidObject.dealId = bid.dealid; } + bidObject.requestId = bid.id; + bidObject.creative_id = bid.crid; + bidObject.creativeId = bid.crid; + + // TODO: Remove when prebid-server returns ttl, currency and netRevenue + bidObject.ttl = (bid.ttl) ? bid.ttl : DEFAULT_S2S_TTL; + bidObject.currency = (bid.currency) ? bid.currency : DEFAULT_S2S_CURRENCY; + bidObject.netRevenue = (bid.netRevenue) ? bid.netRevenue : DEFAULT_S2S_NETREVENUE; + + bids.push({ adUnit: bid.impid, bid: bidObject }); + }); + }); + } + + return bids; + } +}; + +/* + * Returns the required protocol adapter to communicate with the configured + * endpoint. The adapter is an object containing `buildRequest` and + * `interpretResponse` functions. + * + * Usage: + * // build JSON payload to send to server + * const request = protocol().buildRequest(s2sBidRequest, adUnits); + * + * // turn server response into bid object array + * const bids = protocol().interpretResponse(response, bidRequests, requestedBidders); + */ +const protocolAdapter = () => { + const OPEN_RTB_PATH = 'openrtb2/auction'; + + const endpoint = (_s2sConfig && _s2sConfig.endpoint) || ''; + const isOpenRtb = ~endpoint.indexOf(OPEN_RTB_PATH); + + return isOpenRtb ? OPEN_RTB_PROTOCOL : LEGACY_PROTOCOL; +}; + +/** + * Bidder adapter for Prebid Server + */ +export function PrebidServer() { + const baseAdapter = new Adapter('prebidServer'); + + /* Prebid executes this function when the page asks to send out bid requests */ + baseAdapter.callBids = function(s2sBidRequest, bidRequests, addBidResponse, done, ajax) { + const adUnits = utils.deepClone(s2sBidRequest.ad_units); + + convertTypes(adUnits); + + // at this point ad units should have a size array either directly or mapped so filter for that + const adUnitsWithSizes = adUnits.filter(unit => unit.sizes && unit.sizes.length); + + // in case config.bidders contains invalid bidders, we only process those we sent requests for + const requestedBidders = adUnitsWithSizes + .map(adUnit => adUnit.bids.map(bid => bid.bidder).filter(utils.uniques)) + .reduce(utils.flatten) + .filter(utils.uniques); + + const request = protocolAdapter().buildRequest(s2sBidRequest, adUnitsWithSizes); + const requestJson = JSON.stringify(request); + + ajax( + _s2sConfig.endpoint, + response => handleResponse(response, requestedBidders, bidRequests, addBidResponse, done), + requestJson, + { contentType: 'text/plain', withCredentials: true } + ); + }; + + /* 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); + + const bids = protocolAdapter().interpretResponse( + result, + bidRequests, + requestedBidders + ); + + bids.forEach(({adUnit, bid}) => { + if (isValid(adUnit, bid, bidRequests)) { + addBidResponse(adUnit, bid); } - } + }); + if (result.status === 'no_cookie' && _s2sConfig.cookieSet && typeof _s2sConfig.cookieSetUrl === 'string') { // cookie sync cookieSet(_s2sConfig.cookieSetUrl); diff --git a/test/spec/modules/prebidServerBidAdapter_spec.js b/test/spec/modules/prebidServerBidAdapter_spec.js index f403b886b98..7214e841b54 100644 --- a/test/spec/modules/prebidServerBidAdapter_spec.js +++ b/test/spec/modules/prebidServerBidAdapter_spec.js @@ -36,6 +36,11 @@ const REQUEST = { 'h': 600 } ], + 'mediaTypes': { + 'banner': { + 'sizes': [[ 300, 250 ], [ 300, 300 ]] + } + }, 'transactionId': '4ef956ad-fd83-406d-bd35-e4bb786ab86c', 'bids': [ { @@ -51,6 +56,36 @@ const REQUEST = { ] }; +const VIDEO_REQUEST = { + 'account_id': '1', + 'tid': '437fbbf5-33f5-487a-8e16-a7112903cfe5', + 'max_bids': 1, + 'timeout_millis': 1000, + 'secure': 0, + 'url': '', + 'prebid_version': '1.4.0-pre', + 'ad_units': [ + { + 'code': 'div-gpt-ad-1460505748561-0', + 'sizes': [{ 'w': 640, 'h': 480 }], + 'mediaTypes': { + 'video': { + 'playerSize': [[ 640, 480 ]], + 'mimes': ['video/mp4'] + } + }, + 'transactionId': '4ef956ad-fd83-406d-bd35-e4bb786ab86c', + 'bids': [ + { + 'bid_id': '123', + 'bidder': 'appnexus', + 'params': { 'placementId': '12349520' } + } + ] + } + ] +}; + const BID_REQUESTS = [ { 'bidderCode': 'appnexus', @@ -208,6 +243,87 @@ const RESPONSE_NO_PBS_COOKIE_ERROR = { }] }; +const RESPONSE_OPENRTB = { + 'id': 'c7dcf14f', + 'seatbid': [ + { + 'bid': [ + { + 'id': '8750901685062148', + 'impid': '123', + 'price': 0.5, + 'adm': '', + 'adid': '29681110', + 'adomain': [ 'appnexus.com' ], + 'iurl': 'http://lax1-ib.adnxs.com/cr?id=2968111', + 'cid': '958', + 'crid': '2968111', + 'w': 300, + 'h': 250, + 'ext': { + 'prebid': { 'type': 'banner' }, + 'bidder': { + 'appnexus': { + 'brand_id': 1, + 'auction_id': 3, + 'bidder_id': 2 + } + } + } + } + ], + 'seat': 'appnexus' + }, + ], + 'ext': { + 'responsetimemillis': { + 'appnexus': 8, + } + } +}; + +const RESPONSE_OPENRTB_VIDEO = { + id: 'c7dcf14f', + seatbid: [ + { + bid: [ + { + id: '1987250005171537465', + impid: '/19968336/header-bid-tag-0', + price: 10, + adm: 'adnxs', + adid: '81877115', + adomain: ['appnexus.com'], + iurl: 'http://lax1-ib.adnxs.com/cr?id=81877115', + cid: '3535', + crid: '81877115', + w: 1, + h: 1, + ext: { + prebid: { + type: 'video', + }, + bidder: { + appnexus: { + brand_id: 1, + auction_id: 6673622101799484743, + bidder_id: 2, + bid_ad_type: 1, + }, + }, + }, + }, + ], + seat: 'appnexus', + }, + ], + ext: { + responsetimemillis: { + appnexus: 81, + }, + }, +}; + describe('S2S Adapter', () => { let adapter, addBidResponse = sinon.spy(), @@ -473,6 +589,44 @@ describe('S2S Adapter', () => { server.respond(); sinon.assert.calledOnce(cookie.cookieSet); }); + + it('handles OpenRTB responses', () => { + const s2sConfig = Object.assign({}, CONFIG, { + endpoint: 'https://prebid.adnxs.com/pbs/v1/openrtb2/auction' + }); + config.setConfig({s2sConfig}); + + server.respondWith(JSON.stringify(RESPONSE_OPENRTB)); + adapter.callBids(REQUEST, BID_REQUESTS, addBidResponse, done, ajax); + server.respond(); + + sinon.assert.calledOnce(addBidResponse); + const response = addBidResponse.firstCall.args[1]; + expect(response).to.have.property('statusMessage', 'Bid available'); + expect(response).to.have.property('bidderCode', 'appnexus'); + expect(response).to.have.property('adId', '123'); + expect(response).to.have.property('cpm', 0.5); + }); + + it('handles OpenRTB video responses', () => { + const s2sConfig = Object.assign({}, CONFIG, { + endpoint: 'https://prebidserverurl/openrtb2/auction?querystring=param' + }); + config.setConfig({s2sConfig}); + + server.respondWith(JSON.stringify(RESPONSE_OPENRTB_VIDEO)); + adapter.callBids(VIDEO_REQUEST, BID_REQUESTS, addBidResponse, done, ajax); + server.respond(); + + sinon.assert.calledOnce(addBidResponse); + const response = addBidResponse.firstCall.args[1]; + expect(response).to.have.property('statusMessage', 'Bid available'); + expect(response).to.have.property('vastXml', RESPONSE_OPENRTB_VIDEO.seatbid[0].bid[0].adm); + expect(response).to.have.property('mediaType', 'video'); + expect(response).to.have.property('bidderCode', 'appnexus'); + expect(response).to.have.property('adId', '123'); + expect(response).to.have.property('cpm', 10); + }); }); describe('s2sConfig', () => {