From 7c267f73935eed73940c3b83a3b390ac42f04aca Mon Sep 17 00:00:00 2001 From: jsfledd Date: Wed, 27 Jul 2022 11:08:24 -0700 Subject: [PATCH] Nativo Bid Adapter: add Price Floors Module support (#8666) * Initial nativoBidAdapter document creation (js, md and spec) * Fulling working prebid using nativoBidAdapter. Support for GDPR and CCPA in user syncs. * Added defult size settings based on the largest ad unit. Added response body validation. Added consent to request url qs params. * Changed bidder endpoint url * Changed double quotes to single quotes. * Reverted package-json.lock to remove modifications from PR * Added optional bidder param 'url' so the ad server can force- match an existing placement * Lint fix. Added space after if. * Added new QS param to send various adUnit data to adapter endpopint * Updated unit test for new QS param * Added qs param to keep track of ad unit refreshes * Updated bidMap key default value * Updated refresh increment logic * Refactored spread operator for IE11 support * Updated isBidRequestValid check * Refactored Object.enties to use Object.keys to fix CircleCI testing errors * Updated bid mapping key creation to prioritize ad unit code over placementId * Added filtering by ad, advertiser and campaign. * Merged master * Added more robust bidDataMap with multiple key access * Deduped filer values * Rolled back package.json * Duped upstream/master's package.lock file ... not sure how it got changed in the first place * Small refactor of filterData length check. Removed comparison with 0 since a length value of 0 is already falsy. * Added bid sizes to request * Fixed function name in spec. Added unit tests. * Added priceFloor module support * Added protection agains empty url parameter --- modules/nativoBidAdapter.js | 198 ++++++++++++++++++--- test/spec/modules/nativoBidAdapter_spec.js | 149 +++++++++++++++- 2 files changed, 319 insertions(+), 28 deletions(-) diff --git a/modules/nativoBidAdapter.js b/modules/nativoBidAdapter.js index 271ecac7aa2..54c99b17834 100644 --- a/modules/nativoBidAdapter.js +++ b/modules/nativoBidAdapter.js @@ -11,6 +11,8 @@ const GVLID = 263 const TIME_TO_LIVE = 360 const SUPPORTED_AD_TYPES = [BANNER] +const FLOOR_PRICE_CURRENCY = 'USD' +const PRICE_FLOOR_WILDCARD = '*' /** * Keep track of bid data by keys @@ -131,84 +133,106 @@ export const spec = { * @return ServerRequest Info describing the request to the server. */ buildRequests: function (validBidRequests, bidderRequest) { + // Parse values from bid requests const placementIds = new Set() const bidDataMap = BidDataMap() const placementSizes = { length: 0 } + const floorPriceData = {} let placementId, pageUrl - validBidRequests.forEach((request) => { + validBidRequests.forEach((bidRequest) => { pageUrl = deepAccess( - request, + bidRequest, 'params.url', - bidderRequest.refererInfo.page ) - placementId = deepAccess(request, 'params.placementId') + if (pageUrl == undefined || pageUrl === '') { + pageUrl = bidderRequest.refererInfo.page + } + + placementId = deepAccess(bidRequest, 'params.placementId') - const bidDataKeys = [request.adUnitCode] + const bidDataKeys = [bidRequest.adUnitCode] if (placementId && !placementIds.has(placementId)) { placementIds.add(placementId) bidDataKeys.push(placementId) - placementSizes[placementId] = request.sizes + placementSizes[placementId] = bidRequest.sizes placementSizes.length++ } const bidData = { - bidId: request.bidId, - size: getLargestSize(request.sizes), + bidId: bidRequest.bidId, + size: getLargestSize(bidRequest.sizes), } bidDataMap.addBidData(bidData, bidDataKeys) + + const bidRequestFloorPriceData = parseFloorPriceData(bidRequest) + if (bidRequestFloorPriceData) { + floorPriceData[bidRequest.adUnitCode] = bidRequestFloorPriceData + } }) bidRequestMap[bidderRequest.bidderRequestId] = bidDataMap // Build adUnit data - const adUnitData = { - adUnits: validBidRequests.map((adUnit) => { - // Track if we've already requested for this ad unit code - adUnitsRequested[adUnit.adUnitCode] = - adUnitsRequested[adUnit.adUnitCode] !== undefined - ? adUnitsRequested[adUnit.adUnitCode] + 1 - : 0 - return { - adUnitCode: adUnit.adUnitCode, - mediaTypes: adUnit.mediaTypes, - } - }), - } + const adUnitData = buildAdUnitData(validBidRequests) - // Build QS Params + // Build basic required QS Params let params = [ + // Prebid request id { key: 'ntv_pb_rid', value: bidderRequest.bidderRequestId }, + // Ad unit data { key: 'ntv_ppc', value: btoa(JSON.stringify(adUnitData)), // Convert to Base 64 }, + // Number count of requests per ad unit { key: 'ntv_dbr', - value: btoa(JSON.stringify(adUnitsRequested)), + value: btoa(JSON.stringify(adUnitsRequested)), // Convert to Base 64 }, + // Page url { key: 'ntv_url', value: encodeURIComponent(pageUrl), }, ] + // Floor pricing + if (Object.keys(floorPriceData).length) { + params.unshift({ + key: 'ntv_ppf', + value: btoa(JSON.stringify(floorPriceData)), + }) + } + // Add filtering if (adsToFilter.size > 0) { - params.unshift({ key: 'ntv_atf', value: Array.from(adsToFilter).join(',') }) + params.unshift({ + key: 'ntv_atf', + value: Array.from(adsToFilter).join(','), + }) } if (advertisersToFilter.size > 0) { - params.unshift({ key: 'ntv_avtf', value: Array.from(advertisersToFilter).join(',') }) + params.unshift({ + key: 'ntv_avtf', + value: Array.from(advertisersToFilter).join(','), + }) } if (campaignsToFilter.size > 0) { - params.unshift({ key: 'ntv_ctf', value: Array.from(campaignsToFilter).join(',') }) + params.unshift({ + key: 'ntv_ctf', + value: Array.from(campaignsToFilter).join(','), + }) } // Placement Sizes if (placementSizes.length) { - params.unshift({ key: 'ntv_pas', value: btoa(JSON.stringify(placementSizes)) }) + params.unshift({ + key: 'ntv_pas', + value: btoa(JSON.stringify(placementSizes)), + }) } // Add placement IDs @@ -429,6 +453,126 @@ export const spec = { registerBidder(spec) // Utils +export function parseFloorPriceData(bidRequest) { + if (typeof bidRequest.getFloor !== 'function') return + + // Setup price floor data per bid request + let bidRequestFloorPriceData = {} + let bidMediaTypes = bidRequest.mediaTypes + let sizeOptions = new Set() + // Step through meach media type so we can get floor data for each media type per bid request + Object.keys(bidMediaTypes).forEach((mediaType) => { + // Setup price floor data per media type + let mediaTypeData = bidMediaTypes[mediaType] + let mediaTypeFloorPriceData = {} + // Step through each size of the media type so we can get floor data for each size per media type + mediaTypeData.sizes.forEach((size) => { + // Get floor price data per the getFloor method and respective media type / size combination + const priceFloorData = bidRequest.getFloor({ + currency: FLOOR_PRICE_CURRENCY, + mediaType, + size, + }) + // Save the data and track the sizes + mediaTypeFloorPriceData[sizeToString(size)] = priceFloorData.floor + sizeOptions.add(size) + }) + bidRequestFloorPriceData[mediaType] = mediaTypeFloorPriceData + + // Get floor price of current media type with a wildcard size + const sizeWildcardFloor = getSizeWildcardPrice(bidRequest, mediaType) + // Save the wildcard floor price if it was retrieved successfully + if (sizeWildcardFloor.floor > 0) { + mediaTypeFloorPriceData['*'] = sizeWildcardFloor.floor + } + }) + + // Get floor price for wildcard media type using all of the sizes present in the previous media types + const mediaWildCardPrices = getMediaWildcardPrices(bidRequest, [ + PRICE_FLOOR_WILDCARD, + ...Array.from(sizeOptions), + ]) + bidRequestFloorPriceData['*'] = mediaWildCardPrices + + return bidRequestFloorPriceData +} + +/** + * Get price floor data by always setting the size value to the wildcard for a specific size + * @param {Object} bidRequest - The bid request + * @param {String} mediaType - The media type + * @returns {Object} - Bid floor data + */ +export function getSizeWildcardPrice(bidRequest, mediaType) { + return bidRequest.getFloor({ + currency: FLOOR_PRICE_CURRENCY, + mediaType, + size: PRICE_FLOOR_WILDCARD, + }) +} + +/** + * Get price data for a range of sizes and always setting the media type to the wildcard value + * @param {*} bidRequest - The bid request + * @param {*} sizes - The sizes to get the floor price data for + * @returns {Object} - Bid floor data + */ +export function getMediaWildcardPrices( + bidRequest, + sizes = [PRICE_FLOOR_WILDCARD] +) { + const sizePrices = {} + sizes.forEach((size) => { + // MODIFY the bid request's mediaTypes property (so we can get the wildcard media type value) + const temp = bidRequest.mediaTypes + bidRequest.mediaTypes = { PRICE_FLOOR_WILDCARD: temp.sizes } + // Get price floor data + const priceFloorData = bidRequest.getFloor({ + currency: FLOOR_PRICE_CURRENCY, + mediaType: PRICE_FLOOR_WILDCARD, + size, + }) + // RESTORE initial property value + bidRequest.mediaTypes = temp + + // Only save valid floor price data + const key = + size !== PRICE_FLOOR_WILDCARD ? sizeToString(size) : PRICE_FLOOR_WILDCARD + sizePrices[key] = priceFloorData.floor + }) + return sizePrices +} + +/** + * Format size array to a string + * @param {Array} size - Size data [width, height] + * @returns {String} - Formated size string + */ +export function sizeToString(size) { + if (!Array.isArray(size) || size.length < 2) return '' + return `${size[0]}x${size[1]}` +} + +/** + * Build the ad unit data to send back to the request endpoint + * @param {Array} requests - Bid requests + * @returns {Array} - Array of ad unit data + */ +function buildAdUnitData(requests) { + return requests.map((request) => { + // Track if we've already requested for this ad unit code + adUnitsRequested[request.adUnitCode] = + adUnitsRequested[request.adUnitCode] !== undefined + ? adUnitsRequested[request.adUnitCode] + 1 + : 0 + // Return a new object with only the data we need + return { + adUnitCode: request.adUnitCode, + mediaTypes: request.mediaTypes, + } + }) +} + /** * Append QS param to existing string * @param {String} str - String to append to diff --git a/test/spec/modules/nativoBidAdapter_spec.js b/test/spec/modules/nativoBidAdapter_spec.js index 0690c7f90e1..be6b07f9acc 100644 --- a/test/spec/modules/nativoBidAdapter_spec.js +++ b/test/spec/modules/nativoBidAdapter_spec.js @@ -1,5 +1,11 @@ import { expect } from 'chai' import { spec, BidDataMap } from 'modules/nativoBidAdapter.js' +import { + getSizeWildcardPrice, + getMediaWildcardPrices, + sizeToString, + parseFloorPriceData, +} from '../../../modules/nativoBidAdapter' describe('bidDataMap', function () { it('Should fail gracefully if no key value pairs have been added and no key is sent', function () { @@ -94,7 +100,7 @@ describe('nativoBidAdapterTests', function () { const bidRequestString = JSON.stringify(bidRequest) let bidRequests - beforeEach(function() { + beforeEach(function () { // Clone bidRequest each time bidRequests = [JSON.parse(bidRequestString)] }) @@ -119,6 +125,20 @@ describe('nativoBidAdapterTests', function () { expect(request.url).to.include('ntv_pas') }) + it('ntv_url parameter should NOT be empty even if the utl parameter was set as an empty value', function () { + bidRequests[0].params.url = '' + const request = spec.buildRequests(bidRequests, { + bidderRequestId: 123456, + refererInfo: { + referer: 'https://www.test.com', + }, + }) + + expect(request.url).to.exist + expect(request.url).to.be.a('string') + expect(request.url).to.not.be.empty + }) + it('url should NOT contain placement specific query string parameters if placementId option is not provided', function () { bidRequests[0].params = {} const request = spec.buildRequests(bidRequests, { @@ -489,3 +509,130 @@ describe('Response to Request Filter Flow', () => { expect(request.url).to.include('ntv_ctf=234') }) }) + +describe('sizeToString', () => { + it('Formats size array correctly', () => { + const sizeString = sizeToString([300, 250]) + expect(sizeString).to.be.equal('300x250') + }) + + it('Returns an empty array for invalid data', () => { + // Not an array + let sizeString = sizeToString(300, 350) + expect(sizeString).to.be.equal('') + // Single entry + sizeString = sizeToString([300]) + expect(sizeString).to.be.equal('') + // Undefined + sizeString = sizeToString(undefined) + expect(sizeString).to.be.equal('') + }) +}) + +describe('getSizeWildcardPrice', () => { + it('Generates the correct floor price data', () => { + let floorPrice = { + currency: 'USD', + floor: 1.0, + } + let getFloorMock = () => { + return floorPrice + } + let floorMockSpy = sinon.spy(getFloorMock) + let bidRequest = { + getFloor: floorMockSpy, + mediaTypes: { + banner: { + sizes: [300, 250], + }, + }, + } + + let result = getSizeWildcardPrice(bidRequest, 'banner') + expect( + floorMockSpy.calledWith({ + currency: 'USD', + mediaType: 'banner', + size: '*', + }) + ).to.be.true + expect(result).to.equal(floorPrice) + }) +}) + +describe('getMediaWildcardPrices', () => { + it('Generates the correct floor price data', () => { + let defaultFloorPrice = { + currency: 'USD', + floor: 1.1, + } + let sizefloorPrice = { + currency: 'USD', + floor: 2.2, + } + let getFloorMock = ({ currency, mediaType, size }) => { + if (Array.isArray(size)) return sizefloorPrice + + return defaultFloorPrice + } + let floorMockSpy = sinon.spy(getFloorMock) + let bidRequest = { + getFloor: floorMockSpy, + mediaTypes: { + banner: { + sizes: [300, 250], + }, + }, + } + + let result = getMediaWildcardPrices(bidRequest, ['*', [300, 250]]) + expect( + floorMockSpy.calledWith({ + currency: 'USD', + mediaType: '*', + size: '*', + }) + ).to.be.true + expect( + floorMockSpy.calledWith({ + currency: 'USD', + mediaType: '*', + size: [300, 250], + }) + ).to.be.true + expect(result).to.deep.equal({ '*': 1.1, '300x250': 2.2 }) + }) +}) + +describe('parseFloorPriceData', () => { + it('Generates the correct floor price data', () => { + let defaultFloorPrice = { + currency: 'USD', + floor: 1.1, + } + let sizefloorPrice = { + currency: 'USD', + floor: 2.2, + } + let getFloorMock = ({ currency, mediaType, size }) => { + if (Array.isArray(size)) return sizefloorPrice + + return defaultFloorPrice + } + let floorMockSpy = sinon.spy(getFloorMock) + let bidRequest = { + getFloor: floorMockSpy, + mediaTypes: { + banner: { + sizes: [[300, 250]], + }, + }, + } + + let result = parseFloorPriceData(bidRequest) + expect(result).to.deep.equal({ + '*': { '*': 1.1, '300x250': 2.2 }, + banner: { '*': 1.1, '300x250': 2.2 }, + }) + }) +})