From 99ffff27e11b2c984dad2dc8256e930d548f5535 Mon Sep 17 00:00:00 2001 From: Michele Nasti Date: Fri, 24 Feb 2023 18:59:34 +0100 Subject: [PATCH] Rubicon Bid Adapter: add native support (#9574) * add support for native * wrap native tests around FEATURES.NATIVE * remove commented out code * HB-16092 support multiformat parameter * do not generate imp if has only banner media type * check banner bid type only if mediaTypes.banner * new multiformat logic * bidonmultiformat * fixes: do not set empty keywords; better behavior for floors. * currency is always added * remove prorperties that are already set by ortb --------- Co-authored-by: Michele Nasti --- modules/rubiconBidAdapter.js | 555 +++++++++----------- test/spec/modules/rubiconBidAdapter_spec.js | 366 +++++++++++-- 2 files changed, 561 insertions(+), 360 deletions(-) diff --git a/modules/rubiconBidAdapter.js b/modules/rubiconBidAdapter.js index d4875345043..293b0d51b33 100644 --- a/modules/rubiconBidAdapter.js +++ b/modules/rubiconBidAdapter.js @@ -1,5 +1,12 @@ +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { pbsExtensions } from '../libraries/pbsExtensions/pbsExtensions.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { config } from '../src/config.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { find } from '../src/polyfill.js'; +import { getGlobal } from '../src/prebidGlobal.js'; +import { Renderer } from '../src/Renderer.js'; import { - _each, convertTypes, deepAccess, deepSetValue, @@ -11,14 +18,8 @@ import { logMessage, logWarn, mergeDeep, - parseSizesInput + parseSizesInput, _each } from '../src/utils.js'; -import {registerBidder} from '../src/adapters/bidderFactory.js'; -import {config} from '../src/config.js'; -import {BANNER, VIDEO} from '../src/mediaTypes.js'; -import {find} from '../src/polyfill.js'; -import {Renderer} from '../src/Renderer.js'; -import {getGlobal} from '../src/prebidGlobal.js'; const DEFAULT_INTEGRATION = 'pbjs_lite'; const DEFAULT_PBS_INTEGRATION = 'pbjs'; @@ -138,17 +139,101 @@ var sizeMap = { 580: '505x656', 622: '192x160' }; + _each(sizeMap, (item, key) => sizeMap[item] = key); +export const converter = ortbConverter({ + request(buildRequest, imps, bidderRequest, context) { + const {bidRequests} = context; + const data = buildRequest(imps, bidderRequest, context); + data.cur = ['USD']; + data.test = config.getConfig('debug') ? 1 : 0; + deepSetValue(data, 'ext.prebid.cache', { + vastxml: { + returnCreative: rubiConf.returnVast === true + } + }); + + deepSetValue(data, 'ext.prebid.bidders', { + rubicon: { + integration: rubiConf.int_type || DEFAULT_PBS_INTEGRATION, + } + }); + + deepSetValue(data, 'ext.prebid.targeting.pricegranularity', getPriceGranularity(config)); + + let modules = (getGlobal()).installedModules; + if (modules && (!modules.length || modules.indexOf('rubiconAnalyticsAdapter') !== -1)) { + deepSetValue(data, 'ext.prebid.analytics', {'rubicon': {'client-analytics': true}}); + } + + addOrtbFirstPartyData(data, bidRequests); + + delete data?.ext?.prebid?.storedrequest; + + // floors + if (rubiConf.disableFloors === true) { + delete data.ext.prebid.floors; + } + + // If the price floors module is active, then we need to signal to PBS! If floorData obj is present is best way to check + const haveFloorDataBidRequests = bidRequests.filter(bidRequest => typeof bidRequest.floorData === 'object'); + if (haveFloorDataBidRequests.length > 0) { + data.ext.prebid.floors = { enabled: false }; + } + return data; + }, + imp(buildImp, bidRequest, context) { + // skip banner-only requests + const bidRequestType = bidType(bidRequest); + if (bidRequestType.includes(BANNER) && bidRequestType.length == 1) return; + + const imp = buildImp(bidRequest, context); + imp.id = bidRequest.adUnitCode; + delete imp.banner; + if (config.getConfig('s2sConfig.defaultTtl')) { + imp.exp = config.getConfig('s2sConfig.defaultTtl'); + }; + bidRequest.params.position === 'atf' && (imp.video.pos = 1); + bidRequest.params.position === 'btf' && (imp.video.pos = 3); + delete imp.ext?.prebid?.storedrequest; + + setBidFloors(bidRequest, imp); + + return imp; + }, + bidResponse(buildBidResponse, bid, context) { + const bidResponse = buildBidResponse(bid, context); + bidResponse.meta.mediaType = deepAccess(bid, 'ext.prebid.type'); + const {bidRequest} = context; + if (bidResponse.mediaType === VIDEO && bidRequest.mediaTypes.video.context === 'outstream') { + bidResponse.renderer = outstreamRenderer(bidResponse); + } + bidResponse.width = bid.w || deepAccess(bidRequest, 'mediaTypes.video.w') || deepAccess(bidRequest, 'params.video.playerWidth'); + bidResponse.height = bid.h || deepAccess(bidRequest, 'mediaTypes.video.h') || deepAccess(bidRequest, 'params.video.playerHeight'); + + if (deepAccess(bid, 'ext.bidder.rp.advid')) { + deepSetValue(bidResponse, 'meta.advertiserId', bid.ext.bidder.rp.advid); + } + return bidResponse; + }, + context: { + netRevenue: rubiConf.netRevenue !== false, // If anything other than false, netRev is true + ttl: 300, + }, + processors: pbsExtensions +}); + export const spec = { code: 'rubicon', gvlid: GVLID, - supportedMediaTypes: [BANNER, VIDEO], + supportedMediaTypes: [BANNER, VIDEO, NATIVE], /** * @param {object} bid * @return boolean */ isBidRequestValid: function (bid) { + let valid = true; if (typeof bid.params !== 'object') { return false; } @@ -160,15 +245,16 @@ export const spec = { return false } } - let bidFormat = bidType(bid, true); + let bidFormats = bidType(bid, true); // bidType is undefined? Return false - if (!bidFormat) { + if (!bidFormats.length) { return false; - } else if (bidFormat === 'video') { // bidType is video, make sure it has required params - return hasValidVideoParams(bid); + } else if (bidFormats.includes(VIDEO)) { // bidType is video, make sure it has required params + valid = hasValidVideoParams(bid); } - // bidType is banner? return true - return true; + const hasBannerOrNativeMediaType = [BANNER, NATIVE].filter(mediaType => bidFormats.includes(mediaType)).length > 0; + if (!hasBannerOrNativeMediaType) return valid; + return valid && hasBannerOrNativeMediaType; }, /** * @param {BidRequest[]} bidRequests @@ -178,166 +264,57 @@ export const spec = { buildRequests: function (bidRequests, bidderRequest) { // separate video bids because the requests are structured differently let requests = []; - const videoRequests = bidRequests.filter(bidRequest => bidType(bidRequest) === 'video').map(bidRequest => { - bidRequest.startTime = new Date().getTime(); - - const data = { - id: bidRequest.transactionId, - test: config.getConfig('debug') ? 1 : 0, - cur: ['USD'], - source: { - tid: bidRequest.transactionId - }, - tmax: bidderRequest.timeout, - imp: [{ - exp: config.getConfig('s2sConfig.defaultTtl'), - id: bidRequest.adUnitCode, - secure: 1, - ext: { - [bidRequest.bidder]: bidRequest.params - }, - video: deepAccess(bidRequest, 'mediaTypes.video') || {} - }], - ext: { - prebid: { - channel: { - name: 'pbjs', - version: $$PREBID_GLOBAL$$.version - }, - cache: { - vastxml: { - returnCreative: rubiConf.returnVast === true - } - }, - targeting: { - includewinners: true, - // includebidderkeys always false for openrtb - includebidderkeys: false, - pricegranularity: getPriceGranularity(config) - }, - bidders: { - rubicon: { - integration: rubiConf.int_type || DEFAULT_PBS_INTEGRATION - } - } - } - } - } - - // Add alias if it is there - if (bidRequest.bidder !== 'rubicon') { - data.ext.prebid.aliases = { - [bidRequest.bidder]: 'rubicon' - } - } - - let modules = (getGlobal()).installedModules; - if (modules && (!modules.length || modules.indexOf('rubiconAnalyticsAdapter') !== -1)) { - deepSetValue(data, 'ext.prebid.analytics', {'rubicon': {'client-analytics': true}}); - } - - let bidFloor; - if (typeof bidRequest.getFloor === 'function' && !rubiConf.disableFloors) { - let floorInfo; - try { - floorInfo = bidRequest.getFloor({ - currency: 'USD', - mediaType: 'video', - size: parseSizes(bidRequest, 'video') - }); - } catch (e) { - logError('Rubicon: getFloor threw an error: ', e); - } - bidFloor = typeof floorInfo === 'object' && floorInfo.currency === 'USD' && !isNaN(parseInt(floorInfo.floor)) ? parseFloat(floorInfo.floor) : undefined; - } else { - bidFloor = parseFloat(deepAccess(bidRequest, 'params.floor')); - } - if (!isNaN(bidFloor)) { - data.imp[0].bidfloor = bidFloor; - } - - // If the price floors module is active, then we need to signal to PBS! If floorData obj is present is best way to check - if (typeof bidRequest.floorData === 'object') { - data.ext.prebid.floors = { enabled: false }; - } - - // if value is set, will overwrite with same value - data.imp[0].ext[bidRequest.bidder].video.size_id = determineRubiconVideoSizeId(bidRequest) - - appendSiteAppDevice(data, bidRequest, bidderRequest); - - addVideoParameters(data, bidRequest); - - if (bidderRequest.gdprConsent) { - // note - gdprApplies & consentString may be undefined in certain use-cases for consentManagement module - let gdprApplies; - if (typeof bidderRequest.gdprConsent.gdprApplies === 'boolean') { - gdprApplies = bidderRequest.gdprConsent.gdprApplies ? 1 : 0; - } - - deepSetValue(data, 'regs.ext.gdpr', gdprApplies); - deepSetValue(data, 'user.ext.consent', bidderRequest.gdprConsent.consentString); - } - - if (bidderRequest.uspConsent) { - deepSetValue(data, 'regs.ext.us_privacy', bidderRequest.uspConsent); - } - - const eids = deepAccess(bidderRequest, 'bids.0.userIdAsEids'); - if (eids && eids.length) { - deepSetValue(data, 'user.ext.eids', eids); - } - - // set user.id value from config value - const configUserId = config.getConfig('user.id'); - if (configUserId) { - deepSetValue(data, 'user.id', configUserId); - } - - if (config.getConfig('coppa') === true) { - deepSetValue(data, 'regs.coppa', 1); - } - - if (bidRequest.schain && hasValidSupplyChainParams(bidRequest.schain)) { - deepSetValue(data, 'source.ext.schain', bidRequest.schain); - } - - const multibid = config.getConfig('multibid'); - if (multibid) { - deepSetValue(data, 'ext.prebid.multibid', multibid.reduce((result, i) => { - let obj = {}; - - Object.keys(i).forEach(key => { - obj[key.toLowerCase()] = i[key]; - }); - - result.push(obj); - - return result; - }, [])); - } - - applyFPD(bidRequest, VIDEO, data); - - // set ext.prebid.auctiontimestamp using auction time - deepSetValue(data.imp[0], 'ext.prebid.auctiontimestamp', bidderRequest.auctionStart); + let filteredHttpRequest = []; + let filteredRequests; + + filteredRequests = bidRequests.filter(req => { + const mediaTypes = bidType(req) || []; + const { length } = mediaTypes; + const { bidonmultiformat, video } = req.params || {}; + + return ( + // if there's just one mediaType and it's video or native, just send it! + (length === 1 && (mediaTypes.includes(VIDEO) || mediaTypes.includes(NATIVE))) || + // if it's two mediaTypes, and they don't contain banner, send to PBS both native & video + (length === 2 && !mediaTypes.includes(BANNER)) || + // if it contains the video param and the Video mediaType, send Video to PBS (not native!) + (video && mediaTypes.includes(VIDEO)) || + // if bidonmultiformat is on, send everything to PBS + (bidonmultiformat && (mediaTypes.includes(VIDEO) || mediaTypes.includes(NATIVE))) + ) + }); - // set storedrequests to undefined so not sent to PBS - // top level and imp level both 'ext.prebid' objects are set above so no exception thrown here - data.ext.prebid.storedrequest = undefined; - data.imp[0].ext.prebid.storedrequest = undefined; + if (filteredRequests && filteredRequests.length) { + const data = converter.toORTB({bidRequests: filteredRequests, bidderRequest}); - return { + filteredHttpRequest.push({ method: 'POST', url: `https://${rubiConf.videoHost || 'prebid-server'}.rubiconproject.com/openrtb2/auction`, data, - bidRequest - } - }); + bidRequest: filteredRequests + }); + } + const bannerBidRequests = bidRequests.filter((req) => { + const mediaTypes = bidType(req) || []; + const {bidonmultiformat, video} = req.params || {}; + return ( + // Send to fastlane if: it must include BANNER and... + mediaTypes.includes(BANNER) && ( + // if it's just banner + (mediaTypes.length === 1) || + // if bidonmultiformat is true + bidonmultiformat || + // if bidonmultiformat is false and there's no video parameter + (!bidonmultiformat && !video) || + // if there's video parameter, but there's no video mediatype + (!bidonmultiformat && video && !mediaTypes.includes(VIDEO)) + ) + ); + }); if (config.getConfig('rubicon.singleRequest') !== true) { // bids are not grouped if single request mode is not enabled - requests = videoRequests.concat(bidRequests.filter(bidRequest => bidType(bidRequest) === 'banner').map(bidRequest => { + requests = filteredHttpRequest.concat(bannerBidRequests.map(bidRequest => { const bidParams = spec.createSlotParams(bidRequest, bidderRequest); return { method: 'GET', @@ -352,8 +329,7 @@ export const spec = { } else { // single request requires bids to be grouped by site id into a single request // note: groupBy wasn't used because deep property access was needed - const nonVideoRequests = bidRequests.filter(bidRequest => bidType(bidRequest) === 'banner'); - const groupedBidRequests = nonVideoRequests.reduce((groupedBids, bid) => { + const groupedBidRequests = bannerBidRequests.reduce((groupedBids, bid) => { (groupedBids[bid.params['siteId']] = groupedBids[bid.params['siteId']] || []).push(bid); return groupedBids; }, {}); @@ -362,7 +338,7 @@ export const spec = { const SRA_BID_LIMIT = 10; // multiple requests are used if bids groups have more than 10 bids - requests = videoRequests.concat(Object.keys(groupedBidRequests).reduce((aggregate, bidGroupKey) => { + requests = filteredHttpRequest.concat(Object.keys(groupedBidRequests).reduce((aggregate, bidGroupKey) => { // for each partioned bidGroup, append a bidRequest to requests list partitionArray(groupedBidRequests[bidGroupKey], SRA_BID_LIMIT).forEach(bidsInGroup => { const combinedSlotParams = spec.combineSlotUrlParams(bidsInGroup.map(bidRequest => { @@ -615,106 +591,36 @@ export const spec = { /** * @param {*} responseObj - * @param {BidRequest|Object.} bidRequest - if request was SRA the bidRequest argument will be a keyed BidRequest array object, + * @param {BidRequest|Object.} request - if request was SRA the bidRequest argument will be a keyed BidRequest array object, * non-SRA responses return a plain BidRequest object * @return {Bid[]} An array of bids which */ - interpretResponse: function (responseObj, {bidRequest}) { + interpretResponse: function (responseObj, request) { responseObj = responseObj.body; + const {data} = request; // check overall response if (!responseObj || typeof responseObj !== 'object') { return []; } - // video response from PBS Java openRTB + // Response from PBS Java openRTB if (responseObj.seatbid) { const responseErrors = deepAccess(responseObj, 'ext.errors.rubicon'); if (Array.isArray(responseErrors) && responseErrors.length > 0) { logWarn('Rubicon: Error in video response'); } - const bids = []; - responseObj.seatbid.forEach(seatbid => { - (seatbid.bid || []).forEach(bid => { - let bidObject = { - requestId: bidRequest.bidId, - currency: responseObj.cur || 'USD', - creativeId: bid.crid, - cpm: bid.price || 0, - bidderCode: seatbid.seat, - ttl: 300, - netRevenue: rubiConf.netRevenue !== false, // If anything other than false, netRev is true - width: bid.w || deepAccess(bidRequest, 'mediaTypes.video.w') || deepAccess(bidRequest, 'params.video.playerWidth'), - height: bid.h || deepAccess(bidRequest, 'mediaTypes.video.h') || deepAccess(bidRequest, 'params.video.playerHeight'), - }; - - if (bid.id) { - bidObject.seatBidId = bid.id; - } - - if (bid.dealid) { - bidObject.dealId = bid.dealid; - } - - if (bid.adomain) { - deepSetValue(bidObject, 'meta.advertiserDomains', Array.isArray(bid.adomain) ? bid.adomain : [bid.adomain]); - } - - if (deepAccess(bid, 'ext.bidder.rp.advid')) { - deepSetValue(bidObject, 'meta.advertiserId', bid.ext.bidder.rp.advid); - } - - let serverResponseTimeMs = deepAccess(responseObj, 'ext.responsetimemillis.rubicon'); - if (bidRequest && serverResponseTimeMs) { - bidRequest.serverResponseTimeMs = serverResponseTimeMs; - } - - if (deepAccess(bid, 'ext.prebid.type') === VIDEO) { - bidObject.mediaType = VIDEO; - deepSetValue(bidObject, 'meta.mediaType', VIDEO); - const extPrebidTargeting = deepAccess(bid, 'ext.prebid.targeting'); - - // If ext.prebid.targeting exists, add it as a property value named 'adserverTargeting' - if (extPrebidTargeting && typeof extPrebidTargeting === 'object') { - bidObject.adserverTargeting = extPrebidTargeting; - } - - // try to get cache values from 'response.ext.prebid.cache.js' - // else try 'bid.ext.prebid.targeting' as fallback - if (bid.ext.prebid.cache && typeof bid.ext.prebid.cache.vastXml === 'object' && bid.ext.prebid.cache.vastXml.cacheId && bid.ext.prebid.cache.vastXml.url) { - bidObject.videoCacheKey = bid.ext.prebid.cache.vastXml.cacheId; - bidObject.vastUrl = bid.ext.prebid.cache.vastXml.url; - } else if (extPrebidTargeting && extPrebidTargeting.hb_uuid && extPrebidTargeting.hb_cache_host && extPrebidTargeting.hb_cache_path) { - bidObject.videoCacheKey = extPrebidTargeting.hb_uuid; - // build url using key and cache host - bidObject.vastUrl = `https://${extPrebidTargeting.hb_cache_host}${extPrebidTargeting.hb_cache_path}?uuid=${extPrebidTargeting.hb_uuid}`; - } - - if (bid.adm) { bidObject.vastXml = bid.adm; } - if (bid.nurl) { bidObject.vastUrl = bid.nurl; } - if (!bidObject.vastUrl && bid.nurl) { bidObject.vastUrl = bid.nurl; } - - const videoContext = deepAccess(bidRequest, 'mediaTypes.video.context'); - if (videoContext.toLowerCase() === 'outstream') { - bidObject.renderer = outstreamRenderer(bidObject); - } - } else { - logWarn('Rubicon: video response received non-video media type'); - } - - bids.push(bidObject); - }); - }); - + const bids = converter.fromORTB({request: data, response: responseObj}).bids; return bids; } let ads = responseObj.ads; let lastImpId; let multibid = 0; + const {bidRequest} = request; // video ads array is wrapped in an object - if (typeof bidRequest === 'object' && !Array.isArray(bidRequest) && bidType(bidRequest) === 'video' && typeof ads === 'object') { + if (typeof bidRequest === 'object' && !Array.isArray(bidRequest) && bidType(bidRequest).includes(VIDEO) && typeof ads === 'object') { ads = ads[bidRequest.adUnitCode]; } @@ -780,7 +686,6 @@ export const spec = { } else { logError(`Rubicon: bidRequest undefined at index position:${i}`, bidRequest, responseObj); } - return bids; }, []).sort((adA, adB) => { return (adB.cpm || 0.0) - (adA.cpm || 0.0); @@ -919,7 +824,7 @@ function outstreamRenderer(rtbBid) { function parseSizes(bid, mediaType) { let params = bid.params; - if (mediaType === 'video') { + if (mediaType === VIDEO) { let size = []; if (params.video && params.video.playerWidth && params.video.playerHeight) { size = [ @@ -949,65 +854,6 @@ function parseSizes(bid, mediaType) { return masSizeOrdering(sizes); } -/** - * @param {Object} data - * @param bidRequest - * @param bidderRequest - */ -function appendSiteAppDevice(data, bidRequest, bidderRequest) { - if (!data) return; - - // ORTB specifies app OR site - if (typeof config.getConfig('app') === 'object') { - data.app = config.getConfig('app'); - } else { - data.site = { - page: _getPageUrl(bidRequest, bidderRequest) - } - } - if (typeof config.getConfig('device') === 'object') { - data.device = config.getConfig('device'); - } - // Add language to site and device objects if there - if (bidRequest.params.video.language) { - ['site', 'device'].forEach(function(param) { - if (data[param]) { - if (param === 'site') { - data[param].content = Object.assign({language: bidRequest.params.video.language}, data[param].content) - } else { - data[param] = Object.assign({language: bidRequest.params.video.language}, data[param]) - } - } - }); - } -} - -/** - * @param {Object} data - * @param {BidRequest} bidRequest - */ -function addVideoParameters(data, bidRequest) { - if (typeof data.imp[0].video === 'object' && data.imp[0].video.skip === undefined) { - data.imp[0].video.skip = bidRequest.params.video.skip; - } - if (typeof data.imp[0].video === 'object' && data.imp[0].video.skipafter === undefined) { - data.imp[0].video.skipafter = bidRequest.params.video.skipdelay; - } - // video.pos can already be specified by adunit.mediatypes.video.pos. - // but if not, it might be specified in the params - if (typeof data.imp[0].video === 'object' && data.imp[0].video.pos === undefined) { - if (bidRequest.params.position === 'atf') { - data.imp[0].video.pos = 1; - } else if (bidRequest.params.position === 'btf') { - data.imp[0].video.pos = 3; - } - } - - const size = parseSizes(bidRequest, 'video') - data.imp[0].video.w = size[0] - data.imp[0].video.h = size[1] -} - function applyFPD(bidRequest, mediaType, data) { const BID_FPD = { user: {ext: {data: {...bidRequest.params.visitor}}}, @@ -1118,11 +964,15 @@ function mapSizes(sizes) { export function classifiedAsVideo(bidRequest) { let isVideo = typeof deepAccess(bidRequest, `mediaTypes.${VIDEO}`) !== 'undefined'; let isBanner = typeof deepAccess(bidRequest, `mediaTypes.${BANNER}`) !== 'undefined'; + let isBidOnMultiformat = typeof deepAccess(bidRequest, `params.bidonmultiformat`) !== 'undefined'; let isMissingVideoParams = typeof deepAccess(bidRequest, 'params.video') !== 'object'; // If an ad has both video and banner types, a legacy implementation allows choosing video over banner // based on whether or not there is a video object defined in the params // Given this legacy implementation, other code depends on params.video being defined + // if it's bidonmultiformat, we don't care of the video object + if (isVideo && isBidOnMultiformat) return true; + if (isBanner && isMissingVideoParams) { isVideo = false; } @@ -1133,13 +983,14 @@ export function classifiedAsVideo(bidRequest) { } /** - * Determine bidRequest mediaType + * Determine bidRequest mediaTypes. All mediaTypes must be correct. If one fails, all the others will fail too. * @param bid the bid to test - * @param log whether we should log errors/warnings for invalid bids - * @returns {string|undefined} Returns 'video' or 'banner' if resolves to a type, or undefined otherwise (invalid). + * @param log boolean. whether we should log errors/warnings for invalid bids + * @returns {string|undefined} Returns an array containing one of 'video' or 'banner' or 'native' if resolves to a type. */ function bidType(bid, log = false) { // Is it considered video ad unit by rubicon + let bidTypes = []; if (classifiedAsVideo(bid)) { // Removed legacy mediaType support. new way using mediaTypes.video object is now required // We require either context as instream or outstream @@ -1147,37 +998,43 @@ function bidType(bid, log = false) { if (log) { logError('Rubicon: mediaTypes.video.context must be outstream or instream'); } - return; + return bidTypes; } // we require playerWidth and playerHeight to come from one of params.playerWidth/playerHeight or mediaTypes.video.playerSize or adUnit.sizes - if (parseSizes(bid, 'video').length < 2) { + if (parseSizes(bid, VIDEO).length < 2) { if (log) { logError('Rubicon: could not determine the playerSize of the video'); } - return; + return bidTypes; } if (log) { logMessage('Rubicon: making video request for adUnit', bid.adUnitCode); } - return 'video'; - } else { + bidTypes.push(VIDEO); + } + if (typeof deepAccess(bid, `mediaTypes.${NATIVE}`) !== 'undefined') { + bidTypes.push(NATIVE); + } + + if (typeof deepAccess(bid, `mediaTypes.${BANNER}`) !== 'undefined') { // we require banner sizes to come from one of params.sizes or mediaTypes.banner.sizes or adUnit.sizes, in that order // if we cannot determine them, we reject it! - if (parseSizes(bid, 'banner').length === 0) { + if (parseSizes(bid, BANNER).length === 0) { if (log) { logError('Rubicon: could not determine the sizes for banner request'); } - return; + return bidTypes; } // everything looks good for banner so lets do it if (log) { logMessage('Rubicon: making banner request for adUnit', bid.adUnitCode); } - return 'banner'; + bidTypes.push(BANNER); } + return bidTypes; } export const resetRubiConf = () => rubiConf = {}; @@ -1307,4 +1164,64 @@ export function resetUserSync() { hasSynced = false; } +/** + * Sets the floor on the bidRequest. imp.bidfloor and imp.bidfloorcur + * should be already set by the conversion library. if they're not, + * or invalid, try to read from params.floor. + * @param {*} bidRequest + * @param {*} imp + */ +function setBidFloors(bidRequest, imp) { + if (imp.bidfloorcur != 'USD') { + delete imp.bidfloor; + delete imp.bidfloorcur; + } + + if (!imp.bidfloor) { + let bidFloor = parseFloat(deepAccess(bidRequest, 'params.floor')); + + if (!isNaN(bidFloor)) { + imp.bidfloor = bidFloor; + imp.bidfloorcur = 'USD'; + } + } +} + +function addOrtbFirstPartyData(data, nonBannerRequests) { + let fpd = {}; + const keywords = new Set(); + nonBannerRequests.forEach(bidRequest => { + const bidFirstPartyData = { + user: {ext: {data: {...bidRequest.params.visitor}}}, + site: {ext: {data: {...bidRequest.params.inventory}}} + }; + + // add site.content.language + const impThatHasVideoLanguage = data.imp.find(imp => imp.ext?.prebid?.bidder?.rubicon?.video?.language); + if (impThatHasVideoLanguage) { + bidFirstPartyData.site.content = { + language: impThatHasVideoLanguage.ext?.prebid?.bidder?.rubicon?.video?.language + } + } + + if (bidRequest.params.keywords) { + const keywordsArray = (!Array.isArray(bidRequest.params.keywords) ? bidRequest.params.keywords.split(',') : bidRequest.params.keywords); + keywordsArray.forEach(keyword => keywords.add(keyword)); + } + fpd = mergeDeep(fpd, bidRequest.ortb2 || {}, bidFirstPartyData); + + // add user.id from config. + // NOTE: This is DEPRECATED. user.id should come from setConfig({ortb2}). + const configUserId = config.getConfig('user.id'); + fpd.user.id = fpd.user.id || configUserId; + }); + + mergeDeep(data, fpd); + + if (keywords && keywords.size) { + deepSetValue(data, 'site.keywords', Array.from(keywords.values()).join(',')); + } + delete data?.ext?.prebid?.storedrequest; +} + registerBidder(spec); diff --git a/test/spec/modules/rubiconBidAdapter_spec.js b/test/spec/modules/rubiconBidAdapter_spec.js index e81ef1c805f..d33a9350359 100644 --- a/test/spec/modules/rubiconBidAdapter_spec.js +++ b/test/spec/modules/rubiconBidAdapter_spec.js @@ -5,13 +5,21 @@ import { masSizeOrdering, resetUserSync, classifiedAsVideo, - resetRubiConf + resetRubiConf, + converter } from 'modules/rubiconBidAdapter.js'; import {parse as parseQuery} from 'querystring'; import {config} from 'src/config.js'; import * as utils from 'src/utils.js'; import {find} from 'src/polyfill.js'; import {createEidsArray} from 'modules/userId/eids.js'; +import 'modules/schain.js'; +import 'modules/consentManagement.js'; +import 'modules/consentManagementUsp.js'; +import 'modules/userId/index.js'; +import 'modules/priceFloors.js'; +import 'modules/multibid/index.js'; +import adapterManager from 'src/adapterManager.js'; const INTEGRATION = `pbjs_lite_v$prebid.version$`; // $prebid.version$ will be substituted in by gulp in built prebid const PBS_INTEGRATION = 'pbjs'; @@ -102,6 +110,9 @@ describe('the rubicon adapter', function () { referrer: 'localhost', latLong: [40.7607823, '111.8910325'] }, + mediaTypes: { + banner: [[300, 250]] + }, adUnitCode: '/19968336/header-bid-tag-0', code: 'div-1', sizes: [[300, 250], [320, 50]], @@ -323,6 +334,9 @@ describe('the rubicon adapter', function () { referrer: 'localhost', latLong: [40.7607823, '111.8910325'] }, + mediaTypes: { + banner: [[300, 250]] + }, adUnitCode: '/19968336/header-bid-tag-0', code: 'div-1', sizes: [[300, 250], [320, 50]], @@ -1553,23 +1567,21 @@ describe('the rubicon adapter', function () { expect(imp.video.w).to.equal(640); expect(imp.video.h).to.equal(480); expect(imp.video.pos).to.equal(1); - expect(imp.video.context).to.equal('instream'); expect(imp.video.minduration).to.equal(15); expect(imp.video.maxduration).to.equal(30); expect(imp.video.startdelay).to.equal(0); expect(imp.video.skip).to.equal(1); expect(imp.video.skipafter).to.equal(15); - expect(imp.ext.rubicon.video.playerWidth).to.equal(640); - expect(imp.ext.rubicon.video.playerHeight).to.equal(480); - expect(imp.ext.rubicon.video.size_id).to.equal(201); - expect(imp.ext.rubicon.video.language).to.equal('en'); + expect(imp.ext.prebid.bidder.rubicon.video.playerWidth).to.equal(640); + expect(imp.ext.prebid.bidder.rubicon.video.playerHeight).to.equal(480); + expect(imp.ext.prebid.bidder.rubicon.video.size_id).to.equal(201); + expect(imp.ext.prebid.bidder.rubicon.video.language).to.equal('en'); // Also want it to be in post.site.content.language - expect(post.site.content.language).to.equal('en'); - expect(imp.ext.rubicon.video.skip).to.equal(1); - expect(imp.ext.rubicon.video.skipafter).to.equal(15); - expect(imp.ext.prebid.auctiontimestamp).to.equal(1472239426000); + expect(imp.ext.prebid.bidder.rubicon.video.skip).to.equal(1); + expect(imp.ext.prebid.bidder.rubicon.video.skipafter).to.equal(15); + expect(post.ext.prebid.auctiontimestamp).to.equal(1472239426000); // should contain version - expect(post.ext.prebid.channel).to.deep.equal({name: 'pbjs', version: 'v$prebid.version$'}); + expect(post.ext.prebid.channel).to.deep.equal({name: 'pbjs', version: $$PREBID_GLOBAL$$.version}); expect(post.user.ext.consent).to.equal('BOJ/P2HOJ/P2HABABMAAAAAZ+A=='); // EIDs should exist expect(post.user.ext).to.have.property('eids').that.is.an('array'); @@ -1672,8 +1684,8 @@ describe('the rubicon adapter', function () { expect( bidderRequest.bids[0].getFloor.calledWith({ currency: 'USD', - mediaType: 'video', - size: [640, 480] + mediaType: '*', + size: '*' }) ).to.be.true; @@ -1701,7 +1713,7 @@ describe('the rubicon adapter', function () { expect(request.data.imp[0].bidfloor).to.equal(1.23); }); - it('should continue with auction and log error if getFloor throws one', function () { + it('should continue with auction if getFloor throws error', function () { createVideoBidderRequest(); // default getFloor response is empty object so should not break and not send hard_floor bidderRequest.bids[0].getFloor = () => { @@ -1713,20 +1725,18 @@ describe('the rubicon adapter', function () { let [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); - // log error called - expect(logErrorSpy.calledOnce).to.equal(true); - // should have an imp expect(request.data.imp).to.exist.and.to.be.a('array'); expect(request.data.imp).to.have.lengthOf(1); // should be NO bidFloor expect(request.data.imp[0].bidfloor).to.be.undefined; + expect(request.data.imp[0].bidfloorcur).to.be.undefined; }); it('should add alias name to PBS Request', function () { createVideoBidderRequest(); - + adapterManager.aliasRegistry['superRubicon'] = 'rubicon'; bidderRequest.bidderCode = 'superRubicon'; bidderRequest.bids[0].bidder = 'superRubicon'; let [request] = spec.buildRequests(bidderRequest.bids, bidderRequest); @@ -1736,8 +1746,8 @@ describe('the rubicon adapter', function () { expect(request.data.ext.prebid.aliases).to.deep.equal({superRubicon: 'rubicon'}); // should have the imp ext bidder params be under the alias name not rubicon superRubicon - expect(request.data.imp[0].ext).to.have.property('superRubicon').that.is.an('object'); - expect(request.data.imp[0].ext).to.not.haveOwnProperty('rubicon'); + expect(request.data.imp[0].ext.prebid.bidder).to.have.property('superRubicon').that.is.an('object'); + expect(request.data.imp[0].ext.prebid.bidder).to.not.haveOwnProperty('rubicon'); }); it('should add floors flag correctly to PBS Request', function () { @@ -2000,7 +2010,7 @@ describe('the rubicon adapter', function () { let [request] = spec.buildRequests(bidRequestCopy.bids, bidRequestCopy); expect(spec.isBidRequestValid(bidderRequest.bids[0])).to.equal(true); - expect(request.data.imp[0].ext.rubicon.video.size_id).to.equal(203); + expect(request.data.imp[0].ext.prebid.bidder.rubicon.video.size_id).to.equal(203); }); it('should send banner request when outstream or instream video included but no rubicon video obect is present', function () { @@ -2214,21 +2224,18 @@ describe('the rubicon adapter', function () { expect(imp.video.w).to.equal(640); expect(imp.video.h).to.equal(480); expect(imp.video.pos).to.equal(1); - expect(imp.video.context).to.equal('instream'); expect(imp.video.minduration).to.equal(15); expect(imp.video.maxduration).to.equal(30); expect(imp.video.startdelay).to.equal(0); expect(imp.video.skip).to.equal(1); expect(imp.video.skipafter).to.equal(15); - expect(imp.ext.rubicon.video.playerWidth).to.equal(640); - expect(imp.ext.rubicon.video.playerHeight).to.equal(480); - expect(imp.ext.rubicon.video.size_id).to.equal(201); - expect(imp.ext.rubicon.video.language).to.equal('en'); + expect(imp.ext.prebid.bidder.rubicon.video.playerWidth).to.equal(640); + expect(imp.ext.prebid.bidder.rubicon.video.playerHeight).to.equal(480); + expect(imp.ext.prebid.bidder.rubicon.video.language).to.equal('en'); + // Also want it to be in post.site.content.language expect(post.site.content.language).to.equal('en'); - expect(imp.ext.rubicon.video.skip).to.equal(1); - expect(imp.ext.rubicon.video.skipafter).to.equal(15); - expect(imp.ext.prebid.auctiontimestamp).to.equal(1472239426000); + expect(post.ext.prebid.auctiontimestamp).to.equal(1472239426000); expect(post.user.ext.consent).to.equal('BOJ/P2HOJ/P2HABABMAAAAAZ+A=='); // Config user.id @@ -2366,6 +2373,110 @@ describe('the rubicon adapter', function () { expect(bid.params.video).to.not.be.undefined; }); }); + + if (FEATURES.NATIVE) { + describe('when there is a native request', function () { + describe('and bidonmultiformat = undefined (false)', () => { + it('should send only one native bid to PBS endpoint', function () { + const bidReq = addNativeToBidRequest(bidderRequest); + bidReq.bids[0].params = { + video: {} + } + let [request] = spec.buildRequests(bidReq.bids, bidReq); + expect(request.method).to.equal('POST'); + expect(request.url).to.equal('https://prebid-server.rubiconproject.com/openrtb2/auction'); + expect(request.data.imp).to.have.nested.property('[0].native'); + }); + + describe('that contains also a banner mediaType', function () { + it('should send the banner to fastlane BUT NOT the native bid because missing params.video', function() { + const bidReq = addNativeToBidRequest(bidderRequest); + bidReq.bids[0].mediaTypes.banner = { + sizes: [[300, 250]] + } + let [request] = spec.buildRequests(bidReq.bids, bidReq); + expect(request.method).to.equal('GET'); + expect(request.url).to.include('https://fastlane.rubiconproject.com/a/api/fastlane.json'); + }); + }); + describe('with another banner request', () => { + it('should send the native bid to PBS and the banner to fastlane', function() { + const bidReq = addNativeToBidRequest(bidderRequest); + bidReq.bids[0].params = { video: {} }; + // add second bidRqeuest + bidReq.bids.push({ + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + }, + params: bidReq.bids[0].params + }) + let [request1, request2] = spec.buildRequests(bidReq.bids, bidReq); + expect(request1.method).to.equal('POST'); + expect(request1.url).to.equal('https://prebid-server.rubiconproject.com/openrtb2/auction'); + expect(request1.data.imp).to.have.nested.property('[0].native'); + expect(request2.method).to.equal('GET'); + expect(request2.url).to.include('https://fastlane.rubiconproject.com/a/api/fastlane.json'); + }); + }); + }); + + describe('with bidonmultiformat === true', () => { + it('should send two requests, to PBS with 2 imps', () => { + const bidReq = addNativeToBidRequest(bidderRequest); + // add second mediaType + bidReq.bids[0].mediaTypes = { + ...bidReq.bids[0].mediaTypes, + banner: { + sizes: [[300, 250]] + } + }; + bidReq.bids[0].params.bidonmultiformat = true; + let [pbsRequest, fastlanteRequest] = spec.buildRequests(bidReq.bids, bidReq); + expect(pbsRequest.method).to.equal('POST'); + expect(pbsRequest.url).to.equal('https://prebid-server.rubiconproject.com/openrtb2/auction'); + expect(pbsRequest.data.imp).to.have.nested.property('[0].native'); + expect(fastlanteRequest.url).to.equal('https://fastlane.rubiconproject.com/a/api/fastlane.json'); + }); + }); + describe('with bidonmultiformat === false', () => { + it('should send only banner request because there\'s no params.video', () => { + const bidReq = addNativeToBidRequest(bidderRequest); + // add second mediaType + bidReq.bids[0].mediaTypes = { + ...bidReq.bids[0].mediaTypes, + banner: { + sizes: [[300, 250]] + } + }; + + let [fastlanteRequest, ...others] = spec.buildRequests(bidReq.bids, bidReq); + expect(fastlanteRequest.url).to.equal('https://fastlane.rubiconproject.com/a/api/fastlane.json'); + expect(others).to.be.empty; + }); + + it('should not send native to PBS even if there\'s param.video', () => { + const bidReq = addNativeToBidRequest(bidderRequest); + // add second mediaType + bidReq.bids[0].mediaTypes = { + ...bidReq.bids[0].mediaTypes, + banner: { + sizes: [[300, 250]] + } + }; + // by adding this, when bidonmultiformat is false, the native request will be sent to pbs + bidReq.bids[0].params = { + video: {} + } + let [fastlaneRequest, ...other] = spec.buildRequests(bidReq.bids, bidReq); + expect(fastlaneRequest.method).to.equal('GET'); + expect(fastlaneRequest.url).to.equal('https://fastlane.rubiconproject.com/a/api/fastlane.json'); + expect(other).to.be.empty; + }); + }); + }); + } }); describe('interpretResponse', function () { @@ -3119,7 +3230,7 @@ describe('the rubicon adapter', function () { seatbid: [{ bid: [{ id: '0', - impid: 'instream_video1', + impid: '/19968336/header-bid-tag-0', adomain: ['test.com'], price: 2, crid: '4259970', @@ -3144,9 +3255,9 @@ describe('the rubicon adapter', function () { }], }; - let bids = spec.interpretResponse({body: response}, { - bidRequest: bidderRequest.bids[0] - }); + const request = converter.toORTB({bidderRequest, bidRequests: bidderRequest.bids}); + + let bids = spec.interpretResponse({body: response}, {data: request}); expect(bids).to.be.lengthOf(1); @@ -3167,6 +3278,18 @@ describe('the rubicon adapter', function () { }); }); + if (FEATURES.NATIVE) { + describe('for native', () => { + it('should get a native bid', () => { + const nativeBidderRequest = addNativeToBidRequest(bidderRequest); + const request = converter.toORTB({bidderRequest: nativeBidderRequest, bidRequests: nativeBidderRequest.bids}); + let response = getNativeResponse({impid: request.imp[0].id}); + let bids = spec.interpretResponse({body: response}, {data: request}); + expect(bids).to.have.nested.property('[0].native'); + }); + }); + } + describe('for outstream video', function () { const sandbox = sinon.createSandbox(); beforeEach(function () { @@ -3196,7 +3319,7 @@ describe('the rubicon adapter', function () { seatbid: [{ bid: [{ id: '0', - impid: 'outstream_video1', + impid: '/19968336/header-bid-tag-0', adomain: ['test.com'], price: 2, crid: '4259970', @@ -3221,9 +3344,9 @@ describe('the rubicon adapter', function () { }], }; - let bids = spec.interpretResponse({body: response}, { - bidRequest: bidderRequest.bids[0] - }); + const request = converter.toORTB({bidderRequest, bidRequests: bidderRequest.bids}); + + let bids = spec.interpretResponse({body: response}, { data: request }); expect(bids).to.be.lengthOf(1); @@ -3256,7 +3379,7 @@ describe('the rubicon adapter', function () { seatbid: [{ bid: [{ id: '0', - impid: 'outstream_video1', + impid: '/19968336/header-bid-tag-0', adomain: ['test.com'], price: 2, crid: '4259970', @@ -3282,11 +3405,11 @@ describe('the rubicon adapter', function () { }], }; + const request = converter.toORTB({bidderRequest, bidRequests: bidderRequest.bids}); + sinon.spy(window.MagniteApex, 'renderAd'); - let bids = spec.interpretResponse({body: response}, { - bidRequest: bidderRequest.bids[0] - }); + let bids = spec.interpretResponse({body: response}, {data: request}); const bid = bids[0]; bid.adUnitCode = 'outstream_video1_placement'; const adUnit = document.createElement('div'); @@ -3617,3 +3740,164 @@ describe('the rubicon adapter', function () { }); }); }); + +function addNativeToBidRequest(bidderRequest) { + const nativeOrtbRequest = { + assets: [{ + id: 0, + required: 1, + title: { + len: 140 + } + }, + { + id: 1, + required: 1, + img: { + type: 3, + w: 300, + h: 600 + } + }, + { + id: 2, + required: 1, + data: { + type: 1 + } + }] + }; + bidderRequest.refererInfo = { + page: 'localhost' + } + bidderRequest.bids[0] = { + bidder: 'rubicon', + params: { + accountId: '14062', + siteId: '70608', + zoneId: '335918', + }, + adUnitCode: '/19968336/header-bid-tag-0', + code: 'div-1', + bidId: '2ffb201a808da7', + bidderRequestId: '178e34bad3658f', + auctionId: 'c45dd708-a418-42ec-b8a7-b70a6c6fab0a', + transactionId: 'd45dd707-a418-42ec-b8a7-b70a6c6fab0b', + mediaTypes: { + native: { + ortb: { + ...nativeOrtbRequest + } + } + }, + nativeOrtbRequest + } + return bidderRequest; +} + +function getNativeResponse(options = {impid: 1234}) { + return { + 'id': 'd7786a80-bfb4-4541-859f-225a934e81d4', + 'seatbid': [ + { + 'bid': [ + { + 'id': '971650', + 'impid': options.impid, + 'price': 20, + 'adm': { + 'ver': '1.2', + 'assets': [ + { + 'id': 0, + 'title': { + 'text': 'This is a title' + }, + 'link': { + 'clicktrackers': [ + 'http://localhost:5500/event?type=click1&component=card&asset=0' + ] + } + }, + { + 'id': 1, + 'img': { + 'url': 'https:\\\\/\\\\/vcdn.adnxs.com\\\\/p\\\\/creative-image\\\\/94\\\\/22\\\\/cd\\\\/0f\\\\/9422cd0f-f400-45d3-80f5-2b92629d9257.jpg', + 'h': 2250, + 'w': 3000 + }, + 'link': { + 'clicktrackers': [ + 'http://localhost:5500/event?type=click1&component=card&asset=1' + ] + } + }, + { + 'id': 2, + 'data': { + 'value': 'this is asset data 1 that corresponds to sponsoredBy' + } + } + ], + 'link': { + 'url': 'https://magnite.com', + 'clicktrackers': [ + 'http://localhost:5500/event?type=click1&component=card', + 'http://localhost:5500/event?type=click2&component=card' + ] + }, + 'jstracker': '', + 'eventtrackers': [ + { + 'event': 1, + 'method': 2, + 'url': 'http://localhost:5500/event?type=1&method=2' + }, + { + 'event': 2, + 'method': 1, + 'url': 'http://localhost:5500/event?type=v50&component=card' + } + ] + }, + 'adid': '392180', + 'adomain': [ + 'http://prebid.org' + ], + 'iurl': 'https://lax1-ib.adnxs.com/cr?id=97494403', + 'cid': '9325', + 'crid': '97494403', + 'cat': [ + 'IAB3-1' + ], + 'w': 300, + 'h': 600, + 'ext': { + 'prebid': { + 'targeting': { + 'hb_bidder': 'rubicon', + 'hb_cache_host': 'prebid.lax1.adnxs-simple.com', + 'hb_cache_path': '/pbc/v1/cache', + 'hb_pb': '20.00' + }, + 'type': 'native', + 'video': { + 'duration': 0, + 'primary_category': '' + } + }, + 'rubicon': { + 'auction_id': 642778043863823100, + 'bid_ad_type': 3, + 'bidder_id': 2, + 'brand_id': 555545 + } + } + } + ], + 'seat': 'rubicon' + } + ], + 'cur': 'USD' + }; +}