diff --git a/adapters.json b/adapters.json index 296f91bdc0d..aa4b747ef51 100644 --- a/adapters.json +++ b/adapters.json @@ -4,6 +4,7 @@ "adbutler", "adequant", "adform", + "adkernel", "admedia", "aol", "appnexus", diff --git a/integrationExamples/gpt/pbjs_example_gpt.html b/integrationExamples/gpt/pbjs_example_gpt.html index a8e90559f10..456f1ce09cb 100644 --- a/integrationExamples/gpt/pbjs_example_gpt.html +++ b/integrationExamples/gpt/pbjs_example_gpt.html @@ -217,6 +217,13 @@ params:{ placementId:'VZH123' //REQUIRED AD unit id } + }, + { + bidder: 'adkernel', + params: { + zoneId : '30164', + host : 'cpm.metaadserving.com' + } } ] }, { diff --git a/src/adapters/adkernel.js b/src/adapters/adkernel.js new file mode 100644 index 00000000000..48d73326d35 --- /dev/null +++ b/src/adapters/adkernel.js @@ -0,0 +1,207 @@ +import bidmanager from 'src/bidmanager'; +import bidfactory from 'src/bidfactory'; +import * as utils from 'src/utils'; +import {ajax} from 'src/ajax'; + +/** + * Adapter for requesting bids from AdKernel white-label platform + * @class + */ +const AdkernelAdapter = function AdkernelAdapter() { + const ADKERNEL = 'adkernel'; + const AJAX_REQ_PARAMS = { + contentType: 'text/plain', + withCredentials: true, + method: 'GET' + }; + const EMPTY_BID_RESPONSE = {'seatbid': [{'bid': []}]}; + + /** + * Helper object to build multiple bid requests in case of multiple zones/ad-networks + * @constructor + */ + function RtbRequestDispatcher() { + const _dispatch = {}; + const originalBids = {}; + const site = createSite(); + + //translate adunit info into rtb impression dispatched by host/zone + this.addImp = function (bid) { + let host = bid.params.host; + let zone = bid.params.zoneId; + let size = bid.sizes[0]; + let bidId = bid.bidId; + + if (!(host in _dispatch)) { + _dispatch[host] = {}; + } + /* istanbul ignore else */ + if (!(zone in _dispatch[host])) { + _dispatch[host][zone] = []; + } + let imp = {'id': bidId, 'banner': {'w': size[0], 'h': size[1]}}; + if (utils.getTopWindowLocation().protocol === 'https:') { + imp.secure = 1; + } + //save rtb impression for specified ad-network host and zone + _dispatch[host][zone].push(imp); + originalBids[bidId] = bid; + }; + + /** + * Main function to get bid requests + */ + this.dispatch = function (callback) { + utils._each(_dispatch, (zones, host) => { + utils.logMessage('processing network ' + host); + utils._each(zones, (impressions, zone) => { + utils.logMessage('processing zone ' + zone); + dispatchRtbRequest(host, zone, impressions, callback); + }); + }); + }; + + function dispatchRtbRequest(host, zone, impressions, callback) { + let url = buildEndpointUrl(host); + let rtbRequest = buildRtbRequest(impressions); + let params = buildRequestParams(zone, rtbRequest); + ajax(url, (bidResp) => { + bidResp = bidResp === '' ? EMPTY_BID_RESPONSE : JSON.parse(bidResp); + utils._each(rtbRequest.imp, (imp) => { + let bidFound = false; + utils._each(bidResp.seatbid[0].bid, (bid) => { + /* istanbul ignore else */ + if (!bidFound && bid.impid === imp.id) { + bidFound = true; + callback(originalBids[imp.id], imp, bid); + } + }); + if (!bidFound) { + callback(originalBids[imp.id], imp); + } + }); + }, params, AJAX_REQ_PARAMS); + } + + /** + * Builds complete rtb bid request + * @param imps collection of impressions + */ + function buildRtbRequest(imps) { + return { + 'id': utils.getUniqueIdentifierStr(), + 'imp': imps, + 'site': site, + 'at': 1, + 'device': { + 'ip': 'caller', + 'ua': 'caller' + } + }; + } + + /** + * Build ad-network specific endpoint url + */ + function buildEndpointUrl(host) { + return window.location.protocol + '//' + host + '/rtbg'; + } + + function buildRequestParams(zone, rtbReq) { + return { + 'zone': encodeURIComponent(zone), + 'ad_type': 'rtb', + 'r': encodeURIComponent(JSON.stringify(rtbReq)) + }; + } + } + + /** + * Main module export function implementation + */ + function _callBids(params) { + var bids = params.bids || []; + processBids(bids); + } + + /** + * Process all bids grouped by network/zone + */ + function processBids(bids) { + const dispatcher = new RtbRequestDispatcher(); + //process individual bids + utils._each(bids, (bid) => { + if (!validateBidParams(bid.params)) { + utils.logError('Incorrect configuration for adkernel bidder:', bid.params); + bidmanager.addBidResponse(bid.placementCode, createEmptyBidObject(bid)); + } else { + dispatcher.addImp(bid); + } + }); + //process bids grouped into bidrequests + dispatcher.dispatch((bid, imp, bidResp) => { + let adUnitId = bid.placementCode; + if (bidResp) { + utils.logMessage('got response for ' + adUnitId); + bidmanager.addBidResponse(adUnitId, createBidObject(bidResp, bid, imp.banner.w, imp.banner.h)); + } else { + utils.logMessage('got empty response for ' + adUnitId); + bidmanager.addBidResponse(adUnitId, createEmptyBidObject(bid)); + } + }); + } + + /** + * Create bid object for the bid manager + */ + function createBidObject(resp, bid, width, height) { + return utils.extend(bidfactory.createBid(1, bid), { + bidderCode: ADKERNEL, + ad: formatAdmarkup(resp), + width: width, + height: height, + cpm: parseFloat(resp.price) + }); + } + + /** + * Create empty bid object for the bid manager + */ + function createEmptyBidObject(bid) { + return utils.extend(bidfactory.createBid(2, bid), { + bidderCode: ADKERNEL + }); + } + + /** + * Format creative with optional nurl call + */ + function formatAdmarkup(bid) { + var adm = bid.adm; + if ('nurl' in bid) { + adm += utils.createTrackPixelHtml(bid.nurl); + } + return adm; + } + + function validateBidParams(params) { + return typeof params.host !== 'undefined' && typeof params.zoneId !== 'undefined'; + } + + /** + * Creates site description object + */ + function createSite() { + var location = utils.getTopWindowLocation(); + return { + 'domain': location.hostname, + 'page': location.pathname + }; + } + + return { + callBids: _callBids + }; +}; + +module.exports = AdkernelAdapter; \ No newline at end of file diff --git a/test/spec/adapters/adkernel_spec.js b/test/spec/adapters/adkernel_spec.js new file mode 100644 index 00000000000..1887662ba19 --- /dev/null +++ b/test/spec/adapters/adkernel_spec.js @@ -0,0 +1,247 @@ +import {expect} from "chai"; +import Adapter from "src/adapters/adkernel"; +import * as ajax from "src/ajax"; +import * as utils from "src/utils"; +import bidmanager from "src/bidmanager"; +import CONSTANTS from "src/constants.json"; + +describe("Adkernel adapter", () => { + + const bid1_zone1 = { + bidder: "adkernel", + bidId: "Bid_01", + params: {zoneId: 1, host: "rtb.adkernel.com"}, + placementCode: "ad-unit-1", + sizes: [[300, 250]] + }, bid2_zone2 = { + bidder: "adkernel", + bidId: "Bid_02", + params: {zoneId: 2, host: "rtb.adkernel.com"}, + placementCode: "ad-unit-2", + sizes: [[728, 90]] + }, bid3_host2 = { + bidder: "adkernel", + bidId: "Bid_02", + params: {zoneId: 1, host: "rtb-private.adkernel.com"}, + placementCode: "ad-unit-2", + sizes: [[728, 90]] + }, bid_without_zone = { + bidder: "adkernel", + bidId: "Bid_W", + params: {host: "rtb-private.adkernel.com"}, + placementCode: "ad-unit-1", + sizes: [[728, 90]] + }, bid_without_host = { + bidder: "adkernel", + bidId: "Bid_W", + params: {zoneId: 1}, + placementCode: "ad-unit-1", + sizes: [[728, 90]] + }; + + const bidResponse1 = { + "id": "bid1", + "seatbid": [{ + "bid": [{ + "id": "1", + "impid": "Bid_01", + "price": 3.01, + "nurl": "https://rtb.com/win?i=ZjKoPYSFI3Y_0", + "adm": "" + }] + }], + "cur": "USD" + }, bidResponse2 = { + "id": "bid2", + "seatbid": [{ + "bid": [{ + "id": "2", + "impid": "Bid_02", + "price": 1.31, + "adm": "" + }] + }], + "cur": "USD" + }; + + let adapter, + sandbox, + ajaxStub; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + adapter = new Adapter(); + ajaxStub = sandbox.stub(ajax, "ajax"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + function doRequest(bids) { + adapter.callBids({ + bidderCode: "adkernel", + bids: bids + }); + } + + describe("input parameters validation", ()=> { + let spy; + + beforeEach(() => { + spy = sandbox.spy(); + sandbox.stub(bidmanager, 'addBidResponse'); + }); + + it("empty request shouldn't generate exception", () => { + expect(adapter.callBids({ + bidderCode: "adkernel" + })).to.be.an('undefined'); + }); + + it("request without zone shouldn't issue a request", () => { + doRequest([bid_without_zone]); + sinon.assert.notCalled(ajaxStub); + expect(bidmanager.addBidResponse.firstCall.args[1].getStatusCode()).to.equal(CONSTANTS.STATUS.NO_BID); + expect(bidmanager.addBidResponse.firstCall.args[1].bidderCode).to.equal("adkernel"); + }); + + it("request without host shouldn't issue a request", () => { + doRequest([bid_without_host]); + sinon.assert.notCalled(ajaxStub); + expect(bidmanager.addBidResponse.firstCall.args[1].getStatusCode()).to.equal(CONSTANTS.STATUS.NO_BID); + expect(bidmanager.addBidResponse.firstCall.args[1].bidderCode).to.equal("adkernel"); + }); + }); + + describe("request building", () => { + let bidRequest; + + beforeEach(() => { + sandbox.stub(utils, 'getTopWindowLocation', () => { + return { + protocol: 'https:', + hostname: 'example.com', + host: 'example.com', + pathname: '/index.html' + }; + }); + + ajaxStub.onCall(0).callsArgWith(1, JSON.stringify(bidResponse1)); + doRequest([bid1_zone1]); + bidRequest = JSON.parse(decodeURIComponent(ajaxStub.getCall(0).args[2].r)); + }); + + it("should be a first-price auction", () => { + expect(bidRequest).to.have.property('at', 1); + }); + + it("should have banner object", () => { + expect(bidRequest.imp[0]).to.have.property('banner'); + }); + + it("should have h/w", () => { + expect(bidRequest.imp[0].banner).to.have.property('w', 300); + expect(bidRequest.imp[0].banner).to.have.property('h', 250); + }); + + it("should respect secure connection", () => { + expect(bidRequest.imp[0]).to.have.property('secure', 1); + }); + + it("should create proper site block", () => { + expect(bidRequest.site).to.have.property('domain', 'example.com'); + expect(bidRequest.site).to.have.property('page', '/index.html'); + }); + + it("should fill device with caller macro", ()=> { + expect(bidRequest).to.have.property('device'); + expect(bidRequest.device).to.have.property('ip', 'caller'); + expect(bidRequest.device).to.have.property('ua', 'caller'); + }) + + }); + + describe("requests routing", () => { + + it("should issue a request for each network", () => { + ajaxStub.onFirstCall().callsArgWith(1, "") + .onSecondCall().callsArgWith(1, ""); + doRequest([bid1_zone1, bid3_host2]); + expect(ajaxStub.calledTwice); + expect(ajaxStub.firstCall.args[0]).to.include(bid1_zone1.params.host); + expect(ajaxStub.secondCall.args[0]).to.include(bid3_host2.params.host); + }); + + it("should issue a request for each zone", () => { + ajaxStub.onCall(0).callsArgWith(1, JSON.stringify(bidResponse1)); + ajaxStub.onCall(1).callsArgWith(1, JSON.stringify(bidResponse2)); + doRequest([bid1_zone1, bid2_zone2]); + expect(ajaxStub.calledTwice); + }); + + it("should route calls to proper zones", () => { + ajaxStub.onCall(0).callsArgWith(1, JSON.stringify(bidResponse1)); + ajaxStub.onCall(1).callsArgWith(1, JSON.stringify(bidResponse2)); + doRequest([bid1_zone1, bid2_zone2]); + expect(ajaxStub.firstCall.args[2].zone).to.equal('1'); + expect(ajaxStub.secondCall.args[2].zone).to.equal('2'); + }); + }); + + describe("responses processing", () => { + + beforeEach(() => { + sandbox.stub(bidmanager, 'addBidResponse'); + }); + + it("should return fully-initialized bid-response", () => { + ajaxStub.onCall(0).callsArgWith(1, JSON.stringify(bidResponse1)); + doRequest([bid1_zone1]); + let bidResponse = bidmanager.addBidResponse.firstCall.args[1]; + expect(bidmanager.addBidResponse.firstCall.args[0]).to.equal('ad-unit-1'); + expect(bidResponse.getStatusCode()).to.equal(CONSTANTS.STATUS.GOOD); + expect(bidResponse.bidderCode).to.equal("adkernel"); + expect(bidResponse.cpm).to.equal(3.01); + expect(bidResponse.ad).to.include(''); + expect(bidResponse.width).to.equal(300); + expect(bidResponse.height).to.equal(250); + }); + + it("should map responses to proper ad units", () => { + ajaxStub.onCall(0).callsArgWith(1, JSON.stringify(bidResponse1)); + ajaxStub.onCall(1).callsArgWith(1, JSON.stringify(bidResponse2)); + doRequest([bid1_zone1, bid2_zone2]); + expect(bidmanager.addBidResponse.firstCall.args[1].getStatusCode()).to.equal(CONSTANTS.STATUS.GOOD); + expect(bidmanager.addBidResponse.firstCall.args[1].bidderCode).to.equal("adkernel"); + expect(bidmanager.addBidResponse.firstCall.args[0]).to.equal('ad-unit-1'); + expect(bidmanager.addBidResponse.secondCall.args[1].getStatusCode()).to.equal(CONSTANTS.STATUS.GOOD); + expect(bidmanager.addBidResponse.secondCall.args[1].bidderCode).to.equal("adkernel"); + expect(bidmanager.addBidResponse.secondCall.args[0]).to.equal('ad-unit-2'); + }); + + it("should process empty responses", () => { + ajaxStub.onCall(0).callsArgWith(1, JSON.stringify(bidResponse1)); + ajaxStub.onCall(1).callsArgWith(1, ""); + doRequest([bid1_zone1, bid2_zone2]); + expect(bidmanager.addBidResponse.firstCall.args[1].getStatusCode()).to.equal(CONSTANTS.STATUS.GOOD); + expect(bidmanager.addBidResponse.firstCall.args[1].bidderCode).to.equal("adkernel"); + expect(bidmanager.addBidResponse.firstCall.args[0]).to.equal('ad-unit-1'); + expect(bidmanager.addBidResponse.secondCall.args[1].getStatusCode()).to.equal(CONSTANTS.STATUS.NO_BID); + expect(bidmanager.addBidResponse.secondCall.args[1].bidderCode).to.equal("adkernel"); + expect(bidmanager.addBidResponse.secondCall.args[0]).to.equal('ad-unit-2'); + }); + + it("should add nurl as pixel", () => { + sandbox.spy(utils, 'createTrackPixelHtml'); + ajaxStub.onCall(0).callsArgWith(1, JSON.stringify(bidResponse1)); + doRequest([bid1_zone1]); + expect(bidmanager.addBidResponse.firstCall.args[1].getStatusCode()).to.equal(CONSTANTS.STATUS.GOOD); + expect(utils.createTrackPixelHtml.calledOnce); + let result = pbjs.getBidResponsesForAdUnitCode(bid1_zone1.placementCode); + expect(result.bids[0].ad).to.include(bidResponse1.seatbid[0].bid[0].nurl); + }); + + }); + +});