From 0ae0bff88755148276bc52bc7a0f8e35d5a32a9e Mon Sep 17 00:00:00 2001 From: Abimael Martinez Date: Tue, 25 Feb 2020 20:25:31 -0700 Subject: [PATCH] NextRoll Bidder Adapter (#4829) * First implementation of the AdRoll adapter (#1) * Fix request and bid id (#5) * Send Zone ID (#6) * Add age check before fastbid eval (#7) * Add age check before fastbid eval * Fix linting * Add date check (#8) * Add date exists check * Remove logging statement * Fix bidRequest validation (#9) * Fix deprecated function usage (#10) * [SENG-2757] remove custom function from adapter (#11) * remove loadExternalScript function * add adroll to the adloader whitelist * Handle nextroll id (#12) * Handle nextroll id * Remove double nesting in user obj * Revert change to publisherTagAvailable * Rename adroll -> nextroll (#14) * Rename fastbid -> pubtag functions and variables (#15) * Improve coverage of tests * Add docs * Add docs * Improve sizes and add sellerid * Add maintainer email * Fix CI problem * Fix IE tests * Replace second instance of find * Fix types used in the doc Match https://github.com/prebid/prebid.github.io/pull/1796 * Remove unused fields in spec * Add ccpa support * Remove external script usage * Remove IP field * Remove pubtag key * Rename imports; Remove getUserSync function; Remove unused code; Use url.parse function Co-authored-by: Juan Bono Co-authored-by: Ricardo Azpeitia Pimentel --- modules/nextrollBidAdapter.js | 215 +++++++++++++++++++ modules/nextrollBidAdapter.md | 50 +++++ test/spec/modules/nextrollBidAdapter_spec.js | 183 ++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 modules/nextrollBidAdapter.js create mode 100644 modules/nextrollBidAdapter.md create mode 100644 test/spec/modules/nextrollBidAdapter_spec.js diff --git a/modules/nextrollBidAdapter.js b/modules/nextrollBidAdapter.js new file mode 100644 index 00000000000..e85efceba68 --- /dev/null +++ b/modules/nextrollBidAdapter.js @@ -0,0 +1,215 @@ +import * as utils from '../src/utils.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER } from '../src/mediaTypes.js'; +import { parse as parseUrl } from '../src/url.js'; + +import find from 'core-js/library/fn/array/find.js'; + +const BIDDER_CODE = 'nextroll'; +const BIDDER_ENDPOINT = 'https://d.adroll.com/bid/prebid/'; +const ADAPTER_VERSION = 4; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER], + + /** + * Determines whether or not the given bid request is valid. + * + * @param {BidRequest} bid The bid params to validate. + * @return boolean True if this is a valid bid, and false otherwise. + */ + isBidRequestValid: function (bidRequest) { + return bidRequest !== undefined && !!bidRequest.params && !!bidRequest.bidId; + }, + + /** + * Make a server request from the list of BidRequests. + * + * @param {validBidRequests[]} - an array of bids + * @return ServerRequest Info describing the request to the server. + */ + buildRequests: function (validBidRequests, bidderRequest) { + let topLocation = parseUrl(utils.deepAccess(bidderRequest, 'refererInfo.referer')); + let consent = hasCCPAConsent(bidderRequest); + return validBidRequests.map((bidRequest, index) => { + return { + method: 'POST', + options: { + withCredentials: consent, + }, + url: BIDDER_ENDPOINT, + data: { + id: bidRequest.bidId, + imp: { + id: bidRequest.bidId, + bidfloor: utils.getBidIdParameter('bidfloor', bidRequest.params), + banner: { + format: _getSizes(bidRequest) + }, + ext: { + zone: { + id: utils.getBidIdParameter('zoneId', bidRequest.params) + }, + nextroll: { + adapter_version: ADAPTER_VERSION + } + } + }, + + user: _getUser(validBidRequests), + site: _getSite(bidRequest, topLocation), + seller: _getSeller(bidRequest), + device: _getDevice(bidRequest), + } + } + }) + }, + + /** + * Unpack the response from the server into a list of bids. + * + * @param {ServerResponse} serverResponse A successful response from the server. + * @return {Bid[]} An array of bids which were nested inside the server. + */ + interpretResponse: function (serverResponse, bidRequest) { + if (!serverResponse.body) { + return []; + } else { + let response = serverResponse.body + let bids = response.seatbid.reduce((acc, seatbid) => acc.concat(seatbid.bid), []); + return bids.map((bid) => _buildResponse(response, bid)); + } + } +} + +function _getUser(requests) { + let id = utils.deepAccess(requests, '0.userId.nextroll'); + if (id === undefined) { + return + } + + return { + ext: { + eid: [{ + 'source': 'nextroll', + id + }] + } + } +} + +function _buildResponse(bidResponse, bid) { + const adm = utils.replaceAuctionPrice(bid.adm, bid.price); + return { + requestId: bidResponse.id, + cpm: bid.price, + width: bid.w, + height: bid.h, + creativeId: bid.crid, + dealId: bidResponse.dealId, + currency: 'USD', + netRevenue: true, + ttl: 300, + ad: adm + } +} + +function _getSite(bidRequest, topLocation) { + return { + page: topLocation.href, + domain: topLocation.hostname, + publisher: { + id: utils.getBidIdParameter('publisherId', bidRequest.params) + } + } +} + +function _getSeller(bidRequest) { + return { + id: utils.getBidIdParameter('sellerId', bidRequest.params) + } +} + +function _getSizes(bidRequest) { + return bidRequest.sizes.filter(_isValidSize).map(size => { + return { + w: size[0], + h: size[1] + } + }) +} + +function _isValidSize(size) { + const isNumber = x => typeof x === 'number'; + return (size.length === 2 && isNumber(size[0]) && isNumber(size[1])); +} + +function _getDevice(_bidRequest) { + return { + ua: navigator.userAgent, + language: navigator['language'], + os: _getOs(navigator.userAgent.toLowerCase()), + osv: _getOsVersion(navigator.userAgent) + } +} + +function _getOs(userAgent) { + const osTable = { + 'android': /android/i, + 'ios': /iphone|ipad/i, + 'mac': /mac/i, + 'linux': /linux/i, + 'windows': /windows/i + }; + + return find(Object.keys(osTable), os => { + if (userAgent.match(osTable[os])) { + return os; + } + }) || 'etc'; +} + +function _getOsVersion(userAgent) { + let clientStrings = [ + { s: 'Android', r: /Android/ }, + { s: 'iOS', r: /(iPhone|iPad|iPod)/ }, + { s: 'Mac OS X', r: /Mac OS X/ }, + { s: 'Mac OS', r: /(MacPPC|MacIntel|Mac_PowerPC|Macintosh)/ }, + { s: 'Linux', r: /(Linux|X11)/ }, + { s: 'Windows 10', r: /(Windows 10.0|Windows NT 10.0)/ }, + { s: 'Windows 8.1', r: /(Windows 8.1|Windows NT 6.3)/ }, + { s: 'Windows 8', r: /(Windows 8|Windows NT 6.2)/ }, + { s: 'Windows 7', r: /(Windows 7|Windows NT 6.1)/ }, + { s: 'Windows Vista', r: /Windows NT 6.0/ }, + { s: 'Windows Server 2003', r: /Windows NT 5.2/ }, + { s: 'Windows XP', r: /(Windows NT 5.1|Windows XP)/ }, + { s: 'UNIX', r: /UNIX/ }, + { s: 'Search Bot', r: /(nuhk|Googlebot|Yammybot|Openbot|Slurp|MSNBot|Ask Jeeves\/Teoma|ia_archiver)/ } + ]; + let cs = find(clientStrings, cs => cs.r.test(userAgent)); + return cs ? cs.s : 'unknown'; +} + +export function hasCCPAConsent(bidderRequest) { + if (typeof bidderRequest.uspConsent !== 'string') { + return true; + } + const usps = bidderRequest.uspConsent; + const version = usps[0]; + + // If we don't support the consent string, assume no-consent. + if (version !== '1' || usps.length < 3) { + return false; + } + + const notice = usps[1]; + const optOut = usps[2]; + + if (notice === 'N' || optOut === 'Y') { + return false; + } + return true; +} + +registerBidder(spec); diff --git a/modules/nextrollBidAdapter.md b/modules/nextrollBidAdapter.md new file mode 100644 index 00000000000..2f57987f985 --- /dev/null +++ b/modules/nextrollBidAdapter.md @@ -0,0 +1,50 @@ +# Overview + +``` +Module Name: NextRoll Bid Adapter +Module Type: Bidder Adapter +Maintainer: prebid@nextroll.com +``` + +# Description + +Module that connects to NextRoll's bidders. +The NextRoll bid adapter supports Banner format only. + +# Test Parameters +``` javascript +var adUnits = [ + { + code: 'div-1', + mediatypes: { + banner: {sizes: [[300, 250], [160, 600]]} + }, + bids: [{ + bidder: 'nextroll', + params: { + bidfloor: 1, + zoneId: "13144370", + publisherId: "publisherid", + sellerId: "sellerid" + } + }] + }, + { + code: 'div-2', + mediatypes: { + banner: { + sizes: [[728, 90], [970, 250]] + } + }, + bids: [{ + bidder: 'nextroll', + params: { + bidfloor: 2.3, + zoneId: "13144370", + publisherId: "publisherid", + sellerId: "sellerid" + } + }] + } +] +``` \ No newline at end of file diff --git a/test/spec/modules/nextrollBidAdapter_spec.js b/test/spec/modules/nextrollBidAdapter_spec.js new file mode 100644 index 00000000000..85cd45be1d0 --- /dev/null +++ b/test/spec/modules/nextrollBidAdapter_spec.js @@ -0,0 +1,183 @@ +import { expect } from 'chai'; +import { spec, tryGetPubtag, hasCCPAConsent } from 'modules/nextrollBidAdapter.js'; +import * as utils from 'src/utils.js'; + +describe('nextrollBidAdapter', function() { + let utilsMock; + beforeEach(function () { + utilsMock = sinon.mock(utils); + }); + + afterEach(function() { + global.NextRoll = undefined; + utilsMock.restore(); + }); + + let validBid = { + bidder: 'nextroll', + adUnitCode: 'adunit-code', + bidId: 'bid_id', + sizes: [[300, 200]], + params: { + bidfloor: 1, + zoneId: 'zone1', + publisherId: 'publisher_id' + } + }; + let bidWithoutValidId = { id: '' }; + let bidWithoutId = { params: { zoneId: 'zone1' } }; + + describe('isBidRequestValid', function() { + it('validates the bids correctly when the bid has an id', function() { + expect(spec.isBidRequestValid(validBid)).to.be.true; + }); + + it('validates the bids correcly when the bid does not have an id', function() { + expect(spec.isBidRequestValid(bidWithoutValidId)).to.be.false; + expect(spec.isBidRequestValid(bidWithoutId)).to.be.false; + }); + }); + + describe('buildRequests', function() { + it('builds the same amount of requests as valid requests it takes', function() { + expect(spec.buildRequests([validBid, validBid], {})).to.be.lengthOf(2); + }); + + it('doest not build a request when there is no valid requests', function () { + expect(spec.buildRequests([], {})).to.be.lengthOf(0); + }); + + it('builds a request with POST method', function () { + expect(spec.buildRequests([validBid], {})[0].method).to.equal('POST'); + }); + + it('builds a request with cookies method', function () { + expect(spec.buildRequests([validBid], {})[0].options.withCredentials).to.be.true; + }); + + it('builds a request with id, url and imp object', function () { + const request = spec.buildRequests([validBid], {})[0]; + expect(request.data.id).to.be.an('string').that.is.not.empty; + expect(request.url).to.equal('https://d.adroll.com/bid/prebid/'); + expect(request.data.imp).to.exist.and.to.be.a('object'); + }); + + it('builds a request with site and device information', function () { + const request = spec.buildRequests([validBid], {})[0]; + + expect(request.data.site).to.exist.and.to.be.a('object'); + expect(request.data.device).to.exist.and.to.be.a('object'); + }); + + it('builds a request with a complete imp object', function () { + const request = spec.buildRequests([validBid], {})[0]; + + expect(request.data.imp.id).to.equal('bid_id'); + expect(request.data.imp.bidfloor).to.be.equal(1); + expect(request.data.imp.banner).to.exist.and.to.be.a('object'); + expect(request.data.imp.ext.zone.id).to.be.equal('zone1'); + }); + + it('includes the sizes into the request correctly', function () { + const bannerObject = spec.buildRequests([validBid], {})[0].data.imp.banner; + + expect(bannerObject.format).to.exist; + expect(bannerObject.format).to.be.lengthOf(1); + expect(bannerObject.format[0].w).to.be.equal(300); + expect(bannerObject.format[0].h).to.be.equal(200); + }); + }); + + describe('interpretResponse', function () { + let responseBody = { + id: 'bidresponse_id', + dealId: 'deal_id', + seatbid: [ + { + bid: [ + { + price: 1.2, + w: 300, + h: 200, + crid: 'crid1', + adm: 'adm1' + } + ] + }, + { + bid: [ + { + price: 2.1, + w: 250, + h: 300, + crid: 'crid2', + adm: 'adm2' + } + ] + } + ] + }; + + it('returns an empty list when there is no response body', function () { + expect(spec.interpretResponse({}, {})).to.be.eql([]); + }); + + it('builds the same amount of responses as server responses it receives', function () { + expect(spec.interpretResponse({body: responseBody}, {})).to.be.lengthOf(2); + }); + + it('builds a response with the expected fields', function () { + const response = spec.interpretResponse({body: responseBody}, {})[0]; + + expect(response.requestId).to.be.equal('bidresponse_id'); + expect(response.cpm).to.be.equal(1.2); + expect(response.width).to.be.equal(300); + expect(response.height).to.be.equal(200); + expect(response.creativeId).to.be.equal('crid1'); + expect(response.dealId).to.be.equal('deal_id'); + expect(response.currency).to.be.equal('USD'); + expect(response.netRevenue).to.be.equal(true); + expect(response.ttl).to.be.equal(300); + expect(response.ad).to.be.equal('adm1'); + }); + }); + + describe('hasCCPAConsent', function() { + function ccpaRequest(consentString) { + return { + bidderCode: 'bidderX', + auctionId: 'e3a336ad-2222-4a1c-bbbb-ecc7c5554a34', + uspConsent: consentString + }; + } + + const noNoticeCases = ['1NYY', '1NNN', '1N--']; + noNoticeCases.forEach((ccpaString, index) => { + it(`No notice should indicate no consent (case ${index})`, function () { + const req = ccpaRequest(ccpaString); + expect(hasCCPAConsent(req)).to.be.false; + }); + }); + + const noConsentCases = ['1YYY', '1YYN', '1YY-']; + noConsentCases.forEach((ccpaString, index) => { + it(`Opt-Out should indicate no consent (case ${index})`, function () { + const req = ccpaRequest(ccpaString); + expect(hasCCPAConsent(req)).to.be.false; + }); + }); + + const consentCases = [undefined, '1YNY', '1YN-', '1Y--', '1---']; + consentCases.forEach((ccpaString, index) => { + it(`should indicate consent (case ${index})`, function() { + const req = ccpaRequest(ccpaString); + expect(hasCCPAConsent(req)).to.be.true; + }) + }); + + it('builds a request with no credentials', function () { + const noConsent = ccpaRequest('1YYY'); + expect(spec.buildRequests([validBid], noConsent)[0].options.withCredentials).to.be.false; + }); + }); +});