diff --git a/libraries/ortbConverter/processors/video.js b/libraries/ortbConverter/processors/video.js index caa855566eb..85ab1cd6ecf 100644 --- a/libraries/ortbConverter/processors/video.js +++ b/libraries/ortbConverter/processors/video.js @@ -1,30 +1,7 @@ import {deepAccess, isEmpty, logWarn, mergeDeep, sizesToSizeTuples, sizeTupleToRtbSize} from '../../../src/utils.js'; import {VIDEO} from '../../../src/mediaTypes.js'; -// parameters that share the same name & semantics between pbjs adUnits and imp.video -const ORTB_VIDEO_PARAMS = new Set([ - 'pos', - 'placement', - 'plcmt', - 'api', - 'mimes', - 'protocols', - 'playbackmethod', - 'minduration', - 'maxduration', - 'w', - 'h', - 'startdelay', - 'placement', - 'linearity', - 'skip', - 'skipmin', - 'skipafter', - 'minbitrate', - 'maxbitrate', - 'delivery', - 'playbackend' -]); +import {ORTB_VIDEO_PARAMS} from '../../../src/video.js'; export function fillVideoImp(imp, bidRequest, context) { if (context.mediaType && context.mediaType !== VIDEO) return; @@ -32,6 +9,7 @@ export function fillVideoImp(imp, bidRequest, context) { const videoParams = deepAccess(bidRequest, 'mediaTypes.video'); if (!isEmpty(videoParams)) { const video = Object.fromEntries( + // Parameters that share the same name & semantics between pbjs adUnits and imp.video Object.entries(videoParams) .filter(([name]) => ORTB_VIDEO_PARAMS.has(name)) ); diff --git a/modules/adagioBidAdapter.js b/modules/adagioBidAdapter.js index 9bcebf9205e..cb125d4446e 100644 --- a/modules/adagioBidAdapter.js +++ b/modules/adagioBidAdapter.js @@ -8,9 +8,7 @@ import { getDNT, getWindowSelf, isArray, - isArrayOfNums, isFn, - isInteger, isNumber, isStr, logError, @@ -18,7 +16,7 @@ import { logWarn, } from '../src/utils.js'; import { getRefererInfo, parseDomain } from '../src/refererDetection.js'; -import { OUTSTREAM } from '../src/video.js'; +import { OUTSTREAM, validateOrtbVideoFields } from '../src/video.js'; import { Renderer } from '../src/Renderer.js'; import { _ADAGIO } from '../libraries/adagioUtils/adagioUtils.js'; import { config } from '../src/config.js'; @@ -40,39 +38,6 @@ export const BB_RENDERER_URL = `https://${BB_PUBLICATION}.bbvms.com/r/$RENDERER. const CURRENCY = 'USD'; -// This provide a whitelist and a basic validation of OpenRTB 2.5 options used by the Adagio SSP. -// Accept all options but 'protocol', 'companionad', 'companiontype', 'ext' -// https://www.iab.com/wp-content/uploads/2016/03/OpenRTB-API-Specification-Version-2-5-FINAL.pdf -export const ORTB_VIDEO_PARAMS = { - 'mimes': (value) => Array.isArray(value) && value.length > 0 && value.every(v => typeof v === 'string'), - 'minduration': (value) => isInteger(value), - 'maxduration': (value) => isInteger(value), - 'protocols': (value) => isArrayOfNums(value), - 'w': (value) => isInteger(value), - 'h': (value) => isInteger(value), - 'startdelay': (value) => isInteger(value), - 'placement': (value) => { - logWarn(LOG_PREFIX, 'The OpenRTB video param `placement` is deprecated and should not be used anymore.'); - return isInteger(value) - }, - 'plcmt': (value) => isInteger(value), - 'linearity': (value) => isInteger(value), - 'skip': (value) => [1, 0].includes(value), - 'skipmin': (value) => isInteger(value), - 'skipafter': (value) => isInteger(value), - 'sequence': (value) => isInteger(value), - 'battr': (value) => isArrayOfNums(value), - 'maxextended': (value) => isInteger(value), - 'minbitrate': (value) => isInteger(value), - 'maxbitrate': (value) => isInteger(value), - 'boxingallowed': (value) => isInteger(value), - 'playbackmethod': (value) => isArrayOfNums(value), - 'playbackend': (value) => isInteger(value), - 'delivery': (value) => isArrayOfNums(value), - 'pos': (value) => isInteger(value), - 'api': (value) => isArrayOfNums(value) -}; - /** * Returns the window.ADAGIO global object used to store Adagio data. * This object is created in window.top if possible, otherwise in window.self. @@ -186,6 +151,12 @@ function _getEids(bidRequest) { } } +/** + * Merge and compute video params set at mediaTypes and bidder params level + * + * @param {object} bidRequest - copy of the original bidRequest object. + * @returns {void} + */ function _buildVideoBidRequest(bidRequest) { const videoAdUnitParams = deepAccess(bidRequest, 'mediaTypes.video', {}); const videoBidderParams = deepAccess(bidRequest, 'params.video', {}); @@ -206,22 +177,11 @@ function _buildVideoBidRequest(bidRequest) { }; if (videoParams.context && videoParams.context === OUTSTREAM) { - bidRequest.mediaTypes.video.playerName = getPlayerName(bidRequest); + videoParams.playerName = getPlayerName(bidRequest); } - // Only whitelisted OpenRTB options need to be validated. - // Other options will still remain in the `mediaTypes.video` object - // sent in the ad-request, but will be ignored by the SSP. - Object.keys(ORTB_VIDEO_PARAMS).forEach(paramName => { - if (videoParams.hasOwnProperty(paramName)) { - if (ORTB_VIDEO_PARAMS[paramName](videoParams[paramName])) { - bidRequest.mediaTypes.video[paramName] = videoParams[paramName]; - } else { - delete bidRequest.mediaTypes.video[paramName]; - logWarn(`${LOG_PREFIX} The OpenRTB video param ${paramName} has been skipped due to misformating. Please refer to OpenRTB 2.5 spec.`); - } - } - }); + bidRequest.mediaTypes.video = videoParams; + validateOrtbVideoFields(bidRequest); } function _parseNativeBidResponse(bid) { diff --git a/src/prebid.js b/src/prebid.js index e91af3e4d04..c92ab8f5a89 100644 --- a/src/prebid.js +++ b/src/prebid.js @@ -41,7 +41,7 @@ import {enrichFPD} from './fpd/enrichment.js'; import {allConsent} from './consentHandler.js'; import {insertLocatorFrame, renderAdDirect} from './adRendering.js'; import {getHighestCpm} from './utils/reducers.js'; -import {fillVideoDefaults} from './video.js'; +import {fillVideoDefaults, validateOrtbVideoFields} from './video.js'; const pbjsInstance = getGlobal(); const { triggerUserSyncs } = userSync; @@ -134,6 +134,7 @@ function validateVideoMediaType(adUnit) { delete validatedAdUnit.mediaTypes.video.playerSize; } } + validateOrtbVideoFields(validatedAdUnit); return validatedAdUnit; } diff --git a/src/video.js b/src/video.js index f8de2b98861..9be9adec4c5 100644 --- a/src/video.js +++ b/src/video.js @@ -1,4 +1,4 @@ -import {deepAccess, logError} from './utils.js'; +import {deepAccess, isArrayOfNums, isInteger, isNumber, isPlainObject, isStr, logError, logWarn} from './utils.js'; import {config} from '../src/config.js'; import {hook} from './hook.js'; import {auctionManager} from './auctionManager.js'; @@ -6,6 +6,47 @@ import {auctionManager} from './auctionManager.js'; export const OUTSTREAM = 'outstream'; export const INSTREAM = 'instream'; +/** + * List of OpenRTB 2.x video object properties with simple validators. + * Not included: `companionad`, `durfloors`, `ext` + * reference: https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md + */ +export const ORTB_VIDEO_PARAMS = new Map([ + [ 'mimes', value => Array.isArray(value) && value.length > 0 && value.every(v => typeof v === 'string') ], + [ 'minduration', isInteger ], + [ 'maxduration', isInteger ], + [ 'startdelay', isInteger ], + [ 'maxseq', isInteger ], + [ 'poddur', isInteger ], + [ 'protocols', isArrayOfNums ], + [ 'w', isInteger ], + [ 'h', isInteger ], + [ 'podid', isStr ], + [ 'podseq', isInteger ], + [ 'rqddurs', isArrayOfNums ], + [ 'placement', isInteger ], // deprecated, see plcmt + [ 'plcmt', isInteger ], + [ 'linearity', isInteger ], + [ 'skip', value => [1, 0].includes(value) ], + [ 'skipmin', isInteger ], + [ 'skipafter', isInteger ], + [ 'sequence', isInteger ], // deprecated + [ 'slotinpod', isInteger ], + [ 'mincpmpersec', isNumber ], + [ 'battr', isArrayOfNums ], + [ 'maxextended', isInteger ], + [ 'minbitrate', isInteger ], + [ 'maxbitrate', isInteger ], + [ 'boxingallowed', isInteger ], + [ 'playbackmethod', isArrayOfNums ], + [ 'playbackend', isInteger ], + [ 'delivery', isArrayOfNums ], + [ 'pos', isInteger ], + [ 'api', isArrayOfNums ], + [ 'companiontype', isArrayOfNums ], + [ 'poddedupe', isArrayOfNums ] +]); + export function fillVideoDefaults(adUnit) { const video = adUnit?.mediaTypes?.video; if (video != null && video.plcmt == null) { @@ -17,6 +58,42 @@ export function fillVideoDefaults(adUnit) { } } +/** + * validateOrtbVideoFields mutates the `adUnit.mediaTypes.video` object by removing invalid ortb properties (default). + * The onInvalidParam callback can be used to handle invalid properties differently. + * Other properties are ignored and kept as is. + * + * @param {Object} adUnit - The adUnit object. + * @param {Function} onInvalidParam - The callback function to be called with key, value, and adUnit. + * @returns {void} + */ +export function validateOrtbVideoFields(adUnit, onInvalidParam) { + const videoParams = adUnit?.mediaTypes?.video; + + if (!isPlainObject(videoParams)) { + logWarn(`validateOrtbVideoFields: videoParams must be an object.`); + return; + } + + if (videoParams != null) { + Object.entries(videoParams) + .forEach(([key, value]) => { + if (!ORTB_VIDEO_PARAMS.has(key)) { + return + } + const isValid = ORTB_VIDEO_PARAMS.get(key)(value); + if (!isValid) { + if (typeof onInvalidParam === 'function') { + onInvalidParam(key, value, adUnit); + } else { + delete videoParams[key]; + logWarn(`Invalid prop in adUnit "${adUnit.code}": Invalid value for mediaTypes.video.${key} ORTB property. The property has been removed.`); + } + } + }); + } +} + /** * @typedef {object} VideoBid * @property {string} adId id of the bid diff --git a/test/spec/modules/adagioBidAdapter_spec.js b/test/spec/modules/adagioBidAdapter_spec.js index 629771de9d6..75b0635bbef 100644 --- a/test/spec/modules/adagioBidAdapter_spec.js +++ b/test/spec/modules/adagioBidAdapter_spec.js @@ -603,7 +603,6 @@ describe('Adagio bid adapter', () => { const requests = spec.buildRequests([bid01], bidderRequest); expect(requests).to.have.lengthOf(1); expect(requests[0].data.adUnits[0].mediaTypes.video).to.deep.equal(expected); - sinon.assert.calledTwice(utils.logWarn.withArgs(sinon.match(new RegExp(/^Adagio: The OpenRTB/)))); }); }); diff --git a/test/spec/video_spec.js b/test/spec/video_spec.js index 3252c58c687..18955771049 100644 --- a/test/spec/video_spec.js +++ b/test/spec/video_spec.js @@ -1,12 +1,26 @@ -import {fillVideoDefaults, isValidVideoBid} from 'src/video.js'; +import {fillVideoDefaults, isValidVideoBid, validateOrtbVideoFields} from 'src/video.js'; import {hook} from '../../src/hook.js'; import {stubAuctionIndex} from '../helpers/indexStub.js'; +import * as utils from '../../src/utils.js'; describe('video.js', function () { + let sandbox; + let utilsMock; + before(() => { hook.ready(); }); + beforeEach(() => { + sandbox = sinon.createSandbox(); + utilsMock = sandbox.mock(utils); + }) + + afterEach(() => { + utilsMock.restore(); + sandbox.restore(); + }); + describe('fillVideoDefaults', () => { function fillDefaults(videoMediaType = {}) { const adUnit = {mediaTypes: {video: videoMediaType}}; @@ -77,6 +91,94 @@ describe('video.js', function () { }) }) + describe('validateOrtbVideoFields', () => { + it('remove incorrect ortb properties, and keep non ortb ones', () => { + sandbox.spy(utils, 'logWarn'); + + const mt = { + content: 'outstream', + + mimes: ['video/mp4'], + minduration: 5, + maxduration: 15, + startdelay: 0, + maxseq: 0, + poddur: 0, + protocols: [7], + w: 600, + h: 480, + podid: 'an-id', + podseq: 0, + rqddurs: [5], + placement: 1, + plcmt: 1, + linearity: 1, + skip: 0, + skipmin: 3, + skipafter: 3, + sequence: 0, + slotinpod: 0, + mincpmpersec: 2.5, + battr: [6, 7], + maxextended: 0, + minbitrate: 800, + maxbitrate: 1000, + boxingallowed: 1, + playbackmethod: [1], + playbackend: 1, + delivery: [2], + pos: 0, + api: 6, // -- INVALID + companiontype: [1, 2, 3], + poddedupe: [1], + + otherOne: 'test', + }; + + const expected = {...mt}; + delete expected.api; + + const adUnit = { + code: 'adUnitCode', + mediaTypes: { video: mt } + }; + validateOrtbVideoFields(adUnit); + + expect(adUnit.mediaTypes.video).to.eql(expected); + sinon.assert.callCount(utils.logWarn, 1); + }); + + it('Early return when 1st param is not a plain object', () => { + sandbox.spy(utils, 'logWarn'); + + validateOrtbVideoFields(); + validateOrtbVideoFields([]); + validateOrtbVideoFields(null); + validateOrtbVideoFields('hello'); + validateOrtbVideoFields(() => {}); + + sinon.assert.callCount(utils.logWarn, 5); + }); + + it('Calls onInvalidParam when a property is invalid', () => { + const onInvalidParam = sandbox.spy(); + const adUnit = { + code: 'adUnitCode', + mediaTypes: { + video: { + content: 'outstream', + mimes: ['video/mp4'], + api: 6 + } + } + }; + validateOrtbVideoFields(adUnit, onInvalidParam); + + sinon.assert.calledOnce(onInvalidParam); + sinon.assert.calledWith(onInvalidParam, 'api', 6, adUnit); + }); + }) + describe('isValidVideoBid', () => { it('validates valid instream bids', function () { const bid = {