diff --git a/modules/appnexusAstBidAdapter.js b/modules/appnexusAstBidAdapter.js index 5ca2178d0a43..7da6bf0285be 100644 --- a/modules/appnexusAstBidAdapter.js +++ b/modules/appnexusAstBidAdapter.js @@ -1,13 +1,10 @@ -import Adapter from 'src/adapter'; import { Renderer } from 'src/Renderer'; -import bidfactory from 'src/bidfactory'; -import bidmanager from 'src/bidmanager'; import * as utils from 'src/utils'; -import { ajax } from 'src/ajax'; -import { STATUS } from 'src/constants'; -import adaptermanager from 'src/adaptermanager'; +import { registerBidder } from 'src/adapters/bidderFactory'; +import { NATIVE, VIDEO } from 'src/mediaTypes'; -const ENDPOINT = '//ib.adnxs.com/ut/v3/prebid'; +const BIDDER_CODE = 'appnexusAst'; +const URL = '//ib.adnxs.com/ut/v3/prebid'; const SUPPORTED_AD_TYPES = ['banner', 'video', 'video-outstream', 'native']; const VIDEO_TARGETING = ['id', 'mimes', 'minduration', 'maxduration', 'startdelay', 'skippable', 'playback_method', 'frameworks']; @@ -27,364 +24,347 @@ const NATIVE_MAPPING = { }; const SOURCE = 'pbjs'; -/** - * Bidder adapter for /ut endpoint. Given the list of all ad unit tag IDs, - * sends out a bid request. When a bid response is back, registers the bid - * to Prebid.js. This adapter supports alias bidding. - */ -function AppnexusAstAdapter() { - let baseAdapter = new Adapter('appnexusAst'); - let bidRequests = {}; - let usersync = false; - - /* Prebid executes this function when the page asks to send out bid requests */ - baseAdapter.callBids = function(bidRequest) { - bidRequests = {}; +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [VIDEO, NATIVE], + + /** + * Determines whether or not the given bid request is valid. + * + * @param {object} bidParams The params to validate. + * @return boolean True if this is a valid bid, and false otherwise. + */ + areParamsValid: function(bidParams) { + return !!(bidParams.placementId || (bidParams.member && bidParams.invCode)); + }, - const bids = bidRequest.bids || []; - var member = 0; + /** + * Make a server request from the list of BidRequests. + * + * @param {BidRequest[]} bidRequests A non-empty list of bid requests which should be sent to the Server. + * @return ServerRequest Info describing the request to the server. + */ + buildRequests: function(bidRequests) { + const tags = bidRequests.map(bidToTag); + const userObjBid = bidRequests.find(hasUserInfo); let userObj; - const tags = bids - .filter(bid => valid(bid)) - .map(bid => { - // map request id to bid object to retrieve adUnit code in callback - bidRequests[bid.bidId] = bid; - - let tag = {}; - tag.sizes = getSizes(bid.sizes); - tag.primary_size = tag.sizes[0]; - tag.uuid = bid.bidId; - if (bid.params.placementId) { - tag.id = parseInt(bid.params.placementId, 10); - } else { - tag.code = bid.params.invCode; - } - tag.allow_smaller_sizes = bid.params.allowSmallerSizes || false; - tag.prebid = true; - tag.disable_psa = true; - member = parseInt(bid.params.member, 10); - if (bid.params.reserve) { - tag.reserve = bid.params.reserve; - } - if (bid.params.position) { - tag.position = {'above': 1, 'below': 2}[bid.params.position] || 0; - } - if (bid.params.trafficSourceCode) { - tag.traffic_source_code = bid.params.trafficSourceCode; - } - if (bid.params.privateSizes) { - tag.private_sizes = getSizes(bid.params.privateSizes); - } - if (bid.params.supplyType) { - tag.supply_type = bid.params.supplyType; - } - if (bid.params.pubClick) { - tag.pubclick = bid.params.pubClick; - } - if (bid.params.extInvCode) { - tag.ext_inv_code = bid.params.extInvCode; - } - if (bid.params.externalImpId) { - tag.external_imp_id = bid.params.externalImpId; - } - if (!utils.isEmpty(bid.params.keywords)) { - tag.keywords = getKeywords(bid.params.keywords); - } - - if (bid.mediaType === 'native') { - tag.ad_types = ['native']; - - if (bid.nativeParams) { - const nativeRequest = {}; - - // map standard prebid native asset identifier to /ut parameters - // e.g., tag specifies `body` but /ut only knows `description` - // mapping may be in form {tag: ''} or - // {tag: {serverName: '', serverParams: {...}}} - Object.keys(bid.nativeParams).forEach(key => { - // check if one of the forms is used, otherwise - // a mapping wasn't specified so pass the key straight through - const requestKey = - (NATIVE_MAPPING[key] && NATIVE_MAPPING[key].serverName) || - NATIVE_MAPPING[key] || - key; - - // if the mapping for this identifier specifies required server - // params via the `serverParams` object, merge that in - const params = Object.assign({}, - NATIVE_MAPPING[key] && NATIVE_MAPPING[key].serverParams, - bid.nativeParams[key] - ); - - nativeRequest[requestKey] = params; - }); - - tag.native = {layouts: [nativeRequest]}; - } - } - - if (bid.mediaType === 'video') { tag.require_asset_url = true; } - if (bid.params.video) { - tag.video = {}; - // place any valid video params on the tag - Object.keys(bid.params.video) - .filter(param => VIDEO_TARGETING.includes(param)) - .forEach(param => tag.video[param] = bid.params.video[param]); - } + if (userObjBid) { + userObj = {}; + Object.keys(userObjBid.params.user) + .filter(param => USER_PARAMS.includes(param)) + .forEach(param => userObj[param] = userObjBid.params.user[param]); + } - if (bid.params.user) { - userObj = {}; - Object.keys(bid.params.user) - .filter(param => USER_PARAMS.includes(param)) - .forEach(param => userObj[param] = bid.params.user[param]); - } + const memberIdBid = bidRequests.find(hasMemberId); + const member = memberIdBid ? parseInt(memberIdBid.params.member, 10) : 0; - return tag; - }); + const payload = { + tags: [...tags], + user: userObj, + sdk: { + source: SOURCE, + version: '$prebid.version$' + } + }; + if (member > 0) { + payload.member_id = member; + } + const payloadString = JSON.stringify(payload); + return { + method: 'POST', + url: URL, + data: payloadString, + }; + }, - if (!utils.isEmpty(tags)) { - const payloadJson = { - tags: [...tags], - user: userObj, - sdk: { - source: SOURCE, - version: '$prebid.version$' + /** + * Unpack the response from the server into a list of bids. + * + * @param {*} serverResponse A successful response from the server. + * @return {Bid[]} An array of bids which were nested inside the server. + */ + interpretResponse: function(serverResponse) { + const bids = []; + serverResponse.tags.forEach(serverBid => { + const rtbBid = getRtbBid(serverBid); + if (rtbBid) { + if (rtbBid.cpm !== 0 && SUPPORTED_AD_TYPES.includes(rtbBid.ad_type)) { + const bid = newBid(serverBid, rtbBid); + bid.mediaType = parseMediaType(rtbBid); + bids.push(bid); } - }; - if (member > 0) { - payloadJson.member_id = member; } - const payload = JSON.stringify(payloadJson); - ajax(ENDPOINT, handleResponse, payload, { - contentType: 'text/plain', - withCredentials: true - }); + }); + return bids; + }, + + getUserSyncs: function(syncOptions) { + if (syncOptions.iframeEnabled) { + return [{ + type: 'iframe', + url: '//acdn.adnxs.com/ib/static/usersync/v3/async_usersync.html' + }]; } - }; + } +} - /* Notify Prebid of bid responses so bids can get in the auction */ - function handleResponse(response) { - let parsed; +function newRenderer(adUnitCode, rtbBid) { + const renderer = Renderer.install({ + id: rtbBid.renderer_id, + url: rtbBid.renderer_url, + config: { adText: `AppNexus Outstream Video Ad via Prebid.js` }, + loaded: false, + }); - try { - parsed = JSON.parse(response); - } catch (error) { - utils.logError(error); - } + try { + renderer.setRender(outstreamRender); + } catch (err) { + utils.logWarning('Prebid Error calling setRender on renderer', err); + } - if (!parsed || parsed.error) { - let errorMessage = `in response for ${baseAdapter.getBidderCode()} adapter`; - if (parsed && parsed.error) { errorMessage += `: ${parsed.error}`; } - utils.logError(errorMessage); - - // signal this response is complete - Object.keys(bidRequests) - .map(bidId => bidRequests[bidId].placementCode) - .forEach(placementCode => { - bidmanager.addBidResponse(placementCode, createBid(STATUS.NO_BID)); - }); - return; + renderer.setEventHandlers({ + impression: () => utils.logMessage('AppNexus outstream video impression event'), + loaded: () => utils.logMessage('AppNexus outstream video loaded event'), + ended: () => { + utils.logMessage('AppNexus outstream renderer video event'); + document.querySelector(`#${adUnitCode}`).style.display = 'none'; } + }); + return renderer; +} - parsed.tags.forEach(tag => { - const ad = getRtbBid(tag); - const cpm = ad && ad.cpm; - const type = ad && ad.ad_type; +/* Turn keywords parameter into ut-compatible format */ +function getKeywords(keywords) { + let arrs = []; - let status; - if (cpm !== 0 && (SUPPORTED_AD_TYPES.includes(type))) { - status = STATUS.GOOD; + utils._each(keywords, (v, k) => { + if (utils.isArray(v)) { + let values = []; + utils._each(v, (val) => { + val = utils.getValueString('keywords.' + k, val); + if (val) { values.push(val); } + }); + v = values; + } else { + v = utils.getValueString('keywords.' + k, v); + if (utils.isStr(v)) { + v = [v]; } else { - status = STATUS.NO_BID; - } + return; + } // unsuported types - don't send a key + } + arrs.push({key: k, value: v}); + }); - if (type && (!SUPPORTED_AD_TYPES.includes(type))) { - utils.logError(`${type} ad type not supported`); - } + return arrs; +} - tag.bidId = tag.uuid; // bidfactory looks for bidId on requested bid - const bid = createBid(status, tag); - if (type === 'native') bid.mediaType = 'native'; - if (type === 'video') bid.mediaType = 'video'; - if (ad && ad.renderer_url) bid.mediaType = 'video-outstream'; +/** + * Unpack the Server's Bid into a Prebid-compatible one. + * @param serverBid + * @param rtbBid + * @return Bid + */ +function newBid(serverBid, rtbBid) { + const bid = { + requestId: serverBid.uuid, + cpm: rtbBid.cpm, + creative_id: rtbBid.creative_id, + dealId: rtbBid.deal_id, + }; - if (bid.adId in bidRequests) { - const placement = bidRequests[bid.adId].placementCode; - bidmanager.addBidResponse(placement, bid); - } + if (rtbBid.rtb.video) { + Object.assign(bid, { + width: rtbBid.rtb.video.player_width, + height: rtbBid.rtb.video.player_height, + vastUrl: rtbBid.rtb.video.asset_url, + descriptionUrl: rtbBid.rtb.video.asset_url }); - - if (!usersync) { - const iframe = utils.createInvisibleIframe(); - iframe.src = '//acdn.adnxs.com/ib/static/usersync/v3/async_usersync.html'; - try { - document.body.appendChild(iframe); - } catch (error) { - utils.logError(error); - } - usersync = true; + // This supports Outstream Video + if (rtbBid.renderer_url) { + Object.assign(bid, { + adResponse: serverBid, + renderer: newRenderer(bid.adUnitCode, rtbBid) + }); + bid.adResponse.ad = bid.adResponse.ads[0]; + bid.adResponse.ad.video = bid.adResponse.ad.rtb.video; } - } - - /* Check that a bid has required paramters */ - function valid(bid) { - if (bid.params.placementId || (bid.params.member && bid.params.invCode)) { - return bid; - } else { - utils.logError('bid requires placementId or (member and invCode) params'); + } else if (rtbBid.rtb.native) { + const native = rtbBid.rtb.native; + bid.native = { + title: native.title, + body: native.desc, + cta: native.ctatext, + sponsoredBy: native.sponsored, + image: native.main_img && native.main_img.url, + icon: native.icon && native.icon.url, + clickUrl: native.link.url, + impressionTrackers: native.impression_trackers, + }; + } else { + Object.assign(bid, { + width: rtbBid.rtb.banner.width, + height: rtbBid.rtb.banner.height, + ad: rtbBid.rtb.banner.content + }); + try { + const url = rtbBid.rtb.trackers[0].impression_urls[0]; + const tracker = utils.createTrackPixelHtml(url); + bid.ad += tracker; + } catch (error) { + utils.logError('Error appending tracking pixel', error); } } - /* Turn keywords parameter into ut-compatible format */ - function getKeywords(keywords) { - let arrs = []; - - utils._each(keywords, (v, k) => { - if (utils.isArray(v)) { - let values = []; - utils._each(v, (val) => { - val = utils.getValueString('keywords.' + k, val); - if (val) { values.push(val); } - }); - v = values; - } else { - v = utils.getValueString('keywords.' + k, v); - if (utils.isStr(v)) { v = [v]; } else { return; } // unsuported types - don't send a key - } - arrs.push({key: k, value: v}); - }); - - return arrs; + return bid; +} +function bidToTag(bid) { + const tag = {}; + tag.sizes = transformSizes(bid.sizes); + tag.primary_size = tag.sizes[0]; + tag.uuid = bid.bidId; + if (bid.params.placementId) { + tag.id = parseInt(bid.params.placementId, 10); + } else { + tag.code = bid.params.invCode; + } + tag.allow_smaller_sizes = bid.params.allowSmallerSizes || false; + tag.prebid = true; + tag.disable_psa = true; + if (bid.params.reserve) { + tag.reserve = bid.params.reserve; + } + if (bid.params.position) { + tag.position = {'above': 1, 'below': 2}[bid.params.position] || 0; + } + if (bid.params.trafficSourceCode) { + tag.traffic_source_code = bid.params.trafficSourceCode; + } + if (bid.params.privateSizes) { + tag.private_sizes = getSizes(bid.params.privateSizes); + } + if (bid.params.supplyType) { + tag.supply_type = bid.params.supplyType; + } + if (bid.params.pubClick) { + tag.pubclick = bid.params.pubClick; + } + if (bid.params.extInvCode) { + tag.ext_inv_code = bid.params.extInvCode; + } + if (bid.params.externalImpId) { + tag.external_imp_id = bid.params.externalImpId; + } + if (!utils.isEmpty(bid.params.keywords)) { + tag.keywords = getKeywords(bid.params.keywords); } - /* Turn bid request sizes into ut-compatible format */ - function getSizes(requestSizes) { - let sizes = []; - let sizeObj = {}; + if (bid.mediaType === 'native') { + tag.ad_types = ['native']; + + if (bid.nativeParams) { + const nativeRequest = {}; + + // map standard prebid native asset identifier to /ut parameters + // e.g., tag specifies `body` but /ut only knows `description` + // mapping may be in form {tag: ''} or + // {tag: {serverName: '', serverParams: {...}}} + Object.keys(bid.nativeParams).forEach(key => { + // check if one of the forms is used, otherwise + // a mapping wasn't specified so pass the key straight through + const requestKey = + (NATIVE_MAPPING[key] && NATIVE_MAPPING[key].serverName) || + NATIVE_MAPPING[key] || + key; + + // if the mapping for this identifier specifies required server + // params via the `serverParams` object, merge that in + nativeRequest[requestKey] = Object.assign({}, + NATIVE_MAPPING[key] && NATIVE_MAPPING[key].serverParams, + bid.nativeParams[key] + ); + }); - if (utils.isArray(requestSizes) && requestSizes.length === 2 && - !utils.isArray(requestSizes[0])) { - sizeObj.width = parseInt(requestSizes[0], 10); - sizeObj.height = parseInt(requestSizes[1], 10); - sizes.push(sizeObj); - } else if (typeof requestSizes === 'object') { - for (let i = 0; i < requestSizes.length; i++) { - let size = requestSizes[i]; - sizeObj = {}; - sizeObj.width = parseInt(size[0], 10); - sizeObj.height = parseInt(size[1], 10); - sizes.push(sizeObj); - } + tag.native = {layouts: [nativeRequest]}; } - - return sizes; } - function getRtbBid(tag) { - return tag && tag.ads && tag.ads.length && tag.ads.find(ad => ad.rtb); + if (bid.mediaType === 'video') { tag.require_asset_url = true; } + if (bid.params.video) { + tag.video = {}; + // place any valid video params on the tag + Object.keys(bid.params.video) + .filter(param => VIDEO_TARGETING.includes(param)) + .forEach(param => tag.video[param] = bid.params.video[param]); } - function outstreamRender(bid) { - // push to render queue because ANOutstreamVideo may not be loaded yet - bid.renderer.push(() => { - window.ANOutstreamVideo.renderAd({ - tagId: bid.adResponse.tag_id, - sizes: [bid.getSize().split('x')], - targetId: bid.adUnitCode, // target div id to render video - uuid: bid.adResponse.uuid, - adResponse: bid.adResponse, - rendererOptions: bid.renderer.getConfig() - }, handleOutstreamRendererEvents.bind(bid)); - }); - } + return tag; +} - function handleOutstreamRendererEvents(id, eventName) { - const bid = this; - bid.renderer.handleVideoEvent({ id, eventName }); +/* Turn bid request sizes into ut-compatible format */ +function transformSizes(requestSizes) { + let sizes = []; + let sizeObj = {}; + + if (utils.isArray(requestSizes) && requestSizes.length === 2 && + !utils.isArray(requestSizes[0])) { + sizeObj.width = parseInt(requestSizes[0], 10); + sizeObj.height = parseInt(requestSizes[1], 10); + sizes.push(sizeObj); + } else if (typeof requestSizes === 'object') { + for (let i = 0; i < requestSizes.length; i++) { + let size = requestSizes[i]; + sizeObj = {}; + sizeObj.width = parseInt(size[0], 10); + sizeObj.height = parseInt(size[1], 10); + sizes.push(sizeObj); + } } - /* Create and return a bid object based on status and tag */ - function createBid(status, tag) { - const ad = getRtbBid(tag); - let bid = bidfactory.createBid(status, tag); - bid.code = baseAdapter.getBidderCode(); - bid.bidderCode = baseAdapter.getBidderCode(); - - if (ad && status === STATUS.GOOD) { - bid.cpm = ad.cpm; - bid.creative_id = ad.creative_id; - bid.dealId = ad.deal_id; - - if (ad.rtb.video) { - bid.width = ad.rtb.video.player_width; - bid.height = ad.rtb.video.player_height; - bid.vastUrl = ad.rtb.video.asset_url; - bid.descriptionUrl = ad.rtb.video.asset_url; - if (ad.renderer_url) { - // outstream video - - bid.adResponse = tag; - bid.renderer = Renderer.install({ - id: ad.renderer_id, - url: ad.renderer_url, - config: { adText: `AppNexus Outstream Video Ad via Prebid.js` }, - loaded: false, - }); - try { - bid.renderer.setRender(outstreamRender); - } catch (err) { - utils.logWarning('Prebid Error calling setRender on renderer', err); - } - - bid.renderer.setEventHandlers({ - impression: () => utils.logMessage('AppNexus outstream video impression event'), - loaded: () => utils.logMessage('AppNexus outstream video loaded event'), - ended: () => { - utils.logMessage('AppNexus outstream renderer video event'); - document.querySelector(`#${bid.adUnitCode}`).style.display = 'none'; - } - }); - - bid.adResponse.ad = bid.adResponse.ads[0]; - bid.adResponse.ad.video = bid.adResponse.ad.rtb.video; - } - } else if (ad.rtb.native) { - const native = ad.rtb.native; - bid.native = { - title: native.title, - body: native.desc, - cta: native.ctatext, - sponsoredBy: native.sponsored, - image: native.main_img && native.main_img.url, - icon: native.icon && native.icon.url, - clickUrl: native.link.url, - impressionTrackers: native.impression_trackers, - }; - } else { - bid.width = ad.rtb.banner.width; - bid.height = ad.rtb.banner.height; - bid.ad = ad.rtb.banner.content; - try { - const url = ad.rtb.trackers[0].impression_urls[0]; - const tracker = utils.createTrackPixelHtml(url); - bid.ad += tracker; - } catch (error) { - utils.logError('Error appending tracking pixel', error); - } - } - } + return sizes; +} - return bid; - } +function hasUserInfo(bid) { + return !!bid.params.user; +} + +function hasMemberId(bid) { + return !!parseInt(bid.params.member, 10); +} - return Object.assign(this, { - callBids: baseAdapter.callBids, - setBidderCode: baseAdapter.setBidderCode, +function getRtbBid(tag) { + return tag && tag.ads && tag.ads.length && tag.ads.find(ad => ad.rtb); +} + +function outstreamRender(bid) { + // push to render queue because ANOutstreamVideo may not be loaded yet + bid.renderer.push(() => { + window.ANOutstreamVideo.renderAd({ + tagId: bid.adResponse.tag_id, + sizes: [bid.getSize().split('x')], + targetId: bid.adUnitCode, // target div id to render video + uuid: bid.adResponse.uuid, + adResponse: bid.adResponse, + rendererOptions: bid.renderer.getConfig() + }, handleOutstreamRendererEvents.bind(null, bid)); }); } -adaptermanager.registerBidAdapter(new AppnexusAstAdapter(), 'appnexusAst', { - supportedMediaTypes: ['video', 'native'] -}); +function handleOutstreamRendererEvents(bid, id, eventName) { + bid.renderer.handleVideoEvent({ id, eventName }); +} + +function parseMediaType(rtbBid) { + const adType = rtbBid.ad_type; + if (rtbBid.renderer_url) { + return 'video-outstream'; + } else if (adType === 'video') { + return 'video'; + } else if (adType === 'native') { + return 'native'; + } else { + return 'banner'; + } +} -module.exports = AppnexusAstAdapter; +registerBidder(spec); diff --git a/src/Renderer.js b/src/Renderer.js index c532b164fea3..3ef0be5ae4d3 100644 --- a/src/Renderer.js +++ b/src/Renderer.js @@ -1,6 +1,14 @@ import { loadScript } from './adloader'; import * as utils from './utils'; +/** + * @typedef {object} Renderer + * + * A Renderer stores some functions which are used to render a particular Bid. + * These are used in Outstream Video Bids, returned on the Bid by the adapter, and will + * be used to render that bid unless the Publisher overrides them. + */ + export function Renderer(options) { const { url, config, id, callback, loaded } = options; this.url = url; diff --git a/src/adapters/bidderFactory.js b/src/adapters/bidderFactory.js new file mode 100644 index 000000000000..7c9860095d9c --- /dev/null +++ b/src/adapters/bidderFactory.js @@ -0,0 +1,302 @@ +import Adapter from 'src/adapter'; +import adaptermanager from 'src/adaptermanager'; +import { ajax } from 'src/ajax'; +import bidmanager from 'src/bidmanager'; +import bidfactory from 'src/bidfactory'; +import { STATUS } from 'src/constants'; + +import { logWarn, logError, parseQueryStringParameters, delayExecution } from 'src/utils'; + +/** + * This file aims to support Adapters during the Prebid 0.x -> 1.x transition. + * + * Prebid 1.x and Prebid 0.x will be in separate branches--perhaps for a long time. + * This function defines an API for adapter construction which is compatible with both versions. + * Adapters which use it can maintain their code in master, and only this file will need to change + * in the 1.x branch. + * + * Typical usage looks something like: + * + * const adapter = registerBidder({ + * code: 'myBidderCode', + * aliases: ['alias1', 'alias2'], + * supportedMediaTypes: ['video', 'native'], + * areParamsValid: function(paramsObject) { return true/false }, + * buildRequests: function(bidRequests) { return some ServerRequest(s) }, + * interpretResponse: function(oneServerResponse) { return some Bids, or throw an error. } + * }); + * + * @see BidderSpec for the full API and more thorough descriptions. + */ + +/** + * @typedef {object} BidderSpec An object containing the adapter-specific functions needed to + * make a Bidder. + * + * @property {string} code A code which will be used to uniquely identify this bidder. This should be the same + * one as is used in the call to registerBidAdapter + * @property {string[]} [aliases] A list of aliases which should also resolve to this bidder. + * @property {MediaType[]} [supportedMediaTypes]: A list of Media Types which the adapter supports. + * @property {function(object): boolean} areParamsValid Determines whether or not the given object has all the params + * needed to make a valid request. + * @property {function(BidRequest[]): ServerRequest|ServerRequest[]} buildRequests Build the request to the Server which + * requests Bids for the given array of Requests. Each BidRequest in the argument array is guaranteed to have + * a "params" property which has passed the areParamsValid() test + * @property {function(*): Bid[]} interpretResponse Given a successful response from the Server, interpret it + * and return the Bid objects. This function will be run inside a try/catch. If it throws any errors, your + * bids will be discarded. + * @property {function(SyncOptions, Array): UserSync[]} [getUserSyncs] Given an array of all the responses + * from the server, determine which user syncs should occur. The argument array will contain every element + * which has been sent through to interpretResponse. The order of syncs in this array matters. The most + * important ones should come first, since publishers may limit how many are dropped on their page. + */ + +/** + * @typedef {object} BidRequest + * + * @property {string} bidId A string which uniquely identifies this BidRequest in the current Auction. + * @property {object} params Any bidder-specific params which the publisher used in their bid request. + * This is guaranteed to have passed the spec.areParamsValid() test. + */ + +/** + * @typedef {object} ServerRequest + * + * @property {('GET'|'POST')} method The type of request which this is. + * @property {string} url The endpoint for the request. For example, "//bids.example.com". + * @property {string|object} data Data to be sent in the request. + * If this is a GET request, they'll become query params. If it's a POST request, they'll be added to the body. + * Strings will be added as-is. Objects will be unpacked into query params based on key/value mappings, or + * JSON-serialized into the Request body. + */ + +/** + * @typedef {object} Bid + * + * @property {string} requestId The specific BidRequest which this bid is aimed at. + * This should correspond to one of the + * @property {string} ad A URL which can be used to load this ad, if it's chosen by the publisher. + * @property {number} cpm The bid price, in US cents per thousand impressions. + * @property {number} height The height of the ad, in pixels. + * @property {number} width The width of the ad, in pixels. + * + * @property [Renderer] renderer A Renderer which can be used as a default for this bid, + * if the publisher doesn't override it. This is only relevant for Outstream Video bids. + */ + +/** + * @typedef {Object} SyncOptions + * + * An object containing information about usersyncs which the adapter should obey. + * + * @property {boolean} iframeEnabled True if iframe usersyncs are allowed, and false otherwise + * @property {boolean} pixelEnabled True if image usersyncs are allowed, and false otherwise + */ + +/** + * TODO: Move this to the UserSync module after that PR is merged. + * + * @typedef {object} UserSync + * + * @property {('image'|'iframe')} type The type of user sync to be done. + * @property {string} url The URL which makes the sync happen. + */ + +/** + * Register a bidder with prebid, using the given spec. + * + * If possible, Adapter modules should use this function instead of adaptermanager.registerBidAdapter(). + * + * @param {BidderSpec} spec An object containing the bare-bones functions we need to make a Bidder. + */ +export function registerBidder(spec) { + const mediaTypes = Array.isArray(spec.supportedMediaTypes) + ? { supportedMediaTypes: spec.supportedMediaTypes } + : undefined; + function putBidder(spec) { + const bidder = newBidder(spec); + adaptermanager.registerBidAdapter(bidder, spec.code, mediaTypes); + } + + putBidder(spec); + if (Array.isArray(spec.aliases)) { + spec.aliases.forEach(alias => { + putBidder(Object.assign({}, spec, { code: alias })); + }); + } +} + +/** + * Make a new bidder from the given spec. This is exported mainly for testing. + * Adapters will probably find it more convenient to use registerBidder instead. + * + * @param {BidderSpec} spec + */ +export function newBidder(spec) { + return Object.assign(new Adapter(spec.code), { + callBids: function(bidderRequest) { + if (!Array.isArray(bidderRequest.bids)) { + return; + } + + // callBids must add a NO_BID response for _every_ AdUnit code, in order for the auction to + // end properly. This map stores placement codes which we've made _real_ bids on. + // + // As we add _real_ bids to the bidmanager, we'll log the ad unit codes here too. Once all the real + // bids have been added, fillNoBids() can be called to add NO_BID bids for any extra ad units, which + // will end the auction. + // + // In Prebid 1.0, this will be simplified to use the `addBidResponse` and `done` callbacks. + const adUnitCodesHandled = {}; + function addBidWithCode(adUnitCode, bid) { + adUnitCodesHandled[adUnitCode] = true; + bidmanager.addBidResponse(adUnitCode, bid); + } + function fillNoBids() { + bidderRequest.bids + .map(bidRequest => bidRequest.placementCode) + .forEach(adUnitCode => { + if (adUnitCode && !adUnitCodesHandled[adUnitCode]) { + bidmanager.addBidResponse(adUnitCode, newEmptyBid()); + } + }); + } + + const bidRequests = bidderRequest.bids.filter(filterAndWarn); + if (bidRequests.length === 0) { + fillNoBids(); + return; + } + const bidRequestMap = {}; + bidRequests.forEach(bid => { + bidRequestMap[bid.bidId] = bid; + }); + + let requests = spec.buildRequests(bidRequests); + if (!requests || requests.length === 0) { + fillNoBids(); + return; + } + if (!Array.isArray(requests)) { + requests = [requests]; + } + + // After all the responses have come back, fill up the "no bid" bids and + // register any required usersync pixels. + const responses = []; + function afterAllResponses() { + fillNoBids(); + + if (spec.getUserSyncs) { + // TODO: Before merge, replace this empty object with the real config values. + // Then register them with the UserSync pool. This is waiting on the UserSync PR + // to be merged first, though. + spec.getUserSyncs({ }, responses); + } + } + + // Callbacks don't compose as nicely as Promises. We should call fillNoBids() once _all_ the + // Server requests have returned and been processed. Since `ajax` accepts a single callback, + // we need to rig up a function which only executes after all the requests have been responded. + const onResponse = delayExecution(afterAllResponses, requests.length) + requests.forEach(processRequest); + + function processRequest(request) { + switch (request.method) { + case 'GET': + ajax( + `${request.url}?${parseQueryStringParameters(request.data)}`, + { + success: onSuccess, + error: onFailure + }, + undefined, + { + method: 'GET', + withCredentials: true + } + ); + break; + case 'POST': + ajax( + request.url, + { + success: onSuccess, + error: onFailure + }, + typeof request.data === 'string' ? request.data : JSON.stringify(request.data), + { + method: 'POST', + contentType: 'text/plain', + withCredentials: true + } + ); + break; + default: + logWarn(`Skipping invalid request from ${spec.code}. Request type ${request.type} must be GET or POST`); + onResponse(); + } + } + + // If the server responds successfully, use the adapter code to unpack the Bids from it. + // If the adapter code fails, no bids should be added. After all the bids have been added, make + // sure to call the `onResponse` function so that we're one step closer to calling fillNoBids(). + function onSuccess(response) { + try { + response = JSON.parse(response); + } catch (e) { /* response might not be JSON... that's ok. */ } + responses.push(response); + + let bids; + try { + bids = spec.interpretResponse(response); + } catch (err) { + logError(`Bidder ${spec.code} failed to interpret the server's response. Continuing without bids`, null, err); + onResponse(); + return; + } + + if (bids) { + if (bids.forEach) { + bids.forEach(addBidUsingRequestMap); + } else { + addBidUsingRequestMap(bids); + } + } + onResponse(); + + function addBidUsingRequestMap(bid) { + const bidRequest = bidRequestMap[bid.requestId]; + if (bidRequest) { + const prebidBid = Object.assign(bidfactory.createBid(STATUS.GOOD, bidRequest), bid); + addBidWithCode(bidRequest.placementCode, prebidBid); + } else { + logWarn(`Bidder ${spec.code} made bid for unknown request ID: ${bid.requestId}. Ignoring.`); + } + } + } + + // If the server responds with an error, there's not much we can do. Log it, and make sure to + // call onResponse() so that we're one step closer to calling fillNoBids(). + function onFailure(err) { + logError(`Server call for ${spec.code} failed: ${err}. Continuing without bids.`); + onResponse(); + } + } + }); + + function filterAndWarn(bid) { + if (!spec.areParamsValid(bid.params)) { + logWarn(`Invalid bid sent to bidder ${spec.code}: ${JSON.stringify(bid)}`); + return false; + } + return true; + } + + function newEmptyBid() { + const bid = bidfactory.createBid(STATUS.NO_BID); + bid.code = spec.code; + bid.bidderCode = spec.code; + return bid; + } +} diff --git a/src/mediaTypes.js b/src/mediaTypes.js new file mode 100644 index 000000000000..7a40030e4e28 --- /dev/null +++ b/src/mediaTypes.js @@ -0,0 +1,17 @@ +/** + * This file contains the valid Media Types in Prebid. + * + * All adapters are assumed to support banner ads. Other media types are specified by Adapters when they + * register themselves with prebid-core. + */ + +/** + * @typedef {('native'|'video'|'banner')} MediaType + */ + +/** @type MediaType */ +export const NATIVE = 'native'; +/** @type MediaType */ +export const VIDEO = 'video'; +/** @type MediaType */ +export const BANNER = 'banner'; diff --git a/src/utils.js b/src/utils.js index 0c1846b18cec..ed2497346611 100644 --- a/src/utils.js +++ b/src/utils.js @@ -666,7 +666,31 @@ export function getBidderRequest(bidder, adUnitCode) { } /** + * Given a function, return a function which only executes the original after + * it's been called numRequiredCalls times. * + * Note that the arguments from the previous calls will *not* be forwarded to the original function. + * Only the final call's arguments matter. + * + * @param {function} func The function which should be executed, once the returned function has been executed + * numRequiredCalls times. + * @param {int} numRequiredCalls The number of times which the returned function needs to be called before + * func is. + */ +export function delayExecution(func, numRequiredCalls) { + if (numRequiredCalls < 1) { + throw new Error(`numRequiredCalls must be a positive number. Got ${numRequiredCalls}`); + } + let numCalls = 0; + return function () { + numCalls++; + if (numCalls === numRequiredCalls) { + func.apply(null, arguments); + } + } +} + +/** * https://stackoverflow.com/a/34890276/428704 * @export * @param {array} xs diff --git a/test/spec/modules/appnexusAstBidAdapter_spec.js b/test/spec/modules/appnexusAstBidAdapter_spec.js index 3756c2a6651d..eea8c55882b7 100644 --- a/test/spec/modules/appnexusAstBidAdapter_spec.js +++ b/test/spec/modules/appnexusAstBidAdapter_spec.js @@ -1,5 +1,6 @@ import { expect } from 'chai'; -import Adapter from 'modules/appnexusAstBidAdapter'; +import { spec } from 'modules/appnexusAstBidAdapter'; +import { newBidder } from 'src/adapters/bidderFactory'; import bidmanager from 'src/bidmanager'; const ENDPOINT = '//ib.adnxs.com/ut/v3/prebid'; @@ -61,9 +62,7 @@ const RESPONSE = { }; describe('AppNexusAdapter', () => { - let adapter; - - beforeEach(() => adapter = new Adapter()); + const adapter = newBidder(spec); describe('request function', () => { let xhr; diff --git a/test/spec/unit/core/bidderFactory_spec.js b/test/spec/unit/core/bidderFactory_spec.js new file mode 100644 index 000000000000..6a819d53d41d --- /dev/null +++ b/test/spec/unit/core/bidderFactory_spec.js @@ -0,0 +1,410 @@ +import { newBidder, registerBidder } from 'src/adapters/bidderFactory'; +import bidmanager from 'src/bidmanager'; +import adaptermanager from 'src/adaptermanager'; +import * as ajax from 'src/ajax'; +import { expect } from 'chai'; +import { STATUS } from 'src/constants'; + +const CODE = 'sampleBidder'; +const MOCK_BIDS_REQUEST = { + bids: [ + { + requestId: 'first-bid-id', + placementCode: 'mock/placement', + params: { + param: 5 + } + }, + { + requestId: 'second-bid-id', + placementCode: 'mock/placement2', + params: { + badParam: 6 + } + } + ] +} + +describe('bidders created by newBidder', () => { + let spec; + let addBidRequestStub; + let bidder; + + beforeEach(() => { + spec = { + code: CODE, + areParamsValid: sinon.stub(), + buildRequests: sinon.stub(), + interpretResponse: sinon.stub(), + getUserSyncs: sinon.stub() + }; + addBidRequestStub = sinon.stub(bidmanager, 'addBidResponse'); + }); + + afterEach(() => { + addBidRequestStub.restore(); + }); + + describe('when the ajax response is irrelevant', () => { + let ajaxStub; + + beforeEach(() => { + ajaxStub = sinon.stub(ajax, 'ajax'); + }); + + afterEach(() => { + ajaxStub.restore(); + }); + + it('should handle bad bid requests gracefully', () => { + const bidder = newBidder(spec); + + bidder.callBids({}); + bidder.callBids({ bids: 'nothing useful' }); + + expect(ajaxStub.called).to.equal(false); + expect(spec.areParamsValid.called).to.equal(false); + expect(spec.buildRequests.called).to.equal(false); + expect(spec.interpretResponse.called).to.equal(false); + }); + + it('should call buildRequests(bidRequest) the params are valid', () => { + const bidder = newBidder(spec); + + spec.areParamsValid.returns(true); + spec.buildRequests.returns([]); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(ajaxStub.called).to.equal(false); + expect(spec.areParamsValid.calledTwice).to.equal(true); + expect(spec.buildRequests.calledOnce).to.equal(true); + expect(spec.buildRequests.firstCall.args[0]).to.deep.equal(MOCK_BIDS_REQUEST.bids); + }); + + it('should not call buildRequests the params are invalid', () => { + const bidder = newBidder(spec); + + spec.areParamsValid.returns(false); + spec.buildRequests.returns([]); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(ajaxStub.called).to.equal(false); + expect(spec.areParamsValid.calledTwice).to.equal(true); + expect(spec.buildRequests.called).to.equal(false); + }); + + it('should filter out invalid bids before calling buildRequests', () => { + const bidder = newBidder(spec); + + spec.areParamsValid.onFirstCall().returns(true); + spec.areParamsValid.onSecondCall().returns(false); + spec.buildRequests.returns([]); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(ajaxStub.called).to.equal(false); + expect(spec.areParamsValid.calledTwice).to.equal(true); + expect(spec.buildRequests.calledOnce).to.equal(true); + expect(spec.buildRequests.firstCall.args[0]).to.deep.equal([MOCK_BIDS_REQUEST.bids[0]]); + }); + + it("should make no server requests if the spec doesn't return any", () => { + const bidder = newBidder(spec); + + spec.areParamsValid.returns(true); + spec.buildRequests.returns([]); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(ajaxStub.called).to.equal(false); + }); + + it('should make the appropriate POST request', () => { + const bidder = newBidder(spec); + const url = 'test.url.com'; + const data = { arg: 2 }; + spec.areParamsValid.returns(true); + spec.buildRequests.returns({ + method: 'POST', + url: url, + data: data + }); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(ajaxStub.calledOnce).to.equal(true); + expect(ajaxStub.firstCall.args[0]).to.equal(url); + expect(ajaxStub.firstCall.args[2]).to.equal(JSON.stringify(data)); + expect(ajaxStub.firstCall.args[3]).to.deep.equal({ + method: 'POST', + contentType: 'text/plain', + withCredentials: true + }); + }); + + it('should make the appropriate GET request', () => { + const bidder = newBidder(spec); + const url = 'test.url.com'; + const data = { arg: 2 }; + spec.areParamsValid.returns(true); + spec.buildRequests.returns({ + method: 'GET', + url: url, + data: data + }); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(ajaxStub.calledOnce).to.equal(true); + expect(ajaxStub.firstCall.args[0]).to.equal(`${url}?arg=2&`); + expect(ajaxStub.firstCall.args[2]).to.be.undefined; + expect(ajaxStub.firstCall.args[3]).to.deep.equal({ + method: 'GET', + withCredentials: true + }); + }); + + it('should make multiple calls if the spec returns them', () => { + const bidder = newBidder(spec); + const url = 'test.url.com'; + const data = { arg: 2 }; + spec.areParamsValid.returns(true); + spec.buildRequests.returns([ + { + method: 'POST', + url: url, + data: data + }, + { + method: 'GET', + url: url, + data: data + } + ]); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(ajaxStub.calledTwice).to.equal(true); + }); + }); + + describe('when the ajax call succeeds', () => { + let ajaxStub; + + beforeEach(() => { + ajaxStub = sinon.stub(ajax, 'ajax', function(url, callbacks) { + callbacks.success('response body'); + }); + }); + + afterEach(() => { + ajaxStub.restore(); + }); + + it('should call spec.interpretResponse() with the response body content', () => { + const bidder = newBidder(spec); + + spec.areParamsValid.returns(true); + spec.buildRequests.returns({ + method: 'POST', + url: 'test.url.com', + data: {} + }); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(spec.interpretResponse.calledOnce).to.equal(true); + expect(spec.interpretResponse.firstCall.args[0]).to.equal('response body'); + }); + + it('should call spec.interpretResponse() once for each request made', () => { + const bidder = newBidder(spec); + + spec.areParamsValid.returns(true); + spec.buildRequests.returns([ + { + method: 'POST', + url: 'test.url.com', + data: {} + }, + { + method: 'POST', + url: 'test.url.com', + data: {} + }, + ]); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(spec.interpretResponse.calledTwice).to.equal(true); + }); + + it("should add bids for each placement code into the bidmanager, even if the bidder doesn't bid on all of them", () => { + const bidder = newBidder(spec); + + const bid = { + requestId: 'some-id', + ad: 'ad-url.com', + cpm: 0.5, + height: 200, + width: 300, + placementCode: 'mock/placement' + }; + spec.areParamsValid.returns(true); + spec.buildRequests.returns({ + method: 'POST', + url: 'test.url.com', + data: {} + }); + spec.interpretResponse.returns(bid); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(bidmanager.addBidResponse.calledTwice).to.equal(true); + const placementsWithBids = + [bidmanager.addBidResponse.firstCall.args[0], bidmanager.addBidResponse.secondCall.args[0]]; + expect(placementsWithBids).to.contain('mock/placement'); + expect(placementsWithBids).to.contain('mock/placement2'); + }); + + it('should call spec.getUserSyncs() with the response', () => { + const bidder = newBidder(spec); + + spec.areParamsValid.returns(true); + spec.buildRequests.returns({ + method: 'POST', + url: 'test.url.com', + data: {} + }); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(spec.getUserSyncs.calledOnce).to.equal(true); + expect(spec.getUserSyncs.firstCall.args[1]).to.deep.equal(['response body']); + }); + }); + + describe('when the ajax call fails', () => { + let ajaxStub; + + beforeEach(() => { + ajaxStub = sinon.stub(ajax, 'ajax', function(url, callbacks) { + callbacks.error('ajax call failed.'); + }); + }); + + afterEach(() => { + ajaxStub.restore(); + }); + + it('should not spec.interpretResponse()', () => { + const bidder = newBidder(spec); + + spec.areParamsValid.returns(true); + spec.buildRequests.returns({ + method: 'POST', + url: 'test.url.com', + data: {} + }); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(spec.interpretResponse.called).to.equal(false); + }); + + it('should add bids for each placement code into the bidmanager', () => { + const bidder = newBidder(spec); + + spec.areParamsValid.returns(true); + spec.buildRequests.returns({ + method: 'POST', + url: 'test.url.com', + data: {} + }); + spec.interpretResponse.returns([]); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(bidmanager.addBidResponse.calledTwice).to.equal(true); + const placementsWithBids = + [bidmanager.addBidResponse.firstCall.args[0], bidmanager.addBidResponse.secondCall.args[0]]; + expect(placementsWithBids).to.contain('mock/placement'); + expect(placementsWithBids).to.contain('mock/placement2'); + }); + + it('should call spec.getUserSyncs() with no responses', () => { + const bidder = newBidder(spec); + + spec.areParamsValid.returns(true); + spec.buildRequests.returns({ + method: 'POST', + url: 'test.url.com', + data: {} + }); + + bidder.callBids(MOCK_BIDS_REQUEST); + + expect(spec.getUserSyncs.calledOnce).to.equal(true); + expect(spec.getUserSyncs.firstCall.args[1]).to.deep.equal([]); + }); + }); +}); + +describe('registerBidder', () => { + let registerBidAdapterStub; + let aliasBidAdapterStub; + + beforeEach(() => { + registerBidAdapterStub = sinon.stub(adaptermanager, 'registerBidAdapter'); + aliasBidAdapterStub = sinon.stub(adaptermanager, 'aliasBidAdapter'); + }); + + afterEach(() => { + registerBidAdapterStub.restore(); + aliasBidAdapterStub.restore(); + }); + + function newEmptySpec() { + return { + code: CODE, + areParamsValid: function() { }, + buildRequests: function() { }, + interpretResponse: function() { }, + }; + } + + it('should register a bidder with the adapterManager', () => { + registerBidder(newEmptySpec()); + expect(registerBidAdapterStub.calledOnce).to.equal(true); + expect(registerBidAdapterStub.firstCall.args[0]).to.have.property('callBids'); + expect(registerBidAdapterStub.firstCall.args[0].callBids).to.be.a('function'); + + expect(registerBidAdapterStub.firstCall.args[1]).to.equal(CODE); + expect(registerBidAdapterStub.firstCall.args[2]).to.be.undefined; + }); + + it('should register a bidder with the appropriate mediaTypes', () => { + const thisSpec = Object.assign(newEmptySpec(), { supportedMediaTypes: ['video'] }); + registerBidder(thisSpec); + expect(registerBidAdapterStub.calledOnce).to.equal(true); + expect(registerBidAdapterStub.firstCall.args[2]).to.deep.equal({supportedMediaTypes: ['video']}); + }); + + it('should register bidders with the appropriate aliases', () => { + const thisSpec = Object.assign(newEmptySpec(), { aliases: ['foo', 'bar'] }); + registerBidder(thisSpec); + + expect(registerBidAdapterStub.calledThrice).to.equal(true); + + // Make sure our later calls don't override the bidder code from previous calls. + expect(registerBidAdapterStub.firstCall.args[0].getBidderCode()).to.equal(CODE); + expect(registerBidAdapterStub.secondCall.args[0].getBidderCode()).to.equal('foo') + expect(registerBidAdapterStub.thirdCall.args[0].getBidderCode()).to.equal('bar') + + expect(registerBidAdapterStub.firstCall.args[1]).to.equal(CODE); + expect(registerBidAdapterStub.secondCall.args[1]).to.equal('foo') + expect(registerBidAdapterStub.thirdCall.args[1]).to.equal('bar') + }); +}) diff --git a/test/spec/utils_spec.js b/test/spec/utils_spec.js index e8cb041ebf23..b1837123449c 100755 --- a/test/spec/utils_spec.js +++ b/test/spec/utils_spec.js @@ -525,6 +525,20 @@ describe('Utils', function () { }); }); + describe('delayExecution', function () { + it('should execute the core function after the correct number of calls', function () { + const callback = sinon.spy(); + const delayed = utils.delayExecution(callback, 5); + for (let i = 0; i < 4; i++) { + delayed(); + } + assert(callback.notCalled); + delayed(3); + assert(callback.called) + assert.equal(callback.firstCall.args[0], 3); + }); + }); + describe('deepAccess', function() { var obj = { 1: 2,