Skip to content

Commit

Permalink
Tremor Video Bid Adapter (#1552)
Browse files Browse the repository at this point in the history
* Added tremor bid adapter for prebid.js

* - Fixed some tests.

* - Added some comments and changed a few variable names to be in line with what we use at tremor.
- Changed a test to look for document.location.href instead of a hardcoded localhost url.

* - Some formatting.

* - Removed the vastUrl field from the adapter.
  • Loading branch information
tremorvideo authored and dbemiller committed Sep 11, 2017
1 parent 598817f commit 27944ac
Show file tree
Hide file tree
Showing 3 changed files with 341 additions and 1 deletion.
167 changes: 167 additions & 0 deletions modules/tremorBidAdapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* Tremor Video bid Adapter for prebid.js
* */

import Adapter from 'src/adapter';
import bidfactory from 'src/bidfactory';
import bidmanager from 'src/bidmanager';
import * as utils from 'src/utils';
import {ajax} from 'src/ajax';
import {STATUS} from 'src/constants';
import adaptermanager from 'src/adaptermanager';

const ENDPOINT = '.ads.tremorhub.com/ad/tag';

const OPTIONAL_PARAMS = [
'mediaId', 'mediaUrl', 'mediaTitle', 'contentLength', 'floor',
'efloor', 'custom', 'categories', 'keywords', 'blockDomains',
'c2', 'c3', 'c4', 'skip', 'skipmin', 'skipafter', 'delivery',
'placement', 'videoMinBitrate', 'videoMaxBitrate'
];

/**
* Bidder adapter Tremor Video. Given the list of all ad unit tag IDs,
* sends out a bid request. When a bid response is back, registers the bid
* to Prebid.js.
* Steps:
* - Format and send the bid request
* - Evaluate and handle the response
* - Store potential VAST markup
* - Send request to ad server
* - intercept ad server response
* - Check if the vast wrapper URL is http://cdn.tremorhub.com/static/dummy.xml
* - If yes: then render the locally stored VAST markup by directly passing it to your player
* - Else: give the player the VAST wrapper from your ad server
*/
function TremorAdapter() {
let baseAdapter = new Adapter('tremor');

/* Prebid executes this function when the page asks to send out bid requests */
baseAdapter.callBids = function (bidRequest) {
const bids = bidRequest.bids || [];
bids.filter(bid => valid(bid))
.map(bid => {
let url = generateUrl(bid);
if (url) {
ajax(url, response => {
handleResponse(bid, response);
}, null, {method: 'GET', withCredentials: true});
}
});
};

/**
* Generates the url based on the parameters given. Sizes are required.
* The format is: [L,W] or [[L1,W1],...]
* @param bid
* @returns {string}
*/
function generateUrl(bid) {
// get the sizes
let width, height;
if (utils.isArray(bid.sizes) && bid.sizes.length === 2 && (!isNaN(bid.sizes[0]) && !isNaN(bid.sizes[1]))) {
width = bid.sizes[0];
height = bid.sizes[1];
} else if (typeof bid.sizes === 'object') {
// take the primary (first) size from the array
width = bid.sizes[0][0];
height = bid.sizes[0][1];
}
if (width && height) {
let scheme = ((document.location.protocol === 'https:') ? 'https' : 'http') + '://';
let url = scheme + bid.params.supplyCode + ENDPOINT + '?adCode=' + bid.params.adCode;

url += ('&playerWidth=' + width);
url += ('&playerHeight=' + height);
url += ('&srcPageUrl=' + encodeURIComponent(document.location.href));

OPTIONAL_PARAMS.forEach(param => {
if (bid.params[param]) {
url += ('&' + param + '=' + bid.params[param]);
}
});

url = (url + '&fmt=json');

return url;
}
}

/* Notify Prebid of bid responses so bids can get in the auction */
function handleResponse(bidReq, response) {
let bidResult;

try {
bidResult = JSON.parse(response);
} catch (error) {
utils.logError(error);
}

if (!bidResult || bidResult.error) {
let errorMessage = `in response for ${baseAdapter.getBidderCode()} adapter`;
if (bidResult && bidResult.error) {
errorMessage += `: ${bidResult.error}`;
}
utils.logError(errorMessage);

// signal this response is complete
bidmanager.addBidResponse(bidReq.placementCode, createBid(STATUS.NO_BID));
}

if (bidResult.seatbid && bidResult.seatbid.length > 0) {
bidResult.seatbid[0].bid.forEach(tag => {
let status = STATUS.GOOD;
const bid = createBid(status, bidReq, tag);
bidmanager.addBidResponse(bidReq.placementCode, bid);
});
} else {
// signal this response is complete with no bid
bidmanager.addBidResponse(bidReq.placementCode, createBid(STATUS.NO_BID));
}
}

/**
* We require the ad code and the supply code to generate a tag url
* @param bid
* @returns {*}
*/
function valid(bid) {
if (bid.params.adCode && bid.params.supplyCode) {
return bid;
} else {
utils.logError('missing bid params');
}
}

/**
* Create and return a bid object based on status and tag
* @param status
* @param reqBid
* @param response
*/
function createBid(status, reqBid, response) {
let bid = bidfactory.createBid(status, reqBid);
bid.code = baseAdapter.getBidderCode();
bid.bidderCode = baseAdapter.getBidderCode();

if (response) {
bid.cpm = response.price;
bid.crid = response.crid;
bid.vastXml = response.adm;
bid.mediaType = 'video';
}

return bid;
}

return Object.assign(this, {
callBids: baseAdapter.callBids,
setBidderCode: baseAdapter.setBidderCode,
});
}

adaptermanager.registerBidAdapter(new TremorAdapter(), 'tremor', {
supportedMediaTypes: ['video']
});

module.exports = TremorAdapter;
173 changes: 173 additions & 0 deletions test/spec/modules/tremorBidAdapter_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import {expect} from 'chai';
import Adapter from 'modules/tremorBidAdapter';
import bidmanager from 'src/bidmanager';

const AD_CODE = 'ssp-!demo!-lufip';
const SUPPLY_CODE = 'ssp-%21demo%21-rm6rh';
const SIZES = [640, 480];
const REQUEST = {
'code': 'video1',
'sizes': [640, 480],
'mediaType': 'video',
'bids': [{
'bidder': 'tremor',
'params': {
'mediaId': 'MyCoolVideo',
'mediaUrl': '',
'mediaTitle': '',
'contentLength': '',
'floor': '',
'efloor': '',
'custom': '',
'categories': '',
'keywords': '',
'blockDomains': '',
'c2': '',
'c3': '',
'c4': '',
'skip': '',
'skipmin': '',
'skipafter': '',
'delivery': '',
'placement': '',
'videoMinBitrate': '',
'videoMaxBitrate': ''
}
}]
};

const RESPONSE = {
'cur': 'USD',
'id': '3dba13e35f3d42f998bc7e65fd871889',
'seatbid': [{
'seat': 'TremorVideo',
'bid': [{
'adomain': [],
'price': 0.50000,
'id': '3dba13e35f3d42f998bc7e65fd871889',
'adm': '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<VAST version="2.0"> <Ad id="defaultText"> <InLine> <AdSystem version="1.0">Tremor Video</AdSystem> <AdTitle>Test MP4 Creative</AdTitle> <Error><![CDATA[https://events.tremorhub.com/diag?rid=3dba13e35f3d42f998bc7e65fd871889&req_ts=1503951950395&pbid=47376&seatid=60858&aid=348453&asid=null&lid=null&rid=3dba13e35f3d42f998bc7e65fd871889&rtype=VAST_ERR&vastError=[ERRORCODE]&sec=true&adcode=ssp-!demo!-lufip&seatId=60858&pbid=47376&brid=141046&sid=149810&sdom=console.tremorhub.com&aid=348453]]></Error>\n<Impression id="TV"><![CDATA[https://events.tremorhub.com/evt?rid=3dba13e35f3d42f998bc7e65fd871889&req_ts=1503951950395&pbid=47376&seatid=60858&aid=348453&asid=null&lid=null&tuid=97e0d10a4b504700b578e4f7d22cac35&evt=IMP&tvssa=false]]></Impression>\n<Impression/> <Creatives> <Creative> <Linear> <Duration><![CDATA[ 00:00:30 ]]></Duration> <AdParameters><![CDATA[ &referer=- ]]></AdParameters> <MediaFiles> <MediaFile delivery="progressive" height="360" type="video/mp4" width="640"> <![CDATA[https://cdn.tremorhub.com/adUnitTest/tremor_video_test_ad_30sec_640x360.mp4]]> </MediaFile> </MediaFiles> <TrackingEvents>\n<Tracking event="start"><![CDATA[https://events.tremorhub.com/evt?rid=3dba13e35f3d42f998bc7e65fd871889&req_ts=1503951950395&pbid=47376&seatid=60858&aid=348453&asid=null&lid=null&tuid=97e0d10a4b504700b578e4f7d22cac35&evt=start&vastcrtype=linear&crid=]]></Tracking>\n<Tracking event="firstQuartile"><![CDATA[https://events.tremorhub.com/evt?rid=3dba13e35f3d42f998bc7e65fd871889&req_ts=1503951950395&pbid=47376&seatid=60858&aid=348453&asid=null&lid=null&tuid=97e0d10a4b504700b578e4f7d22cac35&evt=firstQuartile&vastcrtype=linear&crid=]]></Tracking>\n<Tracking event="midpoint"><![CDATA[https://events.tremorhub.com/evt?rid=3dba13e35f3d42f998bc7e65fd871889&req_ts=1503951950395&pbid=47376&seatid=60858&aid=348453&asid=null&lid=null&tuid=97e0d10a4b504700b578e4f7d22cac35&evt=midpoint&vastcrtype=linear&crid=]]></Tracking>\n<Tracking event="thirdQuartile"><![CDATA[https://events.tremorhub.com/evt?rid=3dba13e35f3d42f998bc7e65fd871889&req_ts=1503951950395&pbid=47376&seatid=60858&aid=348453&asid=null&lid=null&tuid=97e0d10a4b504700b578e4f7d22cac35&evt=thirdQuartile&vastcrtype=linear&crid=]]></Tracking>\n<Tracking event="complete"><![CDATA[https://events.tremorhub.com/evt?rid=3dba13e35f3d42f998bc7e65fd871889&req_ts=1503951950395&pbid=47376&seatid=60858&aid=348453&asid=null&lid=null&tuid=97e0d10a4b504700b578e4f7d22cac35&evt=complete&vastcrtype=linear&crid=]]></Tracking>\n</TrackingEvents> <VideoClicks>\n<ClickTracking id="TV"><![CDATA[https://events.tremorhub.com/evt?rid=3dba13e35f3d42f998bc7e65fd871889&req_ts=1503951950395&pbid=47376&seatid=60858&aid=348453&asid=null&lid=null&tuid=97e0d10a4b504700b578e4f7d22cac35&evt=click&vastcrtype=linear&crid=]]></ClickTracking>\n</VideoClicks> </Linear> </Creative> </Creatives> <Extensions/> </InLine> </Ad>\n</VAST>\n',
'impid': '1'
}]
}]
};

describe('TremorBidAdapter', () => {
let adapter;

beforeEach(() => adapter = new Adapter());

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');
});

it('requires paramters to make request', () => {
adapter.callBids({});
expect(requests).to.be.empty;
});

it('requires adCode && supplyCode', () => {
let backup = REQUEST.bids[0].params;
REQUEST.bids[0].params = {adCode: AD_CODE};
adapter.callBids(REQUEST);
expect(requests).to.be.empty;
REQUEST.bids[0].params = backup;
});

it('requires proper sizes to make a bid request', () => {
let backupBid = REQUEST;
backupBid.sizes = [];
adapter.callBids(backupBid);
expect(requests).to.be.empty;
});

it('generates a proper ad call URL', () => {
REQUEST.bids[0].params.adCode = AD_CODE;
REQUEST.bids[0].params.supplyCode = SUPPLY_CODE;
REQUEST.bids[0].sizes = SIZES;
adapter.callBids(REQUEST);
const requestUrl = requests[0].url;
let srcPageURl = ('&srcPageUrl=' + encodeURIComponent(document.location.href));
expect(requestUrl).to.equal('http://ssp-%21demo%21-rm6rh.ads.tremorhub.com/ad/tag?adCode=ssp-!demo!-lufip&playerWidth=640&playerHeight=480' + srcPageURl + '&mediaId=MyCoolVideo&fmt=json');
});

it('generates a proper ad call URL given a different size format', () => {
REQUEST.bids[0].params.adCode = AD_CODE;
REQUEST.bids[0].params.supplyCode = SUPPLY_CODE;
REQUEST.bids[0].sizes = [SIZES];
adapter.callBids(REQUEST);
const requestUrl = requests[0].url;
let srcPageURl = ('&srcPageUrl=' + encodeURIComponent(document.location.href));
expect(requestUrl).to.equal('http://ssp-%21demo%21-rm6rh.ads.tremorhub.com/ad/tag?adCode=ssp-!demo!-lufip&playerWidth=640&playerHeight=480' + srcPageURl + '&mediaId=MyCoolVideo&fmt=json');
});
});

describe('response handler', () => {
let server;

beforeEach(() => {
server = sinon.fakeServer.create();
sinon.stub(bidmanager, 'addBidResponse');
});

afterEach(() => {
server.restore();
bidmanager.addBidResponse.restore();
});

it('registers bids', () => {
server.respondWith(JSON.stringify(RESPONSE));

adapter.callBids(REQUEST);
server.respond();
sinon.assert.calledOnce(bidmanager.addBidResponse);

const response = bidmanager.addBidResponse.firstCall.args[1];
expect(response).to.have.property('statusMessage', 'Bid available');
expect(response).to.have.property('cpm', 0.50000);
});

it('handles nobid responses', () => {
server.respondWith(JSON.stringify({
'cur': 'USD',
'id': 'ff83ce7e00df41c9bce79b651afc7c51',
'seatbid': []
}));

adapter.callBids(REQUEST);
server.respond();
sinon.assert.calledOnce(bidmanager.addBidResponse);

const response = bidmanager.addBidResponse.firstCall.args[1];
expect(response).to.have.property(
'statusMessage',
'Bid returned empty or error response'
);
});

it('handles JSON.parse errors', () => {
server.respondWith('');

adapter.callBids(REQUEST);
server.respond();
sinon.assert.calledOnce(bidmanager.addBidResponse);

const response = bidmanager.addBidResponse.firstCall.args[1];
expect(response).to.have.property(
'statusMessage',
'Bid returned empty or error response'
);
});
});
});
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2233,7 +2233,7 @@ d@1:
dependencies:
es5-ext "^0.10.9"

"dargs@github:christian-bromann/dargs":
dargs@christian-bromann/dargs:
version "4.0.1"
resolved "https://codeload.github.com/christian-bromann/dargs/tar.gz/7d6d4164a7c4106dbd14ef39ed8d95b7b5e9b770"
dependencies:
Expand Down

0 comments on commit 27944ac

Please sign in to comment.