diff --git a/modules/serverbidServerBidAdapter.js b/modules/serverbidServerBidAdapter.js new file mode 100644 index 00000000000..47d4e518222 --- /dev/null +++ b/modules/serverbidServerBidAdapter.js @@ -0,0 +1,236 @@ +import Adapter from 'src/adapter'; +import bidfactory from 'src/bidfactory'; +import * as utils from 'src/utils'; +import adaptermanager from 'src/adaptermanager'; +import { STATUS, S2S } from 'src/constants'; +import { config } from 'src/config'; + +const TYPE = S2S.SRC; +const getConfig = config.getConfig; +const REQUIRED_S2S_CONFIG_KEYS = ['siteId', 'networkId', 'bidders', 'endpoint']; + +let _s2sConfig; +config.setDefaults({ + 's2sConfig': { + enabled: false, + timeout: 1000, + adapter: 'serverbidServer' + } +}); + +var ServerBidServerAdapter; +ServerBidServerAdapter = function ServerBidServerAdapter() { + const baseAdapter = new Adapter('serverbidServer'); + + const BASE_URI = 'https://e.serverbid.com/api/v2'; + + const sizeMap = [ + null, + '120x90', + '120x90', + '468x60', + '728x90', + '300x250', + '160x600', + '120x600', + '300x100', + '180x150', + '336x280', + '240x400', + '234x60', + '88x31', + '120x60', + '120x240', + '125x125', + '220x250', + '250x250', + '250x90', + '0x0', + '200x90', + '300x50', + '320x50', + '320x480', + '185x185', + '620x45', + '300x125', + '800x250' + ]; + + sizeMap[77] = '970x90'; + sizeMap[123] = '970x250'; + sizeMap[43] = '300x600'; + + function setS2sConfig(options) { + let contains = (xs, x) => xs.indexOf(x) > -1; + let userConfig = Object.keys(options); + + REQUIRED_S2S_CONFIG_KEYS.forEach(key => { + if (!contains(userConfig, key)) { + utils.logError(key + ' missing in server to server config'); + return void 0; // void 0 to beat the linter + } + }) + + _s2sConfig = options; + } + getConfig('s2sConfig', ({s2sConfig}) => setS2sConfig(s2sConfig)); + + function getLocalConfig() { + return (_s2sConfig || {}); + } + + function _convertFields(bid) { + let safeBid = bid || {}; + let converted = {}; + let name = safeBid.bidder; + converted[name] = safeBid.params; + return converted; + } + + baseAdapter.callBids = function(s2sBidRequest, bidRequests, addBidResponse, done, ajax) { + let params = s2sBidRequest; + let shouldDoWorkFn = function(bidRequest) { + return bidRequest && + bidRequest.ad_units && + utils.isArray(bidRequest.ad_units) && + bidRequest.ad_units.length; + } + if (shouldDoWorkFn(params)) { + _callBids(s2sBidRequest, bidRequests, addBidResponse, done, ajax); + } + }; + + function _callBids(s2sBidRequest, bidRequests, addBidResponse, done, ajax) { + let bidRequest = s2sBidRequest; + + // one request per ad unit + for (let i = 0; i < bidRequest.ad_units.length; i++) { + let adunit = bidRequest.ad_units[i]; + let siteId = _s2sConfig.siteId; + let networkId = getLocalConfig().networkId; + let sizes = adunit.sizes; + + const data = { + placements: [], + time: Date.now(), + user: {}, + url: utils.getTopWindowUrl(), + referrer: document.referrer, + enableBotFiltering: true, + includePricingData: true, + parallel: true + }; + + const bids = adunit.bids || []; + + // one placement for each of the bids + for (let i = 0; i < bids.length; i++) { + const bid = bids[i]; + bid.code = adunit.code; + + const placement = Object.assign({}, { + divName: bid.bid_id, + networkId: networkId, + siteId: siteId, + adTypes: bid.adTypes || getSize(sizes), + bidders: _convertFields(bid), + skipSelection: true + }); + + if (placement.networkId && placement.siteId) { + data.placements.push(placement); + } + } + if (data.placements.length) { + ajax(BASE_URI, _responseCallback(addBidResponse, bids), JSON.stringify(data), { method: 'POST', withCredentials: true, contentType: 'application/json' }); + } + } + } + + function _responseCallback(addBidResponse, bids) { + return function (resp) { + let bid; + let bidId; + let result; + let bidObj; + let bidCode; + let placementCode; + let skipSelectionRequestsReturnArray = function (decision) { + return (decision || []).length ? decision[0] : {}; + }; + + try { + result = JSON.parse(resp); + } catch (error) { + utils.logError(error); + } + + for (let i = 0; i < bids.length; i++) { + bidObj = bids[i]; + bidId = bidObj.bid_id; + bidObj.bidId = bidObj.bid_id; + bidCode = bidObj.bidder; + placementCode = bidObj.code; + let noBid = function(bidObj) { + bid = bidfactory.createBid(STATUS.NO_BID, bidObj); + bid.bidderCode = bidCode; + return bid; + }; + + if (result) { + const decision = result.decisions && skipSelectionRequestsReturnArray(result.decisions[bidId]); + const price = decision && decision.pricing && decision.pricing.clearPrice; + + if (decision && price) { + bid = bidfactory.createBid(STATUS.GOOD, bidObj); + bid = Object.assign(bid, {bidderCode: bidCode, + cpm: price, + width: decision.width, + height: decision.height, + ad: retrieveAd(decision)}) + } else { + bid = noBid(bidObj); + } + } else { + bid = noBid(bidObj); + } + addBidResponse(placementCode, bid); + } + done() + } + }; + + function retrieveAd(decision) { + return decision.contents && decision.contents[0] && decision.contents[0].body + utils.createTrackPixelHtml(decision.impressionUrl); + } + + function getSize(sizes) { + let width = 'w'; + let height = 'h'; + const result = []; + sizes.forEach(function(size) { + const index = sizeMap.indexOf(size[width] + 'x' + size[height]); + if (index >= 0) { + result.push(index); + } + }); + return result; + } + + // Export the `callBids` function, so that Prebid.js can execute + // this function when the page asks to send out bid requests. + return Object.assign(this, { + queueSync: baseAdapter.queueSync, + callBids: baseAdapter.callBids, + setBidderCode: baseAdapter.setBidderCode, + type: TYPE + }); +}; + +ServerBidServerAdapter.createNew = function() { + return new ServerBidServerAdapter(); +}; + +adaptermanager.registerBidAdapter(new ServerBidServerAdapter(), 'serverbidServer'); + +module.exports = ServerBidServerAdapter; diff --git a/test/spec/modules/serverbidServerBidAdapter_spec.js b/test/spec/modules/serverbidServerBidAdapter_spec.js new file mode 100644 index 00000000000..7745d0e407c --- /dev/null +++ b/test/spec/modules/serverbidServerBidAdapter_spec.js @@ -0,0 +1,299 @@ +import { expect } from 'chai'; +import Adapter from 'modules/serverbidServerBidAdapter'; +import * as utils from 'src/utils'; +import { config } from 'src/config'; +import { ajax } from 'src/ajax'; + +const ENDPOINT = 'https://e.serverbid.com/api/v2'; + +let CONFIG = { + enabled: true, + bidders: ['appnexus'], + timeout: 1000, + adapter: 'serverbidServer', + networkId: 9969, + siteId: 730181, + endpoint: ENDPOINT +}; + +let CONFIG_ARG = { + s2sConfig: CONFIG +} + +const REQUEST = { + 'account_id': '1', + 'tid': '437fbbf5-33f5-487a-8e16-a7112903cfe5', + 'max_bids': 1, + 'timeout_millis': 1000, + 'url': '', + 'prebid_version': '0.21.0-pre', + 'ad_units': [ + { + 'code': 'div-gpt-ad-1460505748561-0', + 'sizes': [ + { + 'w': 300, + 'h': 250 + }, + { + 'w': 300, + 'h': 600 + } + ], + 'transactionId': '4ef956ad-fd83-406d-bd35-e4bb786ab86c', + 'bids': [ + { + 'bid_id': '123', + 'bidder': 'appnexus', + 'params': { + 'placementId': '10433394', + 'member': 123 + } + } + ] + } + ] +}; + +const BID_REQUESTS = [ + { + 'bidderCode': 'appnexus', + 'auctionId': '173afb6d132ba3', + 'bidderRequestId': '3d1063078dfcc8', + 'tid': '437fbbf5-33f5-487a-8e16-a7112903cfe5', + 'bids': [ + { + 'bidder': 'appnexus', + 'params': { + 'placementId': '10433394', + 'member': 123 + }, + 'bid_id': '123', + 'adUnitCode': 'div-gpt-ad-1460505748561-0', + 'transactionId': '4ef956ad-fd83-406d-bd35-e4bb786ab86c', + 'sizes': [ + { + 'w': 300, + 'h': 250 + } + ], + 'bidId': '259fb43aaa06c1', + 'bidderRequestId': '3d1063078dfcc8', + 'auctionId': '173afb6d132ba3' + } + ], + 'auctionStart': 1510852447530, + 'timeout': 5000, + 'src': 's2s', + 'doneCbCallCount': 0 + } +]; + +const RESPONSE = { + 'user': { 'key': 'ue1-2d33e91b71e74929b4aeecc23f4376f1' }, + 'decisions': { + '123': [{ + 'adId': 2364764, + 'creativeId': 1950991, + 'flightId': 2788300, + 'campaignId': 542982, + 'clickUrl': 'https://e.serverbid.com/r', + 'impressionUrl': 'https://e.serverbid.com/i.gif', + 'contents': [{ + 'type': 'html', + 'body': '', + 'data': { + 'height': 300, + 'width': 250, + 'imageUrl': 'https://static.adzerk.net/Advertisers/b0ab77db8a7848c8b78931aed022a5ef.gif', + 'fileName': 'b0ab77db8a7848c8b78931aed022a5ef.gif' + }, + 'template': 'image' + }], + 'height': 250, + 'width': 300, + 'events': [], + 'pricing': {'price': 0.5, 'clearPrice': 0.5, 'revenue': 0.0005, 'rateType': 2, 'eCPM': 0.5} + }], + } +}; + +const RESPONSE_NO_BID_NO_UNIT = { + 'user': { 'key': 'ue1-2d33e91b71e74929b4aeecc23f4376f1' }, + 'decisions': { + '123': [] + } +}; + +const REQUEST_TWO_UNITS = { + 'account_id': '1', + 'tid': '437fbbf5-33f5-487a-8e16-a7112903cfe5', + 'max_bids': 1, + 'timeout_millis': 1000, + 'url': '', + 'prebid_version': '0.21.0-pre', + 'ad_units': [ + { + 'code': 'div-gpt-ad-1460505748561-0', + 'sizes': [ + { + 'w': 300, + 'h': 250 + }, + { + 'w': 300, + 'h': 600 + } + ], + 'transactionId': '4ef956ad-fd83-406d-bd35-e4bb786ab86c', + 'bids': [ + { + 'bid_id': '123', + 'bidder': 'appnexus', + 'params': { + 'placementId': '10433394', + 'member': 123 + } + } + ] + }, + { + 'code': 'div-gpt-ad-1460505748561-1', + 'sizes': [ + { + 'w': 300, + 'h': 250 + }, + { + 'w': 300, + 'h': 600 + } + ], + 'transactionId': '4ef956ad-fd83-406d-bd35-e4bb786bb86d', + 'bids': [ + { + 'bid_id': '101111', + 'bidder': 'appnexus', + 'params': { + 'placementId': '10433394', + 'member': 123 + } + } + ] + } + ] +}; + +describe('ServerBid S2S Adapter', () => { + let adapter, + addBidResponse = sinon.spy(), + done = sinon.spy(); + + beforeEach(() => adapter = new Adapter()); + + afterEach(() => { + addBidResponse.reset(); + done.reset(); + }); + + describe('request function', () => { + let xhr; + let requests; + + beforeEach(() => { + xhr = sinon.useFakeXMLHttpRequest(); + requests = []; + xhr.onCreate = request => requests.push(request); + }); + + afterEach(() => xhr.restore()); + + it('exists and is a function', () => { + expect(adapter.callBids).to.exist.and.to.be.a('function'); + }); + }); + + describe('response handler', () => { + let server; + + beforeEach(() => { + server = sinon.fakeServer.create(); + sinon.stub(utils, 'getBidRequest').returns({ + bidId: '123' + }); + }); + + afterEach(() => { + server.restore(); + utils.getBidRequest.restore(); + }); + + it('registers bids', () => { + server.respondWith(JSON.stringify(RESPONSE)); + + config.setConfig(CONFIG_ARG); + 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('cpm', 0.5); + expect(response).to.have.property('adId', '123'); + }); + + it('registers no-bid response when ad unit not set', () => { + server.respondWith(JSON.stringify(RESPONSE_NO_BID_NO_UNIT)); + + config.setConfig(CONFIG_ARG); + adapter.callBids(REQUEST, BID_REQUESTS, addBidResponse, done, ajax); + server.respond(); + sinon.assert.calledOnce(addBidResponse); + + const ad_unit_code = addBidResponse.firstCall.args[0]; + expect(ad_unit_code).to.equal('div-gpt-ad-1460505748561-0'); + + const response = addBidResponse.firstCall.args[1]; + expect(response).to.have.property('statusMessage', 'Bid returned empty or error response'); + + const bid_request_passed = addBidResponse.firstCall.args[1]; + expect(bid_request_passed).to.have.property('adId', '123'); + }); + + it('registers no-bid response when ad unit is set', () => { + server.respondWith(JSON.stringify(RESPONSE_NO_BID_NO_UNIT)); + + config.setConfig(CONFIG_ARG); + adapter.callBids(REQUEST, BID_REQUESTS, addBidResponse, done, ajax); + server.respond(); + sinon.assert.calledOnce(addBidResponse); + + const ad_unit_code = addBidResponse.firstCall.args[0]; + expect(ad_unit_code).to.equal('div-gpt-ad-1460505748561-0'); + + const response = addBidResponse.firstCall.args[1]; + expect(response).to.have.property('statusMessage', 'Bid returned empty or error response'); + }); + + it('registers no-bid response when there are less bids than requests', () => { + server.respondWith(JSON.stringify(RESPONSE)); + + config.setConfig(CONFIG_ARG); + adapter.callBids(REQUEST_TWO_UNITS, BID_REQUESTS, addBidResponse, done, ajax); + server.respond(); + + sinon.assert.calledTwice(addBidResponse); + + expect(addBidResponse.firstCall.args[0]).to.equal('div-gpt-ad-1460505748561-0'); + expect(addBidResponse.secondCall.args[0]).to.equal('div-gpt-ad-1460505748561-1'); + + expect(addBidResponse.firstCall.args[1]).to.have.property('adId', '123'); + expect(addBidResponse.secondCall.args[1]).to.have.property('adId', '101111'); + + expect(addBidResponse.firstCall.args[1]) + .to.have.property('statusMessage', 'Bid available'); + expect(addBidResponse.secondCall.args[1]) + .to.have.property('statusMessage', 'Bid returned empty or error response'); + }); + }); +});