From fcf8d18f57814d533dddd24adab75a004247ad7d Mon Sep 17 00:00:00 2001 From: Matt Lane Date: Fri, 15 Sep 2017 12:56:05 -0700 Subject: [PATCH] Add support for video stream context (#1483) * Add support for video stream context * Define adapter as supporting video * Use mediaTypes param to specify context * Use utils.deepAccess * Check for outstream bids * Add JSDoc and validation * Rename functions and add unit test * Update property name * Update stubs to new sinon stub syntax * Only check context when mediaTypes.video was defined * Retain video-outstream compatibility * Revert to Sinon 1 syntax * Server and bid response ad type for any stream type is always 'video' * Update to address code review --- modules/appnexusAstBidAdapter.js | 15 +++-- modules/unrulyBidAdapter.js | 10 +++- src/adaptermanager.js | 19 +++++- src/bidmanager.js | 5 +- src/utils.js | 42 ++++++++++++++ src/video.js | 40 ++++++++++++- test/spec/bidmanager_spec.js | 24 ++++++++ test/spec/modules/unrulyBidAdapter_spec.js | 2 +- test/spec/utils_spec.js | 19 ++++++ test/spec/video_spec.js | 67 ++++++++++++++++++++++ 10 files changed, 229 insertions(+), 14 deletions(-) create mode 100644 test/spec/video_spec.js diff --git a/modules/appnexusAstBidAdapter.js b/modules/appnexusAstBidAdapter.js index 7da6bf0285b..9a7776a8bb5 100644 --- a/modules/appnexusAstBidAdapter.js +++ b/modules/appnexusAstBidAdapter.js @@ -5,7 +5,7 @@ import { NATIVE, VIDEO } from 'src/mediaTypes'; const BIDDER_CODE = 'appnexusAst'; const URL = '//ib.adnxs.com/ut/v3/prebid'; -const SUPPORTED_AD_TYPES = ['banner', 'video', 'video-outstream', 'native']; +const SUPPORTED_AD_TYPES = ['banner', 'video', 'native']; const VIDEO_TARGETING = ['id', 'mimes', 'minduration', 'maxduration', 'startdelay', 'skippable', 'playback_method', 'frameworks']; const USER_PARAMS = ['age', 'external_uid', 'segments', 'gender', 'dnt', 'language']; @@ -218,6 +218,7 @@ function newBid(serverBid, rtbBid) { return bid; } + function bidToTag(bid) { const tag = {}; tag.sizes = transformSizes(bid.sizes); @@ -289,7 +290,13 @@ function bidToTag(bid) { } } - if (bid.mediaType === 'video') { tag.require_asset_url = true; } + const videoMediaType = utils.deepAccess(bid, 'mediaTypes.video'); + const context = utils.deepAccess(bid, 'mediaTypes.video.context'); + + if (bid.mediaType === 'video' || (videoMediaType && context !== 'outstream')) { + tag.require_asset_url = true; + } + if (bid.params.video) { tag.video = {}; // place any valid video params on the tag @@ -356,9 +363,7 @@ function handleOutstreamRendererEvents(bid, id, eventName) { function parseMediaType(rtbBid) { const adType = rtbBid.ad_type; - if (rtbBid.renderer_url) { - return 'video-outstream'; - } else if (adType === 'video') { + if (adType === 'video') { return 'video'; } else if (adType === 'native') { return 'native'; diff --git a/modules/unrulyBidAdapter.js b/modules/unrulyBidAdapter.js index 997272cf9cc..fd9e94859c9 100644 --- a/modules/unrulyBidAdapter.js +++ b/modules/unrulyBidAdapter.js @@ -85,6 +85,12 @@ function UnrulyAdapter() { return } + const videoMediaType = utils.deepAccess(bidRequestBids[0], 'mediaTypes.video') + const context = utils.deepAccess(bidRequestBids[0], 'mediaTypes.video.context') + if (videoMediaType && context !== 'outstream') { + return + } + const payload = { bidRequests: bidRequestBids } @@ -106,6 +112,8 @@ function UnrulyAdapter() { return adapter } -adaptermanager.registerBidAdapter(new UnrulyAdapter(), 'unruly') +adaptermanager.registerBidAdapter(new UnrulyAdapter(), 'unruly', { + supportedMediaTypes: ['video'] +}); module.exports = UnrulyAdapter diff --git a/src/adaptermanager.js b/src/adaptermanager.js index d41ce0b3cef..38b7073478a 100644 --- a/src/adaptermanager.js +++ b/src/adaptermanager.js @@ -1,6 +1,6 @@ /** @module adaptermanger */ -import { flatten, getBidderCodes, shuffle } from './utils'; +import { flatten, getBidderCodes, getDefinedParams, shuffle } from './utils'; import { mapSizes } from './sizeMapping'; import { processNativeAdUnitParams, nativeAdapters } from './native'; import { StorageManager, pbjsSyncsKey } from './storagemanager'; @@ -48,10 +48,23 @@ function getBids({bidderCode, requestId, bidderRequestId, adUnits}) { }); } + if (adUnit.mediaTypes) { + if (utils.isValidMediaTypes(adUnit.mediaTypes)) { + bid = Object.assign({}, bid, { mediaTypes: adUnit.mediaTypes }); + } else { + utils.logError( + `mediaTypes is not correctly configured for adunit ${adUnit.code}` + ); + } + } + + bid = Object.assign({}, bid, getDefinedParams(adUnit, [ + 'mediaType', + 'renderer' + ])); + return Object.assign({}, bid, { placementCode: adUnit.code, - mediaType: adUnit.mediaType, - renderer: adUnit.renderer, transactionId: adUnit.transactionId, sizes: sizes, bidId: bid.bid_id || utils.getUniqueIdentifierStr(), diff --git a/src/bidmanager.js b/src/bidmanager.js index 2fc4f51fd51..e6e3dde0f35 100644 --- a/src/bidmanager.js +++ b/src/bidmanager.js @@ -1,6 +1,7 @@ import { uniques, flatten, adUnitsFilter, getBidderRequest } from './utils'; import { getPriceBucketString } from './cpmBucketManager'; import { NATIVE_KEYS, nativeBidIsValid } from './native'; +import { isValidVideoBid } from './video'; import { getCacheUrl, store } from './videoCache'; import { Renderer } from 'src/Renderer'; import { config } from 'src/config'; @@ -120,8 +121,8 @@ exports.addBidResponse = function (adUnitCode, bid) { utils.logError(errorMessage('Native bid missing some required properties.')); return false; } - if (bid.mediaType === 'video' && !(bid.vastUrl || bid.vastXml)) { - utils.logError(errorMessage(`Video bid has no vastUrl or vastXml property.`)); + if (bid.mediaType === 'video' && !isValidVideoBid(bid)) { + utils.logError(errorMessage(`Video bid does not have required vastUrl or renderer property`)); return false; } if (bid.mediaType === 'banner' && !validBidSize(bid)) { diff --git a/src/utils.js b/src/utils.js index ed249734661..7b3ad3cda21 100644 --- a/src/utils.js +++ b/src/utils.js @@ -720,3 +720,45 @@ export function deepAccess(obj, path) { } return obj; } + +/** + * Build an object consisting of only defined parameters to avoid creating an + * object with defined keys and undefined values. + * @param {object} object The object to pick defined params out of + * @param {string[]} params An array of strings representing properties to look for in the object + * @returns {object} An object containing all the specified values that are defined + */ +export function getDefinedParams(object, params) { + return params + .filter(param => object[param]) + .reduce((bid, param) => Object.assign(bid, { [param]: object[param] }), {}); +} + +/** + * @typedef {Object} MediaTypes + * @property {Object} banner banner configuration + * @property {Object} native native configuration + * @property {Object} video video configuration + */ + +/** + * Validates an adunit's `mediaTypes` parameter + * @param {MediaTypes} mediaTypes mediaTypes parameter to validate + * @return {boolean} If object is valid + */ +export function isValidMediaTypes(mediaTypes) { + const SUPPORTED_MEDIA_TYPES = ['banner', 'native', 'video']; + const SUPPORTED_STREAM_TYPES = ['instream', 'outstream']; + + const types = Object.keys(mediaTypes); + + if (!types.every(type => SUPPORTED_MEDIA_TYPES.includes(type))) { + return false; + } + + if (mediaTypes.video && mediaTypes.video.context) { + return SUPPORTED_STREAM_TYPES.includes(mediaTypes.video.context); + } + + return true; +} diff --git a/src/video.js b/src/video.js index eee1a9dbbd8..386b6b692e9 100644 --- a/src/video.js +++ b/src/video.js @@ -1,8 +1,44 @@ import { videoAdapters } from './adaptermanager'; +import { getBidRequest, deepAccess } from './utils'; + +const VIDEO_MEDIA_TYPE = 'video'; +const OUTSTREAM = 'outstream'; /** * Helper functions for working with video-enabled adUnits */ -export const videoAdUnit = adUnit => adUnit.mediaType === 'video'; +export const videoAdUnit = adUnit => adUnit.mediaType === VIDEO_MEDIA_TYPE; const nonVideoBidder = bid => !videoAdapters.includes(bid.bidder); -export const hasNonVideoBidder = adUnit => adUnit.bids.filter(nonVideoBidder).length; +export const hasNonVideoBidder = adUnit => + adUnit.bids.filter(nonVideoBidder).length; + +/** + * @typedef {object} VideoBid + * @property {string} adId id of the bid + */ + +/** + * Validate that the assets required for video context are present on the bid + * @param {VideoBid} bid video bid to validate + * @return {boolean} If object is valid + */ +export function isValidVideoBid(bid) { + const bidRequest = getBidRequest(bid.adId); + + const videoMediaType = + bidRequest && deepAccess(bidRequest, 'mediaTypes.video'); + const context = videoMediaType && deepAccess(videoMediaType, 'context'); + + // if context not defined assume default 'instream' for video bids + // instream bids require a vast url or vast xml content + if (!bidRequest || (videoMediaType && context !== OUTSTREAM)) { + return !!(bid.vastUrl || bid.vastXml); + } + + // outstream bids require a renderer on the bid or pub-defined on adunit + if (context === OUTSTREAM) { + return !!(bid.renderer || bidRequest.renderer); + } + + return true; +} diff --git a/test/spec/bidmanager_spec.js b/test/spec/bidmanager_spec.js index 901d092e191..7d64e84d276 100644 --- a/test/spec/bidmanager_spec.js +++ b/test/spec/bidmanager_spec.js @@ -622,5 +622,29 @@ describe('bidmanager.js', function () { utils.getBidderRequest.restore(); }); + + it('requires a renderer on outstream bids', () => { + sinon.stub(utils, 'getBidRequest', () => ({ + bidder: 'appnexusAst', + mediaTypes: { + video: {context: 'outstream'} + }, + })); + + const bid = Object.assign({}, + bidfactory.createBid(1), + { + bidderCode: 'appnexusAst', + mediaType: 'video', + renderer: {render: () => true, url: 'render.js'}, + } + ); + + const bidsRecCount = $$PREBID_GLOBAL$$._bidsReceived.length; + bidmanager.addBidResponse('adUnit-code', bid); + assert.equal(bidsRecCount + 1, $$PREBID_GLOBAL$$._bidsReceived.length); + + utils.getBidRequest.restore(); + }); }); }); diff --git a/test/spec/modules/unrulyBidAdapter_spec.js b/test/spec/modules/unrulyBidAdapter_spec.js index dfa7a72b8ad..067f3ea46d0 100644 --- a/test/spec/modules/unrulyBidAdapter_spec.js +++ b/test/spec/modules/unrulyBidAdapter_spec.js @@ -17,7 +17,7 @@ describe('UnrulyAdapter', () => { 'placementId': '5768085' }, 'placementCode': placementCode, - 'mediaType': 'video', + 'mediaTypes': { video: { context: 'outstream' } }, 'transactionId': '62890707-3770-497c-a3b8-d905a2d0cb98', 'sizes': [ 640, diff --git a/test/spec/utils_spec.js b/test/spec/utils_spec.js index b1837123449..e5a0b72d9b2 100755 --- a/test/spec/utils_spec.js +++ b/test/spec/utils_spec.js @@ -568,4 +568,23 @@ describe('Utils', function () { assert.equal(value, undefined); }); }); + + describe('getDefinedParams', () => { + it('builds an object consisting of defined params', () => { + const adUnit = { + mediaType: 'video', + comeWithMe: 'ifuwant2live', + notNeeded: 'do not include', + }; + + const builtObject = utils.getDefinedParams(adUnit, [ + 'mediaType', 'comeWithMe' + ]); + + assert.deepEqual(builtObject, { + mediaType: 'video', + comeWithMe: 'ifuwant2live', + }); + }); + }); }); diff --git a/test/spec/video_spec.js b/test/spec/video_spec.js new file mode 100644 index 00000000000..57a7f7a127e --- /dev/null +++ b/test/spec/video_spec.js @@ -0,0 +1,67 @@ +import { isValidVideoBid } from 'src/video'; +const utils = require('src/utils'); + +describe('video.js', () => { + afterEach(() => { + utils.getBidRequest.restore(); + }); + + it('validates valid instream bids', () => { + sinon.stub(utils, 'getBidRequest', () => ({ + bidder: 'appnexusAst', + mediaTypes: { + video: { context: 'instream' }, + }, + })); + + const valid = isValidVideoBid({ + vastUrl: 'http://www.example.com/vastUrl' + }); + + expect(valid).to.be(true); + }); + + it('catches invalid instream bids', () => { + sinon.stub(utils, 'getBidRequest', () => ({ + bidder: 'appnexusAst', + mediaTypes: { + video: { context: 'instream' }, + }, + })); + + const valid = isValidVideoBid({}); + + expect(valid).to.be(false); + }); + + it('validates valid outstream bids', () => { + sinon.stub(utils, 'getBidRequest', () => ({ + bidder: 'appnexusAst', + mediaTypes: { + video: { context: 'outstream' }, + }, + })); + + const valid = isValidVideoBid({ + renderer: { + url: 'render.url', + render: () => true, + } + }); + + expect(valid).to.be(true); + }); + + it('catches invalid outstream bids', () => { + sinon.stub(utils, 'getBidRequest', () => ({ + bidder: 'appnexusAst', + mediaTypes: { + video: { context: 'outstream' }, + }, + })); + + const valid = isValidVideoBid({}); + + expect(valid).to.be(false); + }); +});