From cae09ad7bb06932c2e53929b3c0a31398513cff3 Mon Sep 17 00:00:00 2001 From: Convergo <61470027+Converge-Digital@users.noreply.github.com> Date: Thu, 2 Apr 2020 18:35:53 +0300 Subject: [PATCH] Added new Converge Bid Adapter (#5053) * Added new Converge Bid Adapter * Fix JSDoc in Converge Bid Adapter --- modules/convergeBidAdapter.js | 313 +++++++ modules/convergeBidAdapter.md | 57 ++ test/spec/modules/convergeBidAdapter_spec.js | 899 +++++++++++++++++++ 3 files changed, 1269 insertions(+) create mode 100644 modules/convergeBidAdapter.js create mode 100644 modules/convergeBidAdapter.md create mode 100644 test/spec/modules/convergeBidAdapter_spec.js diff --git a/modules/convergeBidAdapter.js b/modules/convergeBidAdapter.js new file mode 100644 index 00000000000..bea3b6cb1ab --- /dev/null +++ b/modules/convergeBidAdapter.js @@ -0,0 +1,313 @@ +import * as utils from '../src/utils.js'; +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import { Renderer } from '../src/Renderer.js'; +import { VIDEO, BANNER } from '../src/mediaTypes.js'; + +const BIDDER_CODE = 'converge'; +const ENDPOINT_URL = 'https://tech.convergd.com/hb'; +const TIME_TO_LIVE = 360; +const SYNC_URL = 'https://tech.convergd.com/push_sync'; +const RENDERER_URL = 'https://acdn.adnxs.com/video/outstream/ANOutstreamVideo.js'; + +let hasSynced = false; + +const LOG_ERROR_MESS = { + noAuid: 'Bid from response has no auid parameter - ', + noAdm: 'Bid from response has no adm parameter - ', + noBid: 'Array of bid objects is empty', + noPlacementCode: "Can't find in requested bids the bid with auid - ", + emptyUids: 'Uids should be not empty', + emptySeatbid: 'Seatbid array from response has empty item', + emptyResponse: 'Response is empty', + hasEmptySeatbidArray: 'Response has empty seatbid array', + hasNoArrayOfBids: 'Seatbid from response has no array of bid objects - ' +}; +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [ BANNER, VIDEO ], + /** + * Determines whether or not the given bid request is valid. + * + * @param {BidRequest} bid The bid params to validate. + * @return boolean True if this is a valid bid, and false otherwise. + */ + isBidRequestValid: function(bid) { + return !!bid.params.uid; + }, + /** + * Make a server request from the list of BidRequests. + * + * @param {BidRequest[]} validBidRequests - an array of bids + * @param {bidderRequest} bidderRequest - bidder request object + * @return ServerRequest Info describing the request to the server. + */ + buildRequests: function(validBidRequests, bidderRequest) { + const auids = []; + const bidsMap = {}; + const slotsMapByUid = {}; + const sizeMap = {}; + const bids = validBidRequests || []; + let priceType = 'net'; + let pageKeywords; + let reqId; + + bids.forEach(bid => { + if (bid.params.priceType === 'gross') { + priceType = 'gross'; + } + reqId = bid.bidderRequestId; + const {params: {uid}, adUnitCode} = bid; + auids.push(uid); + const sizesId = utils.parseSizesInput(bid.sizes); + + if (!pageKeywords && !utils.isEmpty(bid.params.keywords)) { + const keywords = utils.transformBidderParamKeywords(bid.params.keywords); + + if (keywords.length > 0) { + keywords.forEach(deleteValues); + } + pageKeywords = keywords; + } + + if (!slotsMapByUid[uid]) { + slotsMapByUid[uid] = {}; + } + const slotsMap = slotsMapByUid[uid]; + if (!slotsMap[adUnitCode]) { + slotsMap[adUnitCode] = {adUnitCode, bids: [bid], parents: []}; + } else { + slotsMap[adUnitCode].bids.push(bid); + } + const slot = slotsMap[adUnitCode]; + + sizesId.forEach((sizeId) => { + sizeMap[sizeId] = true; + if (!bidsMap[uid]) { + bidsMap[uid] = {}; + } + + if (!bidsMap[uid][sizeId]) { + bidsMap[uid][sizeId] = [slot]; + } else { + bidsMap[uid][sizeId].push(slot); + } + slot.parents.push({parent: bidsMap[uid], key: sizeId, uid}); + }); + }); + + const payload = { + pt: priceType, + auids: auids.join(','), + sizes: utils.getKeys(sizeMap).join(','), + r: reqId, + wrapperType: 'Prebid_js', + wrapperVersion: '$prebid.version$' + }; + + if (pageKeywords) { + payload.keywords = JSON.stringify(pageKeywords); + } + + if (bidderRequest) { + if (bidderRequest.refererInfo && bidderRequest.refererInfo.referer) { + payload.u = bidderRequest.refererInfo.referer; + } + if (bidderRequest.timeout) { + payload.wtimeout = bidderRequest.timeout; + } + if (bidderRequest.gdprConsent) { + if (bidderRequest.gdprConsent.consentString) { + payload.gdpr_consent = bidderRequest.gdprConsent.consentString; + } + payload.gdpr_applies = + (typeof bidderRequest.gdprConsent.gdprApplies === 'boolean') + ? Number(bidderRequest.gdprConsent.gdprApplies) : 1; + } + if (bidderRequest.uspConsent) { + payload.us_privacy = bidderRequest.uspConsent; + } + } + + return { + method: 'GET', + url: ENDPOINT_URL, + data: utils.parseQueryStringParameters(payload).replace(/\&$/, ''), + bidsMap: bidsMap, + }; + }, + /** + * Unpack the response from the server into a list of bids. + * + * @param {*} serverResponse A successful response from the server. + * @param {*} bidRequest + * @param {Renderer} RendererConst + * @return {Bid[]} An array of bids which were nested inside the server. + */ + interpretResponse: function(serverResponse, bidRequest, RendererConst = Renderer) { + serverResponse = serverResponse && serverResponse.body; + const bidResponses = []; + const bidsMap = bidRequest.bidsMap; + const priceType = bidRequest.data.pt; + + let errorMessage; + + if (!serverResponse) errorMessage = LOG_ERROR_MESS.emptyResponse; + else if (serverResponse.seatbid && !serverResponse.seatbid.length) { + errorMessage = LOG_ERROR_MESS.hasEmptySeatbidArray; + } + + if (!errorMessage && serverResponse.seatbid) { + serverResponse.seatbid.forEach(respItem => { + _addBidResponse(_getBidFromResponse(respItem), bidsMap, priceType, bidResponses, RendererConst); + }); + } + if (errorMessage) utils.logError(errorMessage); + return bidResponses; + }, + getUserSyncs: function (syncOptions, responses, gdprConsent, uspConsent) { + if (!hasSynced && syncOptions.pixelEnabled) { + let params = ''; + + if (gdprConsent && typeof gdprConsent.consentString === 'string') { + if (typeof gdprConsent.gdprApplies === 'boolean') { + params += `&gdpr=${Number(gdprConsent.gdprApplies)}&gdpr_consent=${gdprConsent.consentString}`; + } else { + params += `&gdpr_consent=${gdprConsent.consentString}`; + } + } + if (uspConsent) { + params += `&us_privacy=${uspConsent}`; + } + + hasSynced = true; + return { + type: 'image', + url: SYNC_URL + params + }; + } + } +}; + +function isPopulatedArray(arr) { + return !!(utils.isArray(arr) && arr.length > 0); +} + +function deleteValues(keyPairObj) { + if (isPopulatedArray(keyPairObj.value) && keyPairObj.value[0] === '') { + delete keyPairObj.value; + } +} + +function _getBidFromResponse(respItem) { + if (!respItem) { + utils.logError(LOG_ERROR_MESS.emptySeatbid); + } else if (!respItem.bid) { + utils.logError(LOG_ERROR_MESS.hasNoArrayOfBids + JSON.stringify(respItem)); + } else if (!respItem.bid[0]) { + utils.logError(LOG_ERROR_MESS.noBid); + } + return respItem && respItem.bid && respItem.bid[0]; +} + +function _addBidResponse(serverBid, bidsMap, priceType, bidResponses, RendererConst) { + if (!serverBid) return; + let errorMessage; + if (!serverBid.auid) errorMessage = LOG_ERROR_MESS.noAuid + JSON.stringify(serverBid); + if (!serverBid.adm) errorMessage = LOG_ERROR_MESS.noAdm + JSON.stringify(serverBid); + else { + const awaitingBids = bidsMap[serverBid.auid]; + if (awaitingBids) { + const sizeId = `${serverBid.w}x${serverBid.h}`; + if (awaitingBids[sizeId]) { + const slot = awaitingBids[sizeId][0]; + + const bid = slot.bids.shift(); + const bidResponse = { + requestId: bid.bidId, // bid.bidderRequestId, + bidderCode: spec.code, + cpm: serverBid.price, + width: serverBid.w, + height: serverBid.h, + creativeId: serverBid.auid, // bid.bidId, + currency: 'EUR', + netRevenue: priceType !== 'gross', + ttl: TIME_TO_LIVE, + dealId: serverBid.dealid + }; + if (serverBid.content_type === 'video' || (!serverBid.content_type && bid.mediaTypes && bid.mediaTypes.video)) { + bidResponse.vastXml = serverBid.adm; + bidResponse.mediaType = VIDEO; + bidResponse.adResponse = { + content: bidResponse.vastXml + }; + if (!bid.renderer && (!bid.mediaTypes || !bid.mediaTypes.video || bid.mediaTypes.video.context === 'outstream')) { + bidResponse.renderer = createRenderer(bidResponse, { + id: bid.bidId, + url: RENDERER_URL + }, RendererConst); + } + } else { + bidResponse.ad = serverBid.adm; + bidResponse.mediaType = BANNER; + } + + bidResponses.push(bidResponse); + + if (!slot.bids.length) { + slot.parents.forEach(({parent, key, uid}) => { + const index = parent[key].indexOf(slot); + if (index > -1) { + parent[key].splice(index, 1); + } + if (!parent[key].length) { + delete parent[key]; + if (!utils.getKeys(parent).length) { + delete bidsMap[uid]; + } + } + }); + } + } + } else { + errorMessage = LOG_ERROR_MESS.noPlacementCode + serverBid.auid; + } + } + if (errorMessage) { + utils.logError(errorMessage); + } +} + +function outstreamRender (bid) { + bid.renderer.push(() => { + window.ANOutstreamVideo.renderAd({ + targetId: bid.adUnitCode, + adResponse: bid.adResponse + }); + }); +} + +function createRenderer (bid, rendererParams, RendererConst) { + const rendererInst = RendererConst.install({ + id: rendererParams.id, + url: rendererParams.url, + loaded: false + }); + + try { + rendererInst.setRender(outstreamRender); + } catch (err) { + utils.logWarn('Prebid Error calling setRender on renderer', err); + } + + return rendererInst; +} + +export function resetUserSync() { + hasSynced = false; +} + +export function getSyncUrl() { + return SYNC_URL; +} + +registerBidder(spec); diff --git a/modules/convergeBidAdapter.md b/modules/convergeBidAdapter.md new file mode 100644 index 00000000000..ab916a8b3b6 --- /dev/null +++ b/modules/convergeBidAdapter.md @@ -0,0 +1,57 @@ +# Overview + +Module Name: Converge Bidder Adapter +Module Type: Bidder Adapter +Maintainer: support@converge-digital.com + +# Description + +Module that connects to Converge demand source to fetch bids. +Converge Bid Adapter supports Banner and Video (instream and outstream). + +# Test Parameters +``` + var adUnits = [ + { + code: 'test-div', + sizes: [[300, 250]], + bids: [ + { + bidder: "converge", + params: { + uid: '59', + priceType: 'gross' // by default is 'net' + } + } + ] + },{ + code: 'test-div', + sizes: [[728, 90]], + bids: [ + { + bidder: "converge", + params: { + uid: 1, + priceType: 'gross', + keywords: { + brandsafety: ['disaster'], + topic: ['stress', 'fear'] + } + } + } + ] + },{ + code: 'test-div', + sizes: [[640, 360]], + mediaTypes: { video: {} }, + bids: [ + { + bidder: "converge", + params: { + uid: 60 + } + } + ] + } + ]; +``` diff --git a/test/spec/modules/convergeBidAdapter_spec.js b/test/spec/modules/convergeBidAdapter_spec.js new file mode 100644 index 00000000000..e92ed475497 --- /dev/null +++ b/test/spec/modules/convergeBidAdapter_spec.js @@ -0,0 +1,899 @@ +import { expect } from 'chai'; +import { spec, resetUserSync, getSyncUrl } from 'modules/convergeBidAdapter.js'; +import { newBidder } from 'src/adapters/bidderFactory.js'; + +describe('ConvergeAdapter', function () { + const adapter = newBidder(spec); + + describe('inherited functions', function () { + it('exists and is a function', function () { + expect(adapter.callBids).to.exist.and.to.be.a('function'); + }); + }); + + describe('isBidRequestValid', function () { + let bid = { + 'bidder': 'converge', + 'params': { + 'uid': '1' + }, + 'adUnitCode': 'adunit-code', + 'sizes': [[300, 250], [300, 600]], + 'bidId': '30b31c1838de1e', + 'bidderRequestId': '22edbae2733bf6', + 'auctionId': '1d1a030790a475', + }; + + it('should return true when required params found', function () { + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return false when required params are not passed', function () { + let bid = Object.assign({}, bid); + delete bid.params; + bid.params = { + 'uid': 0 + }; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + }); + + describe('buildRequests', function () { + function parseRequest(url) { + const res = {}; + url.split('&').forEach((it) => { + const couple = it.split('='); + res[couple[0]] = decodeURIComponent(couple[1]); + }); + return res; + } + + const bidderRequest = { + refererInfo: { + referer: 'https://example.com' + } + }; + const referrer = bidderRequest.refererInfo.referer; + + let bidRequests = [ + { + 'bidder': 'converge', + 'params': { + 'uid': '59' + }, + 'adUnitCode': 'adunit-code-1', + 'sizes': [[300, 250], [300, 600]], + 'bidId': '30b31c1838de1e', + 'bidderRequestId': '22edbae2733bf6', + 'auctionId': '1d1a030790a475', + }, + { + 'bidder': 'converge', + 'params': { + 'uid': '59' + }, + 'adUnitCode': 'adunit-code-2', + 'sizes': [[728, 90], [300, 250]], + 'bidId': '3150ccb55da321', + 'bidderRequestId': '22edbae2733bf6', + 'auctionId': '1d1a030790a475', + }, + { + 'bidder': 'converge', + 'params': { + 'uid': '60' + }, + 'adUnitCode': 'adunit-code-1', + 'sizes': [[300, 250], [300, 600]], + 'bidId': '42dbe3a7168a6a', + 'bidderRequestId': '22edbae2733bf6', + 'auctionId': '1d1a030790a475', + } + ]; + + it('should attach valid params to the tag', function () { + const request = spec.buildRequests([bidRequests[0]], bidderRequest); + expect(request.data).to.be.an('string'); + const payload = parseRequest(request.data); + expect(payload).to.have.property('u', referrer); + expect(payload).to.have.property('pt', 'net'); + expect(payload).to.have.property('auids', '59'); + expect(payload).to.have.property('sizes', '300x250,300x600'); + expect(payload).to.have.property('r', '22edbae2733bf6'); + expect(payload).to.have.property('wrapperType', 'Prebid_js'); + expect(payload).to.have.property('wrapperVersion', '$prebid.version$'); + }); + + it('sizes must not be duplicated', function () { + const request = spec.buildRequests(bidRequests, bidderRequest); + expect(request.data).to.be.an('string'); + const payload = parseRequest(request.data); + expect(payload).to.have.property('u', referrer); + expect(payload).to.have.property('pt', 'net'); + expect(payload).to.have.property('auids', '59,59,60'); + expect(payload).to.have.property('sizes', '300x250,300x600,728x90'); + expect(payload).to.have.property('r', '22edbae2733bf6'); + }); + + it('pt parameter must be "gross" if params.priceType === "gross"', function () { + bidRequests[1].params.priceType = 'gross'; + const request = spec.buildRequests(bidRequests, bidderRequest); + expect(request.data).to.be.an('string'); + const payload = parseRequest(request.data); + expect(payload).to.have.property('u', referrer); + expect(payload).to.have.property('pt', 'gross'); + expect(payload).to.have.property('auids', '59,59,60'); + expect(payload).to.have.property('sizes', '300x250,300x600,728x90'); + expect(payload).to.have.property('r', '22edbae2733bf6'); + delete bidRequests[1].params.priceType; + }); + + it('pt parameter must be "net" or "gross"', function () { + bidRequests[1].params.priceType = 'some'; + const request = spec.buildRequests(bidRequests, bidderRequest); + expect(request.data).to.be.an('string'); + const payload = parseRequest(request.data); + expect(payload).to.have.property('u', referrer); + expect(payload).to.have.property('pt', 'net'); + expect(payload).to.have.property('auids', '59,59,60'); + expect(payload).to.have.property('sizes', '300x250,300x600,728x90'); + expect(payload).to.have.property('r', '22edbae2733bf6'); + delete bidRequests[1].params.priceType; + }); + + it('if gdprConsent is present payload must have gdpr params', function () { + const bidderRequestWithGDPR = Object.assign({gdprConsent: {consentString: 'AAA', gdprApplies: true}}, bidderRequest); + const request = spec.buildRequests(bidRequests, bidderRequestWithGDPR); + expect(request.data).to.be.an('string'); + const payload = parseRequest(request.data); + expect(payload).to.have.property('gdpr_consent', 'AAA'); + expect(payload).to.have.property('gdpr_applies', '1'); + }); + + it('if gdprApplies is false gdpr_applies must be 0', function () { + const bidderRequestWithGDPR = Object.assign({gdprConsent: {consentString: 'AAA', gdprApplies: false}}, bidderRequest); + const request = spec.buildRequests(bidRequests, bidderRequestWithGDPR); + expect(request.data).to.be.an('string'); + const payload = parseRequest(request.data); + expect(payload).to.have.property('gdpr_consent', 'AAA'); + expect(payload).to.have.property('gdpr_applies', '0'); + }); + + it('if gdprApplies is undefined gdpr_applies must be 1', function () { + const bidderRequestWithGDPR = Object.assign({gdprConsent: {consentString: 'AAA'}}, bidderRequest); + const request = spec.buildRequests(bidRequests, bidderRequestWithGDPR); + expect(request.data).to.be.an('string'); + const payload = parseRequest(request.data); + expect(payload).to.have.property('gdpr_consent', 'AAA'); + expect(payload).to.have.property('gdpr_applies', '1'); + }); + + it('if usPrivacy is present payload must have us_privacy param', function () { + const bidderRequestWithUSP = Object.assign({uspConsent: '1YNN'}, bidderRequest); + const request = spec.buildRequests(bidRequests, bidderRequestWithUSP); + expect(request.data).to.be.an('string'); + const payload = parseRequest(request.data); + expect(payload).to.have.property('us_privacy', '1YNN'); + }); + + it('should convert keyword params to proper form and attaches to request', function () { + const bidRequestWithKeywords = [].concat(bidRequests); + bidRequestWithKeywords[1] = Object.assign({}, + bidRequests[1], + { + params: { + uid: '59', + keywords: { + single: 'val', + singleArr: ['val'], + singleArrNum: [5], + multiValMixed: ['value1', 2, 'value3'], + singleValNum: 123, + emptyStr: '', + emptyArr: [''], + badValue: {'foo': 'bar'} // should be dropped + } + } + } + ); + + const request = spec.buildRequests(bidRequestWithKeywords, bidderRequest); + expect(request.data).to.be.an('string'); + const payload = parseRequest(request.data); + expect(payload.keywords).to.be.an('string'); + payload.keywords = JSON.parse(payload.keywords); + + expect(payload.keywords).to.deep.equal([{ + 'key': 'single', + 'value': ['val'] + }, { + 'key': 'singleArr', + 'value': ['val'] + }, { + 'key': 'singleArrNum', + 'value': ['5'] + }, { + 'key': 'multiValMixed', + 'value': ['value1', '2', 'value3'] + }, { + 'key': 'singleValNum', + 'value': ['123'] + }, { + 'key': 'emptyStr' + }, { + 'key': 'emptyArr' + }]); + }); + }); + + describe('interpretResponse', function () { + const responses = [ + {'bid': [{'price': 1.15, 'adm': '