diff --git a/modules/pulsepointLiteBidAdapter.js b/modules/pulsepointLiteBidAdapter.js index 807477f729db..b9ce3aacb56f 100644 --- a/modules/pulsepointLiteBidAdapter.js +++ b/modules/pulsepointLiteBidAdapter.js @@ -5,45 +5,203 @@ import {ajax} from 'src/ajax'; import {STATUS} from 'src/constants'; import adaptermanager from 'src/adaptermanager'; +/** + * PulsePoint "Lite" Adapter. This adapter implementation is lighter than the + * alternative/original PulsePointAdapter because it has no external + * dependencies and relies on a single OpenRTB request to the PulsePoint + * bidder instead of separate requests per slot. + */ function PulsePointLiteAdapter() { - const bidUrl = window.location.protocol + '//bid.contextweb.com/header/tag?'; + const bidUrl = window.location.protocol + '//bid.contextweb.com/header/ortb'; const ajaxOptions = { - method: 'GET', + method: 'POST', withCredentials: true, contentType: 'text/plain' }; + const NATIVE_DEFAULTS = { + TITLE_LEN: 100, + DESCR_LEN: 200, + SPONSORED_BY_LEN: 50, + IMG_MIN: 150, + ICON_MIN: 50, + }; + + /** + * Makes the call to PulsePoint endpoint and registers bids. + */ + function _callBids(bidRequest) { + try { + // construct the openrtb bid request from slots + const request = { + imp: bidRequest.bids.map(slot => impression(slot)), + site: site(bidRequest), + device: device(), + }; + ajax(bidUrl, (rawResponse) => { + bidResponseAvailable(bidRequest, rawResponse); + }, JSON.stringify(request), ajaxOptions); + } catch (e) { + // register passback on any exceptions while attempting to fetch response. + logError('pulsepoint.requestBid', 'ERROR', e); + bidResponseAvailable(bidRequest); + } + } - function _callBids(bidderRequest) { - bidderRequest.bids.forEach(bidRequest => { - try { - var params = Object.assign({}, environment(), bidRequest.params); - var url = bidUrl + Object.keys(params).map(k => k + '=' + encodeURIComponent(params[k])).join('&'); - ajax(url, (bidResponse) => { - bidResponseAvailable(bidRequest, bidResponse); - }, null, ajaxOptions); - } catch (e) { - // register passback on any exceptions while attempting to fetch response. - logError('pulsepoint.requestBid', 'ERROR', e); - bidResponseAvailable(bidRequest); + /** + * Callback for bids, after the call to PulsePoint completes. + */ + function bidResponseAvailable(bidRequest, rawResponse) { + const idToSlotMap = {}; + const idToBidMap = {}; + // extract the request bids and the response bids, keyed by impr-id + bidRequest.bids.forEach((slot) => { + idToSlotMap[slot.bidId] = slot; + }); + const bidResponse = parse(rawResponse); + if (bidResponse) { + bidResponse.seatbid.forEach(seatBid => seatBid.bid.forEach((bid) => { + idToBidMap[bid.impid] = bid; + })); + } + // register the responses + Object.keys(idToSlotMap).forEach((id) => { + if (idToBidMap[id]) { + const size = adSize(idToSlotMap[id]); + const bid = createBid(STATUS.GOOD, bidRequest); + bid.bidderCode = bidRequest.bidderCode; + bid.cpm = idToBidMap[id].price; + bid.adId = id; + if(isNative(idToSlotMap[id])) { + bid.native = nativeResponse(idToSlotMap[id], idToBidMap[id]); + bid.mediaType = 'native'; + } else { + bid.ad = idToBidMap[id].adm; + bid.width = size[0]; + bid.height = size[1]; + } + addBidResponse(idToSlotMap[id].placementCode, bid); + } else { + const passback = createBid(STATUS.NO_BID, bidRequest); + passback.bidderCode = bidRequest.bidderCode; + passback.adId = id; + addBidResponse(idToSlotMap[id].placementCode, passback); } }); } - function environment() { + /** + * Produces an OpenRTBImpression from a slot config. + */ + function impression(slot) { + return { + id: slot.bidId, + banner: banner(slot), + native: native(slot), + tagid: slot.params.ct.toString(), + }; + } + + /** + * Produces an OpenRTB Banner object for the slot given. + */ + function banner(slot) { + const size = adSize(slot); + return slot.nativeParams ? null : { + w: size[0], + h: size[1], + }; + } + + /** + * Produces an OpenRTB Native object for the slot given. + */ + function native(slot) { + if (slot.nativeParams) { + const assets = []; + addAsset(assets, titleAsset(assets.length + 1, slot.nativeParams.title, NATIVE_DEFAULTS.TITLE_LEN)); + addAsset(assets, dataAsset(assets.length + 1, slot.nativeParams.body, 2, NATIVE_DEFAULTS.DESCR_LEN)); + addAsset(assets, dataAsset(assets.length + 1, slot.nativeParams.sponsoredBy, 1, NATIVE_DEFAULTS.SPONSORED_BY_LEN)); + addAsset(assets, imageAsset(assets.length + 1, slot.nativeParams.icon, 1, NATIVE_DEFAULTS.ICON_MIN, NATIVE_DEFAULTS.ICON_MIN)); + addAsset(assets, imageAsset(assets.length + 1, slot.nativeParams.image, 3, NATIVE_DEFAULTS.IMG_MIN, NATIVE_DEFAULTS.IMG_MIN)); + return { + request: JSON.stringify({ assets }), + ver: '1.1', + }; + } + return null; + } + + /** + * Helper method to add an asset to the assets list. + */ + function addAsset(assets, asset) { + if(asset) { + assets.push(asset); + } + } + + /** + * Produces a Native Title asset for the configuration given. + */ + function titleAsset(id, params, defaultLen) { + if (params) { + return { + id: id, + required: params.required ? 1 : 0, + title: { + len: params.len || defaultLen, + }, + }; + } + return null; + } + + /** + * Produces a Native Image asset for the configuration given. + */ + function imageAsset(id, params, type, defaultMinWidth, defaultMinHeight) { + return params ? { + id: id, + required: params.required ? 1 : 0, + img: { + type, + wmin: params.wmin || defaultMinWidth, + hmin: params.hmin || defaultMinHeight, + } + } : null; + } + + /** + * Produces a Native Data asset for the configuration given. + */ + function dataAsset(id, params, type, defaultLen) { + return params ? { + id: id, + required: params.required ? 1 : 0, + data: { + type, + len: params.len || defaultLen, + } + } : null; + } + + /** + * Produces an OpenRTB site object. + */ + function site(bidderRequest) { + const pubId = bidderRequest.bids.length > 0 ? bidderRequest.bids[0].params.cp : '0'; return { - cn: 1, - ca: 'BID', - tl: 1, - 'if': 0, - cwu: getTopWindowLocation().href, - cwr: referrer(), - dw: document.documentElement.clientWidth, - cxy: document.documentElement.clientWidth + ',' + document.documentElement.clientHeight, - tz: new Date().getTimezoneOffset(), - ln: (navigator.language || navigator.browserLanguage || navigator.userLanguage || navigator.systemLanguage) + publisher: { + id: pubId.toString(), + }, + ref: referrer(), + page: getTopWindowLocation().href, }; } + /** + * Attempts to capture the referrer url. + */ function referrer() { try { return window.top.document.referrer; @@ -52,33 +210,74 @@ function PulsePointLiteAdapter() { } } - function bidResponseAvailable(bidRequest, rawResponse) { - if (rawResponse) { - var bidResponse = parse(rawResponse); - if (bidResponse) { - var adSize = bidRequest.params.cf.toUpperCase().split('X'); - var bid = createBid(STATUS.GOOD, bidRequest); - bid.bidderCode = bidRequest.bidder; - bid.cpm = bidResponse.bidCpm; - bid.ad = bidResponse.html; - bid.width = adSize[0]; - bid.height = adSize[1]; - addBidResponse(bidRequest.placementCode, bid); - return; - } - } - var passback = createBid(STATUS.NO_BID, bidRequest); - passback.bidderCode = bidRequest.bidder; - addBidResponse(bidRequest.placementCode, passback); + /** + * Produces an OpenRTB Device object. + */ + function device() { + return { + ua: navigator.userAgent, + language: (navigator.language || navigator.browserLanguage || navigator.userLanguage || navigator.systemLanguage), + }; } + /** + * Safely parses the input given. Returns null on + * parsing failure. + */ function parse(rawResponse) { try { - return JSON.parse(rawResponse); + if(rawResponse) { + return JSON.parse(rawResponse); + } } catch (ex) { - logError('pulsepoint.safeParse', 'ERROR', ex); - return null; + logError('pulsepointLite.safeParse', 'ERROR', ex); + } + return null; + } + + /** + * Determines the AdSize for the slot. + */ + function adSize(slot) { + if(slot.params.cf) { + const size = slot.params.cf.toUpperCase().split('X'); + const width = parseInt(slot.params.cw || size[0], 10); + const height = parseInt(slot.params.ch || size[1], 10); + return [width, height]; } + return [1, 1]; + } + + /** + * Parses the native response from the Bid given. + */ + function nativeResponse(slot, bid) { + if(slot.nativeParams) { + const nativeAd = parse(bid.adm); + const keys = {}; + if(nativeAd && nativeAd.native && nativeAd.native.assets) { + nativeAd.native.assets.forEach((asset) => { + keys.title = asset.title ? asset.title.text : keys.title; + keys.body = asset.data && asset.data.type === 2 ? asset.data.value : keys.body; + keys.sponsoredBy = asset.data && asset.data.type === 1 ? asset.data.value : keys.sponsoredBy; + keys.image = asset.img && asset.img.type === 3 ? asset.img.url : keys.image; + keys.icon = asset.img && asset.img.type === 1 ? asset.img.url : keys.icon; + }); + if (nativeAd.native.link) { + keys.clickUrl = encodeURIComponent(nativeAd.native.link.url); + } + keys.impressionTrackers = nativeAd.native.imptrackers; + return keys; + } + } + return null; + } + + /** + * Parses the native response from the Bid given. + */ + function isNative(slot) { + return slot.nativeParams ? true : false; } return { @@ -86,6 +285,17 @@ function PulsePointLiteAdapter() { }; } -adaptermanager.registerBidAdapter(new PulsePointLiteAdapter, 'pulsepointLite'); +/** + * "pulseLite" will be the adapter name going forward. "pulsepointLite" to be + * deprecated, but kept here for backwards compatibility. + * Reason is key truncation. When the Publisher opts for sending all bids to DFP, then + * the keys get truncated due to the limit in key-size (20 characters, detailed + * here https://support.google.com/dfp_premium/answer/1628457?hl=en). Here is an + * example, where keys got truncated when using the "pulsepointLite" alias - "hb_adid_pulsepointLi=1300bd87d59c4c2" +*/ +adaptermanager.registerBidAdapter(new PulsePointLiteAdapter, 'pulseLite', { + supportedMediaTypes: [ 'native' ] +}); +adaptermanager.aliasBidAdapter('pulseLite', 'pulsepointLite'); module.exports = PulsePointLiteAdapter; diff --git a/test/spec/modules/pulsepointLiteBidAdapter_spec.js b/test/spec/modules/pulsepointLiteBidAdapter_spec.js index f48d361d4cd4..9532685a0378 100644 --- a/test/spec/modules/pulsepointLiteBidAdapter_spec.js +++ b/test/spec/modules/pulsepointLiteBidAdapter_spec.js @@ -1,12 +1,13 @@ import {expect} from 'chai'; import PulsePointAdapter from 'modules/pulsepointLiteBidAdapter'; import bidManager from 'src/bidmanager'; +import {getTopWindowLocation} from 'src/utils'; import * as ajax from 'src/ajax'; -import {parse as parseURL} from 'src/url'; describe('PulsePoint Lite Adapter Tests', () => { let pulsepointAdapter = new PulsePointAdapter(); let slotConfigs; + let nativeSlotConfig; let ajaxStub; beforeEach(() => { @@ -14,10 +15,10 @@ describe('PulsePoint Lite Adapter Tests', () => { ajaxStub = sinon.stub(ajax, 'ajax'); slotConfigs = { + bidderCode: 'pulseLite', bids: [ { placementCode: '/DfpAccount1/slot1', - bidder: 'pulsepoint', bidId: 'bid12345', params: { cp: 'p10000', @@ -26,16 +27,33 @@ describe('PulsePoint Lite Adapter Tests', () => { } }, { placementCode: '/DfpAccount2/slot2', - bidder: 'pulsepoint', bidId: 'bid23456', params: { - cp: 'p20000', + cp: 'p10000', ct: 't20000', cf: '728x90' } } ] }; + nativeSlotConfig = { + bidderCode: 'pulseLite', + bids: [ + { + placementCode: '/DfpAccount1/slot3', + bidId: 'bid12345', + nativeParams: { + title: { required: true, len: 200 }, + image: { wmin: 100 }, + sponsoredBy: { } + }, + params: { + cp: 'p10000', + ct: 't10000' + } + } + ] + }; }); afterEach(() => { @@ -45,48 +63,72 @@ describe('PulsePoint Lite Adapter Tests', () => { it('Verify requests sent to PulsePoint', () => { pulsepointAdapter.callBids(slotConfigs); - var call = parseURL(ajaxStub.firstCall.args[0]).search; + expect(ajaxStub.callCount).to.equal(1); + expect(ajaxStub.firstCall.args[0]).to.equal('http://bid.contextweb.com/header/ortb'); + const ortbRequest = JSON.parse(ajaxStub.firstCall.args[2]); + // site object + expect(ortbRequest.site).to.not.equal(null); + expect(ortbRequest.site.publisher).to.not.equal(null); + expect(ortbRequest.site.publisher.id).to.equal('p10000'); + expect(ortbRequest.site.ref).to.equal(window.top.document.referrer); + expect(ortbRequest.site.page).to.equal(getTopWindowLocation().href); + expect(ortbRequest.imp).to.have.lengthOf(2); + // device object + expect(ortbRequest.device).to.not.equal(null); + expect(ortbRequest.device.ua).to.equal(navigator.userAgent); // slot 1 - // expect(call.cp).to.equal('p10000'); - // expect(call.ct).to.equal('t10000'); - // expect(call.cf).to.equal('300x250'); - expect(call.ca).to.equal('BID'); - expect(call.cn).to.equal('1'); + expect(ortbRequest.imp[0].tagid).to.equal('t10000'); + expect(ortbRequest.imp[0].banner).to.not.equal(null); + expect(ortbRequest.imp[0].banner.w).to.equal(300); + expect(ortbRequest.imp[0].banner.h).to.equal(250); // slot 2 - call = parseURL(ajaxStub.secondCall.args[0]).search; - // expect(call.cp).to.equal('p20000'); - // expect(call.ct).to.equal('t20000'); - // expect(call.cf).to.equal('728x90'); - expect(call.ca).to.equal('BID'); - expect(call.cn).to.equal('1'); + expect(ortbRequest.imp[1].tagid).to.equal('t20000'); + expect(ortbRequest.imp[1].banner).to.not.equal(null); + expect(ortbRequest.imp[1].banner.w).to.equal(728); + expect(ortbRequest.imp[1].banner.h).to.equal(90); }); it('Verify bid', () => { pulsepointAdapter.callBids(slotConfigs); // trigger a mock ajax callback with bid. + const ortbRequest = JSON.parse(ajaxStub.firstCall.args[2]); ajaxStub.firstCall.args[1](JSON.stringify({ - html: 'This is an Ad', - bidCpm: 1.25 + seatbid: [{ + bid: [{ + impid: ortbRequest.imp[0].id, + price: 1.25, + adm: 'This is an Ad' + }] + }] })); + expect(bidManager.addBidResponse.callCount).to.equal(2); + // verify first bid let placement = bidManager.addBidResponse.firstCall.args[0]; let bid = bidManager.addBidResponse.firstCall.args[1]; expect(placement).to.equal('/DfpAccount1/slot1'); - expect(bid.bidderCode).to.equal('pulsepoint'); + expect(bid.bidderCode).to.equal('pulseLite'); expect(bid.cpm).to.equal(1.25); expect(bid.ad).to.equal('This is an Ad'); - expect(bid.width).to.equal('300'); - expect(bid.height).to.equal('250'); + expect(bid.width).to.equal(300); + expect(bid.height).to.equal(250); expect(bid.adId).to.equal('bid12345'); + // verify passback on 2nd impression. + placement = bidManager.addBidResponse.secondCall.args[0]; + bid = bidManager.addBidResponse.secondCall.args[1]; + expect(placement).to.equal('/DfpAccount2/slot2'); + expect(bid.adId).to.equal('bid23456'); + expect(bid.bidderCode).to.equal('pulseLite'); + expect(bid.cpm).to.be.undefined; }); - it('Verify passback', () => { + it('Verify full passback', () => { pulsepointAdapter.callBids(slotConfigs); // trigger a mock ajax callback with no bid. ajaxStub.firstCall.args[1](null); let placement = bidManager.addBidResponse.firstCall.args[0]; let bid = bidManager.addBidResponse.firstCall.args[1]; expect(placement).to.equal('/DfpAccount1/slot1'); - expect(bid.bidderCode).to.equal('pulsepoint'); + expect(bid.bidderCode).to.equal('pulseLite'); expect(bid).to.not.have.property('ad'); expect(bid).to.not.have.property('cpm'); expect(bid.adId).to.equal('bid12345'); @@ -98,9 +140,90 @@ describe('PulsePoint Lite Adapter Tests', () => { let placement = bidManager.addBidResponse.firstCall.args[0]; let bid = bidManager.addBidResponse.firstCall.args[1]; expect(placement).to.equal('/DfpAccount1/slot1'); - expect(bid.bidderCode).to.equal('pulsepoint'); + expect(bid.bidderCode).to.equal('pulseLite'); expect(bid).to.not.have.property('ad'); expect(bid).to.not.have.property('cpm'); expect(bid.adId).to.equal('bid12345'); }); + + it('Verify Native request', () => { + pulsepointAdapter.callBids(nativeSlotConfig); + expect(ajaxStub.callCount).to.equal(1); + expect(ajaxStub.firstCall.args[0]).to.equal('http://bid.contextweb.com/header/ortb'); + const ortbRequest = JSON.parse(ajaxStub.firstCall.args[2]); + // native impression + expect(ortbRequest.imp[0].tagid).to.equal('t10000'); + expect(ortbRequest.imp[0].banner).to.equal(null); + expect(ortbRequest.imp[0].native).to.not.equal(null); + expect(ortbRequest.imp[0].native.ver).to.equal('1.1'); + expect(ortbRequest.imp[0].native.request).to.not.equal(null); + // native request assets + const nativeRequest = JSON.parse(ortbRequest.imp[0].native.request); + expect(nativeRequest).to.not.equal(null); + expect(nativeRequest.assets).to.have.lengthOf(3); + // title asset + expect(nativeRequest.assets[0].id).to.equal(1); + expect(nativeRequest.assets[0].required).to.equal(1); + expect(nativeRequest.assets[0].title).to.not.equal(null); + expect(nativeRequest.assets[0].title.len).to.equal(200); + // data asset + expect(nativeRequest.assets[1].id).to.equal(2); + expect(nativeRequest.assets[1].required).to.equal(0); + expect(nativeRequest.assets[1].title).to.be.undefined; + expect(nativeRequest.assets[1].data).to.not.equal(null); + expect(nativeRequest.assets[1].data.type).to.equal(1); + expect(nativeRequest.assets[1].data.len).to.equal(50); + // image asset + expect(nativeRequest.assets[2].id).to.equal(3); + expect(nativeRequest.assets[2].required).to.equal(0); + expect(nativeRequest.assets[2].title).to.be.undefined; + expect(nativeRequest.assets[2].img).to.not.equal(null); + expect(nativeRequest.assets[2].img.wmin).to.equal(100); + expect(nativeRequest.assets[2].img.hmin).to.equal(150); + expect(nativeRequest.assets[2].img.type).to.equal(3); + }); + + it('Verify Native response', () => { + pulsepointAdapter.callBids(nativeSlotConfig); + expect(ajaxStub.callCount).to.equal(1); + expect(ajaxStub.firstCall.args[0]).to.equal('http://bid.contextweb.com/header/ortb'); + const ortbRequest = JSON.parse(ajaxStub.firstCall.args[2]); + const nativeResponse = { + native: { + assets: [ + { title: { text: 'Ad Title'} }, + { data: { type: 1, value: 'Sponsored By: Brand' }}, + { img: { type: 3, url: 'http://images.cdn.brand.com/123' } } + ], + link: { url: 'http://brand.clickme.com/' }, + imptrackers: [ 'http://imp1.trackme.com/', 'http://imp1.contextweb.com/' ] + } + }; + ajaxStub.firstCall.args[1](JSON.stringify({ + seatbid: [{ + bid: [{ + impid: ortbRequest.imp[0].id, + price: 1.25, + adm: JSON.stringify(nativeResponse) + }] + }] + })); + // verify bid + let placement = bidManager.addBidResponse.firstCall.args[0]; + let bid = bidManager.addBidResponse.firstCall.args[1]; + expect(placement).to.equal('/DfpAccount1/slot3'); + expect(bid.bidderCode).to.equal('pulseLite'); + expect(bid.cpm).to.equal(1.25); + expect(bid.adId).to.equal('bid12345'); + expect(bid.ad).to.be.undefined; + expect(bid.mediaType).to.equal('native'); + expect(bid.native).to.not.equal(null); + expect(bid.native.title).to.equal('Ad Title'); + expect(bid.native.sponsoredBy).to.equal('Sponsored By: Brand'); + expect(bid.native.image).to.equal('http://images.cdn.brand.com/123'); + expect(bid.native.clickUrl).to.equal(encodeURIComponent('http://brand.clickme.com/')); + expect(bid.native.impressionTrackers).to.have.lengthOf(2); + expect(bid.native.impressionTrackers[0]).to.equal('http://imp1.trackme.com/'); + expect(bid.native.impressionTrackers[1]).to.equal('http://imp1.contextweb.com/'); + }); });