diff --git a/modules/nextrollBidAdapter.js b/modules/nextrollBidAdapter.js new file mode 100644 index 00000000000..b5af7ec1486 --- /dev/null +++ b/modules/nextrollBidAdapter.js @@ -0,0 +1,375 @@ +import { + deepAccess, + parseUrl, + isNumber, + getBidIdParameter, + isPlainObject, + isFn, + isStr, + replaceAuctionPrice, + isArray, +} from '../src/utils.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE } from '../src/mediaTypes.js'; + +import find from 'core-js-pure/features/array/find.js'; + +const BIDDER_CODE = 'nextroll'; +const BIDDER_ENDPOINT = 'https://d.adroll.com/bid/prebid/'; +const ADAPTER_VERSION = 5; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, NATIVE], + + /** + * 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(deepAccess(bidderRequest, 'refererInfo.referer')); + + return validBidRequests.map((bidRequest) => { + return { + method: 'POST', + options: { + withCredentials: true, + }, + url: BIDDER_ENDPOINT, + data: { + id: bidRequest.bidId, + imp: { + id: bidRequest.bidId, + bidfloor: _getFloor(bidRequest), + banner: _getBanner(bidRequest), + native: _getNative(deepAccess(bidRequest, 'mediaTypes.native')), + ext: { + zone: { + id: getBidIdParameter('zoneId', bidRequest.params) + }, + nextroll: { + adapter_version: ADAPTER_VERSION + } + } + }, + + user: _getUser(validBidRequests), + site: _getSite(bidRequest, topLocation), + seller: _getSeller(bidRequest), + device: _getDevice(bidRequest), + regs: _getRegs(bidderRequest) + } + }; + }); + }, + + /** + * 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 _getBanner(bidRequest) { + let sizes = _getSizes(bidRequest); + if (sizes === undefined) return undefined; + return {format: sizes}; +} + +function _getNative(mediaTypeNative) { + if (mediaTypeNative === undefined) return undefined; + let assets = _getNativeAssets(mediaTypeNative); + if (assets === undefined || assets.length == 0) return undefined; + return { + request: { + native: { + assets: assets + } + } + }; +} + +/* + id: Unique numeric id for the asset + kind: OpenRTB kind of asset. Supported: title, img and data. + key: Name of property that comes in the mediaType.native object. + type: OpenRTB type for that spefic kind of asset. + required: Overrides the asset required field configured, only overrides when is true. +*/ +const NATIVE_ASSET_MAP = [ + {id: 1, kind: 'title', key: 'title', required: true}, + {id: 2, kind: 'img', key: 'image', type: 3, required: true}, + {id: 3, kind: 'img', key: 'icon', type: 1}, + {id: 4, kind: 'img', key: 'logo', type: 2}, + {id: 5, kind: 'data', key: 'sponsoredBy', type: 1}, + {id: 6, kind: 'data', key: 'body', type: 2} +]; + +const ASSET_KIND_MAP = { + title: _getTitleAsset, + img: _getImageAsset, + data: _getDataAsset, +}; + +function _getAsset(mediaTypeNative, assetMap) { + const asset = mediaTypeNative[assetMap.key]; + if (asset === undefined) return undefined; + const assetFunc = ASSET_KIND_MAP[assetMap.kind]; + return { + id: assetMap.id, + required: (assetMap.required || !!asset.required) ? 1 : 0, + [assetMap.kind]: assetFunc(asset, assetMap) + }; +} + +function _getTitleAsset(title, _assetMap) { + return {len: title.len || 0}; +} + +function _getMinAspectRatio(aspectRatio, property) { + if (!isPlainObject(aspectRatio)) return 1; + + const ratio = aspectRatio['ratio_' + property]; + const min = aspectRatio['min_' + property]; + + if (isNumber(ratio)) return ratio; + if (isNumber(min)) return min; + + return 1; +} + +function _getImageAsset(image, assetMap) { + const sizes = image.sizes; + const aspectRatio = image.aspect_ratios ? image.aspect_ratios[0] : undefined; + + return { + type: assetMap.type, + w: (sizes ? sizes[0] : undefined), + h: (sizes ? sizes[1] : undefined), + wmin: _getMinAspectRatio(aspectRatio, 'width'), + hmin: _getMinAspectRatio(aspectRatio, 'height'), + }; +} + +function _getDataAsset(data, assetMap) { + return { + type: assetMap.type, + len: data.len || 0 + }; +} + +function _getNativeAssets(mediaTypeNative) { + return NATIVE_ASSET_MAP + .map(assetMap => _getAsset(mediaTypeNative, assetMap)) + .filter(asset => asset !== undefined); +} + +function _getUser(requests) { + const id = deepAccess(requests, '0.userId.nextrollId'); + if (id === undefined) { + return; + } + + return { + ext: { + eid: [{ + 'source': 'nextroll', + id + }] + } + }; +} + +function _getFloor(bidRequest) { + if (!isFn(bidRequest.getFloor)) { + return (bidRequest.params.bidfloor) ? bidRequest.params.bidfloor : null; + } + + let floor = bidRequest.getFloor({ + currency: 'USD', + mediaType: '*', + size: '*' + }); + + if (isPlainObject(floor) && !isNaN(floor.floor) && floor.currency === 'USD') { + return floor.floor; + } + return null; +} + +function _buildResponse(bidResponse, bid) { + let response = { + requestId: bidResponse.id, + cpm: bid.price, + width: bid.w, + height: bid.h, + creativeId: bid.crid, + dealId: bidResponse.dealId, + currency: 'USD', + netRevenue: true, + ttl: 300, + meta: { + advertiserDomains: bidResponse.adomain || [] + } + }; + if (isStr(bid.adm)) { + response.mediaType = BANNER; + response.ad = replaceAuctionPrice(bid.adm, bid.price); + } else { + response.mediaType = NATIVE; + response.native = _getNativeResponse(bid.adm, bid.price); + } + return response; +} + +const privacyLink = 'https://info.evidon.com/pub_info/573'; +const privacyIcon = 'https://c.betrad.com/pub/icon1.png'; + +function _getNativeResponse(adm, price) { + let baseResponse = { + clickTrackers: (adm.link && adm.link.clicktrackers) || [], + jstracker: adm.jstracker || [], + clickUrl: replaceAuctionPrice(adm.link.url, price), + impressionTrackers: adm.imptrackers.map(impTracker => replaceAuctionPrice(impTracker, price)), + privacyLink: privacyLink, + privacyIcon: privacyIcon + }; + return adm.assets.reduce((accResponse, asset) => { + const assetMaps = NATIVE_ASSET_MAP.filter(assetMap => assetMap.id === asset.id && asset[assetMap.kind] !== undefined); + if (assetMaps.length === 0) return accResponse; + const assetMap = assetMaps[0]; + accResponse[assetMap.key] = _getAssetResponse(asset, assetMap); + return accResponse; + }, baseResponse); +} + +function _getAssetResponse(asset, assetMap) { + switch (assetMap.kind) { + case 'title': + return asset.title.text; + + case 'img': + return { + url: asset.img.url, + width: asset.img.w, + height: asset.img.h + }; + + case 'data': + return asset.data.value; + } +} + +function _getSite(bidRequest, topLocation) { + return { + page: topLocation.href, + domain: topLocation.hostname, + publisher: { + id: getBidIdParameter('publisherId', bidRequest.params) + } + }; +} + +function _getSeller(bidRequest) { + return { + id: getBidIdParameter('sellerId', bidRequest.params) + }; +} + +function _getSizes(bidRequest) { + if (!isArray(bidRequest.sizes)) { + return undefined; + } + 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 _getRegs(bidderRequest) { + if (!bidderRequest || !bidderRequest.uspConsent) { + return undefined; + } + return { + ext: { + us_privacy: bidderRequest.uspConsent + } + }; +} + +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) { + const 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'; +} + +registerBidder(spec); diff --git a/test/spec/modules/nextrollBidAdapter_spec.js b/test/spec/modules/nextrollBidAdapter_spec.js new file mode 100644 index 00000000000..4699fbc6e08 --- /dev/null +++ b/test/spec/modules/nextrollBidAdapter_spec.js @@ -0,0 +1,290 @@ +import { expect } from 'chai'; +import { spec } from 'modules/nextrollBidAdapter.js'; +import * as utils from 'src/utils.js'; +import { deepClone } from '../../../src/utils'; + +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('nativeBidRequest', () => { + it('validates native spec', () => { + let nativeAdUnit = [{ + bidder: 'nextroll', + adUnitCode: 'adunit-code', + bidId: 'bid_id', + mediaTypes: { + native: { + title: {required: true, len: 80}, + image: {required: true, sizes: [728, 90]}, + sponsoredBy: {required: false, len: 20}, + clickUrl: {required: true}, + body: {required: true, len: 25}, + icon: {required: true, sizes: [50, 50], aspect_ratios: [{ratio_height: 3, ratio_width: 4}]}, + someRandomAsset: {required: false, len: 100} // This should be ignored + } + }, + params: { + bidfloor: 1, + zoneId: 'zone1', + publisherId: 'publisher_id' + } + }]; + + let request = spec.buildRequests(nativeAdUnit) + let assets = request[0].data.imp.native.request.native.assets + + let excptedAssets = [ + {id: 1, required: 1, title: {len: 80}}, + {id: 2, required: 1, img: {w: 728, h: 90, wmin: 1, hmin: 1, type: 3}}, + {id: 3, required: 1, img: {w: 50, h: 50, wmin: 4, hmin: 3, type: 1}}, + {id: 5, required: 0, data: {len: 20, type: 1}}, + {id: 6, required: 1, data: {len: 25, type: 2}} + ] + expect(assets).to.be.deep.equal(excptedAssets) + }) + }) + + 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('builds a request with the correct floor object', function () { + // bidfloor is defined, getFloor isn't + let bid = deepClone(validBid); + let request = spec.buildRequests([bid], {})[0]; + expect(request.data.imp.bidfloor).to.be.equal(1); + + // bidfloor not defined, getFloor not defined + bid = deepClone(validBid); + bid.params.bidfloor = null; + request = spec.buildRequests([bid], {})[0]; + expect(request.data.imp.bidfloor).to.not.exist; + + // bidfloor defined, getFloor defined, use getFloor + let getFloorResponse = { currency: 'USD', floor: 3 }; + bid = deepClone(validBid); + bid.getFloor = () => getFloorResponse; + request = spec.buildRequests([bid], {})[0]; + + expect(request.data.imp.bidfloor).to.exist.and.to.equal(3); + }); + + 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); + }); + + it('sets the CCPA consent string', function () { + const us_privacy = '1YYY'; + const request = spec.buildRequests([validBid], {'uspConsent': us_privacy})[0]; + + expect(request.data.regs.ext.us_privacy).to.be.equal(us_privacy); + }); + }); + + 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('interpret native response', () => { + let clickUrl = 'https://clickurl.com/with/some/path' + let titleText = 'Some title' + let imgW = 300 + let imgH = 250 + let imgUrl = 'https://clickurl.com/img.png' + let brandText = 'Some Brand' + let impUrl = 'https://clickurl.com/imptracker' + + let responseBody = { + body: { + id: 'bidresponse_id', + seatbid: [{ + bid: [{ + price: 1.2, + crid: 'crid1', + adm: { + link: {url: clickUrl}, + assets: [ + {id: 1, title: {text: titleText}}, + {id: 2, img: {w: imgW, h: imgH, url: imgUrl}}, + {id: 5, data: {value: brandText}} + ], + imptrackers: [impUrl] + } + }] + }] + } + }; + + it('Should interpret response', () => { + let response = spec.interpretResponse(utils.deepClone(responseBody)) + let expectedResponse = { + clickUrl: clickUrl, + impressionTrackers: [impUrl], + privacyLink: 'https://info.evidon.com/pub_info/573', + privacyIcon: 'https://c.betrad.com/pub/icon1.png', + title: titleText, + image: {url: imgUrl, width: imgW, height: imgH}, + sponsoredBy: brandText, + clickTrackers: [], + jstracker: [] + } + + expect(response[0].native).to.be.deep.equal(expectedResponse) + }) + + it('Should interpret all assets', () => { + let allAssetsResponse = utils.deepClone(responseBody) + let iconUrl = imgUrl + '?icon=true', iconW = 10, iconH = 15 + let logoUrl = imgUrl + '?logo=true', logoW = 20, logoH = 25 + let bodyText = 'Some body text' + + allAssetsResponse.body.seatbid[0].bid[0].adm.assets.push(...[ + {id: 3, img: {w: iconW, h: iconH, url: iconUrl}}, + {id: 4, img: {w: logoW, h: logoH, url: logoUrl}}, + {id: 6, data: {value: bodyText}} + ]) + + let response = spec.interpretResponse(allAssetsResponse) + let expectedResponse = { + clickUrl: clickUrl, + impressionTrackers: [impUrl], + jstracker: [], + clickTrackers: [], + privacyLink: 'https://info.evidon.com/pub_info/573', + privacyIcon: 'https://c.betrad.com/pub/icon1.png', + title: titleText, + image: {url: imgUrl, width: imgW, height: imgH}, + icon: {url: iconUrl, width: iconW, height: iconH}, + logo: {url: logoUrl, width: logoW, height: logoH}, + body: bodyText, + sponsoredBy: brandText + } + + expect(response[0].native).to.be.deep.equal(expectedResponse) + }) + }) +});