Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Native support for NextRoll adapter #5319

Merged
merged 11 commits into from
Jun 11, 2020
159 changes: 149 additions & 10 deletions modules/nextrollBidAdapter.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import * as utils from '../src/utils.js';
import { registerBidder } from '../src/adapters/bidderFactory.js';
import { BANNER } from '../src/mediaTypes.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 = 4;
const ADAPTER_VERSION = 5;

export const spec = {
code: BIDDER_CODE,
supportedMediaTypes: [BANNER],
supportedMediaTypes: [BANNER, NATIVE],

/**
* Determines whether or not the given bid request is valid.
Expand Down Expand Up @@ -43,9 +43,8 @@ export const spec = {
imp: {
id: bidRequest.bidId,
bidfloor: utils.getBidIdParameter('bidfloor', bidRequest.params),
banner: {
format: _getSizes(bidRequest)
},
banner: _getBanner(bidRequest),
native: _getNative(utils.deepAccess(bidRequest, 'mediaTypes.native')),
ext: {
zone: {
id: utils.getBidIdParameter('zoneId', bidRequest.params)
Expand Down Expand Up @@ -82,6 +81,98 @@ export const spec = {
}
}

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) {
let asset = mediaTypeNative[assetMap.key]
if (asset === undefined) return undefined
let 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 (!utils.isPlainObject(aspectRatio)) return 1

let ratio = aspectRatio['ratio_' + property]
let min = aspectRatio['min_' + property]

if (utils.isNumber(ratio)) return ratio
if (utils.isNumber(min)) return min

return 1
}

function _getImageAsset(image, assetMap) {
let sizes = image.sizes
let 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) {
let id = utils.deepAccess(requests, '0.userId.nextroll');
if (id === undefined) {
Expand All @@ -99,8 +190,7 @@ function _getUser(requests) {
}

function _buildResponse(bidResponse, bid) {
const adm = utils.replaceAuctionPrice(bid.adm, bid.price);
return {
let response = {
requestId: bidResponse.id,
cpm: bid.price,
width: bid.w,
Expand All @@ -109,8 +199,53 @@ function _buildResponse(bidResponse, bid) {
dealId: bidResponse.dealId,
currency: 'USD',
netRevenue: true,
ttl: 300,
ad: adm
ttl: 300
}
if (utils.isStr(bid.adm)) {
response.mediaType = BANNER
response.ad = utils.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: utils.replaceAuctionPrice(adm.link.url, price),
impressionTrackers: adm.imptrackers.map(impTracker => utils.replaceAuctionPrice(impTracker, price)),
privacyLink: privacyLink,
privacyIcon: privacyIcon
}
return adm.assets.reduce((accResponse, asset) => {
let assetMaps = NATIVE_ASSET_MAP.filter(assetMap => assetMap.id === asset.id && asset[assetMap.kind] !== undefined)
if (assetMaps.length === 0) return accResponse
let 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
}
}

Expand All @@ -131,6 +266,9 @@ function _getSeller(bidRequest) {
}

function _getSizes(bidRequest) {
if (!utils.isArray(bidRequest.sizes)) {
return undefined
}
return bidRequest.sizes.filter(_isValidSize).map(size => {
return {
w: size[0],
Expand Down Expand Up @@ -191,6 +329,7 @@ function _getOsVersion(userAgent) {
}

export function hasCCPAConsent(bidderRequest) {
if (bidderRequest === undefined) return true;
if (typeof bidderRequest.uspConsent !== 'string') {
return true;
}
Expand Down
29 changes: 28 additions & 1 deletion modules/nextrollBidAdapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ Maintainer: prebid@nextroll.com
# Description

Module that connects to NextRoll's bidders.
The NextRoll bid adapter supports Banner format only.
The NextRoll bid adapter supports banner and native format.

# Test Parameters

## Banner Example
``` javascript
var adUnits = [
{
Expand Down Expand Up @@ -47,4 +49,29 @@ var adUnits = [
}]
}
]
```

## Native Example
```javascript
var adUnits = [
{
code: 'div-1',
mediaTypes: {
native: {
title: { required: true, len: 80 },
image: { required: true, sizes: [728, 90] },
sponsoredBy: { required: false, len: 20 }
}
},
bids: [{
bidder: 'nextroll',
params: {
bidfloor: 1,
zoneId: "13144370",
publisherId: "publisherId",
sellerId: "sellerId",
}
}]
}
];
```
117 changes: 117 additions & 0 deletions test/spec/modules/nextrollBidAdapter_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,44 @@ describe('nextrollBidAdapter', function() {
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;
Expand Down Expand Up @@ -142,6 +180,85 @@ describe('nextrollBidAdapter', function() {
});
});

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

describe('hasCCPAConsent', function() {
function ccpaRequest(consentString) {
return {
Expand Down