diff --git a/modules/setupadBidAdapter.js b/modules/setupadBidAdapter.js new file mode 100644 index 00000000000..9e9dc4f99a3 --- /dev/null +++ b/modules/setupadBidAdapter.js @@ -0,0 +1,424 @@ +import { + _each, + createTrackPixelHtml, + deepAccess, + isStr, + getBidIdParameter, + triggerPixel, + logWarn, +} from '../src/utils.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { getRefererInfo } from '../src/refererDetection.js'; +import { config } from '../src/config.js'; +import * as events from '../src/events.js'; +import { BANNER } from '../src/mediaTypes.js'; +import CONSTANTS from '../src/constants.json'; + +const BIDDER_CODE = 'setupad'; +const ENDPOINT = 'https://prebid.setupad.io/openrtb2/auction'; +const SYNC_ENDPOINT = 'https://cookie.stpd.cloud/sync?'; +const REPORT_ENDPOINT = 'https://adapter-analytics.azurewebsites.net/api/adapter-analytics'; +const GVLID = 1241; +const TIME_TO_LIVE = 360; +const allBidders = {}; + +const sendingDataStatistic = initSendingDataStatistic(); +events.on(CONSTANTS.EVENTS.AUCTION_INIT, () => { + sendingDataStatistic.initEvents(); +}); + +function getEids(bidRequest) { + if (deepAccess(bidRequest, 'userIdAsEids')) return bidRequest.userIdAsEids; +} + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER], + gvlid: GVLID, + + isBidRequestValid: function (bid) { + return !!(bid.params.placement_id && isStr(bid.params.placement_id)); + }, + + buildRequests: function (validBidRequests, bidderRequest) { + const requests = []; + window.nmmRefreshCounts = window.nmmRefreshCounts || {}; + _each(validBidRequests, function (bid) { + window.nmmRefreshCounts[bid.adUnitCode] = window.nmmRefreshCounts[bid.adUnitCode] || 0; + const id = getBidIdParameter('placement_id', bid.params); + const accountId = getBidIdParameter('account_id', bid.params); + const auctionId = bid.auctionId; + const bidId = bid.bidId; + const eids = getEids(bid) || []; + let sizes = bid.sizes; + if (sizes && !Array.isArray(sizes[0])) sizes = [sizes]; + + const site = getSiteObj(); + const device = getDeviceObj(); + + const payload = { + id: bid?.bidderRequestId, + ext: { + prebid: { + storedrequest: { + id: accountId || 'default', + }, + }, + + setupad: { + refresh_count: window.nmmRefreshCounts[bid.adUnitCode]++, + elOffsets: getBoundingClient(bid), + scrollTop: window.pageYOffset || document.documentElement.scrollTop, + }, + }, + user: { ext: { eids } }, + device, + site, + imp: [], + test: 1, + }; + + const imp = { + id: bid.adUnitCode, + ext: { + prebid: { + storedrequest: { id }, + }, + }, + }; + + if (deepAccess(bid, 'mediaTypes.banner')) { + imp.banner = { + format: (sizes || []).map((s) => { + return { w: s[0], h: s[1] }; + }), + }; + } + + payload.imp.push(imp); + + const gdprConsent = bidderRequest && bidderRequest.gdprConsent; + const uspConsent = bidderRequest && bidderRequest.uspConsent; + + if (gdprConsent || uspConsent) { + payload.regs = { ext: {} }; + + if (uspConsent) payload.regs.ext.us_privacy = uspConsent; + + if (gdprConsent) { + if (typeof gdprConsent.gdprApplies !== 'undefined') { + payload.regs.ext.gdpr = gdprConsent.gdprApplies ? 1 : 0; + } + + if (typeof gdprConsent.consentString !== 'undefined') { + payload.user.ext.consent = gdprConsent.consentString; + } + } + } + const params = bid.params; + + requests.push({ + method: 'POST', + url: ENDPOINT, + data: JSON.stringify(payload), + options: { + contentType: 'text/plain', + withCredentials: true, + }, + + bidId, + params, + auctionId, + }); + }); + + return requests; + }, + + interpretResponse: function (serverResponse, bidRequest) { + if ( + !serverResponse || + !serverResponse.body || + typeof serverResponse.body != 'object' || + Object.keys(serverResponse.body).length === 0 + ) { + logWarn('no response or body is malformed'); + return []; + } + + const serverBody = serverResponse.body; + const bidResponses = []; + + _each(serverBody.seatbid, (res) => { + _each(res.bid, (bid) => { + const requestId = bidRequest.bidId; + const params = bidRequest.params; + const { ad, adUrl } = getAd(bid); + + const bidResponse = { + requestId, + params, + cpm: bid.price, + width: bid.w, + height: bid.h, + creativeId: bid.id, + currency: serverBody.cur, + netRevenue: true, + ttl: TIME_TO_LIVE, + meta: { + advertiserDomains: bid.adomain || [], + }, + }; + + // Set all bidders obj for later use in getPixelUrl() + allBidders[res.seat] = {}; + allBidders[res.seat].cpm = bidResponse.cpm; + allBidders[res.seat].currency = bidResponse.currency; + allBidders[res.seat].creativeId = bidResponse.creativeId; + + bidResponse.ad = ad; + bidResponse.adUrl = adUrl; + bidResponses.push(bidResponse); + }); + }); + + return bidResponses; + }, + + getUserSyncs: function (syncOptions, responses, gdprConsent, uspConsent) { + if (!responses?.length) return []; + + const syncs = []; + const bidders = getBidders(responses); + + if (syncOptions.iframeEnabled && bidders) { + const queryParams = []; + + queryParams.push(`bidders=${bidders}`); + queryParams.push('gdpr=' + +gdprConsent.gdprApplies); + queryParams.push('gdpr_consent=' + gdprConsent.consentString); + queryParams.push('usp_consent=' + (uspConsent || '')); + + const strQueryParams = queryParams.join('&'); + + syncs.push({ + type: 'iframe', + url: SYNC_ENDPOINT + strQueryParams + '&type=iframe', + }); + + return syncs; + } + + return []; + }, + + getPixelUrl: function (eventName, bid, timestamp) { + let bidder = bid.bidder || bid.bidderCode; + const auctionId = bid.auctionId; + if (bidder != BIDDER_CODE) return; + + let params; + if (bid.params) { + params = Array.isArray(bid.params) ? bid.params : [bid.params]; + } else { + if (Array.isArray(bid.bids)) { + params = bid.bids.map((singleBid) => singleBid.params); + } + } + + if (!params.length) return; + + const placementIdsArray = []; + params.forEach((param) => { + if (!param.placement_id) return; + placementIdsArray.push(param.placement_id); + }); + + const placementIds = (placementIdsArray.length && placementIdsArray.join(';')) || ''; + + if (!placementIds) return; + + let extraBidParams = ''; + // additional params on bidWon + if (eventName === 'bidWon') { + extraBidParams = `&cpm=${bid.originalCpm}¤cy=${bid.originalCurrency}`; + } + + if (eventName === 'bidResponse') { + // Exclude not needed creativeId key for bidResponse bidders + const filteredBidders = Object.fromEntries( + Object.entries(allBidders).map(([bidderKey, bidderObj]) => [ + bidderKey, + Object.fromEntries(Object.entries(bidderObj).filter(([key]) => key !== 'creativeId')), + ]) + ); + bidder = JSON.stringify(filteredBidders); + } else if (eventName === 'bidWon') { + // Iterate through all bidders to find the winning bidder by using creativeId as identification + for (const bidderName in allBidders) { + if ( + allBidders.hasOwnProperty(bidderName) && + allBidders[bidderName].creativeId === bid.creativeId + ) { + bidder = bidderName; + break; // Exit the loop once a match is found + } + } + } + + const url = `${REPORT_ENDPOINT}?event=${eventName}&bidder=${bidder}&placementIds=${placementIds}&auctionId=${auctionId}${extraBidParams}×tamp=${timestamp}`; + + return url; + }, +}; + +function getBidders(serverResponse) { + const bidders = serverResponse + .map((res) => Object.keys(res.body.ext.responsetimemillis || [])) + .flat(1); + + if (bidders.length) { + return encodeURIComponent(JSON.stringify([...new Set(bidders)])); + } +} + +function getAdEl(bid) { + // best way I could think of to get El, is by matching adUnitCode to google slots... + const slot = + window.googletag && + window.googletag.pubads && + window.googletag + .pubads() + .getSlots() + .find((slot) => slot.getAdUnitPath() === bid.adUnitCode); + const slotElementId = slot && slot.getSlotElementId(); + if (!slotElementId) return null; + return document.querySelector('#' + slotElementId); +} + +function getBoundingClient(bid) { + const el = getAdEl(bid); + if (!el) return {}; + return el.getBoundingClientRect(); +} + +function getAd(bid) { + let ad, adUrl, vastXml, vastUrl; + + switch (deepAccess(bid, 'ext.prebid.type')) { + default: + if (bid.adm && bid.nurl) { + ad = bid.adm; + ad += createTrackPixelHtml(decodeURIComponent(bid.nurl)); + } else if (bid.adm) { + ad = bid.adm; + } else if (bid.nurl) { + adUrl = bid.nurl; + } + } + + return { ad, adUrl, vastXml, vastUrl }; +} + +function getSiteObj() { + const refInfo = (getRefererInfo && getRefererInfo()) || {}; + + return { + page: refInfo.page, + ref: refInfo.ref, + domain: refInfo.domain, + }; +} + +function getDeviceObj() { + return { + w: + window.innerWidth || + window.document.documentElement.clientWidth || + window.document.body.clientWidth || + 0, + h: + window.innerHeight || + window.document.documentElement.clientHeight || + window.document.body.clientHeight || + 0, + }; +} + +function initSendingDataStatistic() { + class SendingDataStatistic { + eventNames = [ + CONSTANTS.EVENTS.BID_TIMEOUT, + CONSTANTS.EVENTS.BID_RESPONSE, + CONSTANTS.EVENTS.BID_REQUESTED, + CONSTANTS.EVENTS.NO_BID, + CONSTANTS.EVENTS.BID_WON, + ]; + + disabledSending = false; + enabledSending = false; + eventHendlers = {}; + + initEvents() { + this.disabledSending = !!config.getBidderConfig()?.setupad?.disabledSendingStatisticData; + if (this.disabledSending) { + this.removeEvents(); + } else { + this.createEvents(); + } + } + + createEvents() { + if (this.enabledSending) return; + + this.enabledSending = true; + for (let eventName of this.eventNames) { + if (!this.eventHendlers[eventName]) { + this.eventHendlers[eventName] = this.eventHandler(eventName); + } + + events.on(eventName, this.eventHendlers[eventName]); + } + } + + removeEvents() { + if (!this.enabledSending) return; + + this.enabledSending = false; + for (let eventName of this.eventNames) { + if (!this.eventHendlers[eventName]) continue; + + events.off(eventName, this.eventHendlers[eventName]); + } + } + + eventHandler(eventName) { + const eventHandlerFunc = this.getEventHandler(eventName); + if (eventName == CONSTANTS.EVENTS.BID_TIMEOUT) { + return (bids) => { + if (this.disabledSending || !Array.isArray(bids)) return; + + for (let bid of bids) { + eventHandlerFunc(bid); + } + }; + } + + return eventHandlerFunc; + } + + getEventHandler(eventName) { + return (bid) => { + if (this.disabledSending) return; + + const url = spec.getPixelUrl(eventName, bid, Date.now()); + if (!url) return; + triggerPixel(url); + }; + } + } + + return new SendingDataStatistic(); +} + +registerBidder(spec); diff --git a/modules/setupadBidAdapter.md b/modules/setupadBidAdapter.md new file mode 100644 index 00000000000..d9b72f05c0f --- /dev/null +++ b/modules/setupadBidAdapter.md @@ -0,0 +1,35 @@ +# Overview + +``` +Module Name: Setupad Bid Adapter +Module Type: Bidder Adapter +Maintainer: it@setupad.com +``` + +# Description + +Module that connects to Setupad's demand sources. + +# Test Parameters + +```js +const adUnits = [ + { + code: 'test-div', + mediaTypes: { + banner: { + sizes: [[300, 250]], + }, + }, + bids: [ + { + bidder: 'setupad', + params: { + placement_id: '123', //required + account_id: '123', //optional + }, + }, + ], + }, +]; +``` diff --git a/test/spec/modules/setupadBidAdapter_spec.js b/test/spec/modules/setupadBidAdapter_spec.js new file mode 100644 index 00000000000..ad1f870bb53 --- /dev/null +++ b/test/spec/modules/setupadBidAdapter_spec.js @@ -0,0 +1,403 @@ +import { spec } from 'modules/setupadBidAdapter.js'; + +describe('SetupadAdapter', function () { + const userIdAsEids = [ + { + source: 'pubcid.org', + uids: [ + { + atype: 1, + id: '01EAJWWNEPN3CYMM5N8M5VXY22', + }, + ], + }, + ]; + + const bidRequests = [ + { + adUnitCode: 'test-div', + auctionId: 'b06c5141-fe8f-4cdf-9d7d-54415490a917', + bidId: '22c4871113f461', + bidder: 'rubicon', + bidderRequestId: '15246a574e859f', + uspConsent: 'usp-context-string', + gdprConsent: { + consentString: 'BOtmiBKOtmiBKABABAENAFAAAAACeAAA', + gdprApplies: true, + }, + params: { + placement_id: '123', + account_id: 'test-account-id', + }, + sizes: [[300, 250]], + ortb2: { + device: { + w: 1500, + h: 1000, + }, + site: { + domain: 'test.com', + page: 'http://test.com', + }, + }, + userIdAsEids, + }, + ]; + + const serverResponse = { + body: { + id: 'f7b3d2da-e762-410c-b069-424f92c4c4b2', + seatbid: [ + { + bid: [ + { + id: 'test-bid-id', + price: 0.8, + adm: 'this is an ad', + adid: 'test-ad-id', + adomain: ['test.addomain.com'], + w: 300, + h: 250, + }, + ], + }, + ], + cur: 'USD', + ext: { + sync: { + image: ['urlA?gdpr={{.GDPR}}'], + iframe: ['urlB'], + }, + }, + }, + }; + + describe('isBidRequestValid', function () { + const bid = { + bidder: 'setupad', + params: { + placement_id: '123', + }, + }; + 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 () { + delete bid.params.placement_id; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + }); + + describe('buildRequests', function () { + it('check request params with GDPR and USP', function () { + const request = spec.buildRequests(bidRequests, bidRequests[0]); + expect(JSON.parse(request[0].data).user.ext.consent).to.equal( + 'BOtmiBKOtmiBKABABAENAFAAAAACeAAA' + ); + expect(JSON.parse(request[0].data).regs.ext.gdpr).to.equal(1); + expect(JSON.parse(request[0].data).regs.ext.us_privacy).to.equal('usp-context-string'); + }); + + it('check request params without GDPR', function () { + let bidRequestsWithoutGDPR = Object.assign({}, bidRequests[0]); + delete bidRequestsWithoutGDPR.gdprConsent; + const request = spec.buildRequests([bidRequestsWithoutGDPR], bidRequestsWithoutGDPR); + expect(JSON.parse(request[0].data).regs.ext.gdpr).to.be.undefined; + expect(JSON.parse(request[0].data).regs.ext.us_privacy).to.equal('usp-context-string'); + }); + + it('should return correct storedrequest id if account_id is provided', function () { + const request = spec.buildRequests(bidRequests, bidRequests[0]); + expect(JSON.parse(request[0].data).ext.prebid.storedrequest.id).to.equal('test-account-id'); + }); + + it('should return correct storedrequest id if account_id is not provided', function () { + let bidRequestsWithoutAccountId = Object.assign({}, bidRequests[0]); + delete bidRequestsWithoutAccountId.params.account_id; + const request = spec.buildRequests( + [bidRequestsWithoutAccountId], + bidRequestsWithoutAccountId + ); + expect(JSON.parse(request[0].data).ext.prebid.storedrequest.id).to.equal('default'); + }); + + it('validate generated params', function () { + const request = spec.buildRequests(bidRequests); + expect(request[0].bidId).to.equal('22c4871113f461'); + expect(JSON.parse(request[0].data).id).to.equal('15246a574e859f'); + }); + + it('check if domain was added', function () { + const request = spec.buildRequests(bidRequests); + expect(JSON.parse(request[0].data).site.domain).to.exist; + }); + + it('check if elOffsets was added', function () { + const request = spec.buildRequests(bidRequests); + expect(JSON.parse(request[0].data).ext.setupad.elOffsets).to.be.an('object'); + }); + + it('check if imp object was added', function () { + const request = spec.buildRequests(bidRequests); + expect(JSON.parse(request[0].data).imp).to.be.an('array'); + }); + + it('should send "user.ext.eids" in the request for Prebid.js supported modules only', function () { + const request = spec.buildRequests(bidRequests); + expect(JSON.parse(request[0].data).user.ext.eids).to.deep.equal(userIdAsEids); + }); + + it('should send an empty "user.ext.eids" array in the request if userId module is unsupported', function () { + let bidRequestsUnsupportedUserIdModule = Object.assign({}, bidRequests[0]); + delete bidRequestsUnsupportedUserIdModule.userIdAsEids; + const request = spec.buildRequests(bidRequestsUnsupportedUserIdModule); + + expect(JSON.parse(request[0].data).user.ext.eids).to.be.empty; + }); + }); + + describe('getUserSyncs', () => { + it('should return user sync', () => { + const syncOptions = { + iframeEnabled: true, + pixelEnabled: true, + }; + const responses = [ + { + body: { + ext: { + responsetimemillis: { + 'test seat 1': 2, + 'test seat 2': 1, + }, + }, + }, + }, + ]; + const gdprConsent = { + gdprApplies: 1, + consentString: 'dkj49Sjmfjuj34as:12jaf90123hufabidfy9u23brfpoig', + }; + const uspConsent = 'mkjvbiniwot4827obfoy8sdg8203gb'; + const expectedUserSyncs = [ + { + type: 'iframe', + url: 'https://cookie.stpd.cloud/sync?bidders=%5B%22test%20seat%201%22%2C%22test%20seat%202%22%5D&gdpr=1&gdpr_consent=dkj49Sjmfjuj34as:12jaf90123hufabidfy9u23brfpoig&usp_consent=mkjvbiniwot4827obfoy8sdg8203gb&type=iframe', + }, + ]; + + const userSyncs = spec.getUserSyncs(syncOptions, responses, gdprConsent, uspConsent); + + expect(userSyncs).to.deep.equal(expectedUserSyncs); + }); + + it('should return empty user syncs when responsetimemillis is not defined', () => { + const syncOptions = { + iframeEnabled: true, + pixelEnabled: true, + }; + const responses = [ + { + body: { + ext: {}, + }, + }, + ]; + const gdprConsent = { + gdprApplies: 1, + consentString: 'dkj49Sjmfjuj34as:12jaf90123hufabidfy9u23brfpoig', + }; + const uspConsent = 'mkjvbiniwot4827obfoy8sdg8203gb'; + const expectedUserSyncs = []; + + const userSyncs = spec.getUserSyncs(syncOptions, responses, gdprConsent, uspConsent); + + expect(userSyncs).to.deep.equal(expectedUserSyncs); + }); + }); + + describe('interpretResponse', function () { + it('should return empty array if error during parsing', () => { + const wrongServerResponse = 'wrong data'; + let request = spec.buildRequests(bidRequests, bidRequests[0]); + let result = spec.interpretResponse(wrongServerResponse, request); + + expect(result).to.be.instanceof(Array); + expect(result.length).to.equal(0); + }); + + it('should get correct bid response', function () { + const result = spec.interpretResponse(serverResponse, bidRequests[0]); + expect(result).to.be.an('array').with.lengthOf(1); + expect(result[0].requestId).to.equal('22c4871113f461'); + expect(result[0].cpm).to.equal(0.8); + expect(result[0].width).to.equal(300); + expect(result[0].height).to.equal(250); + expect(result[0].creativeId).to.equal('test-bid-id'); + expect(result[0].currency).to.equal('USD'); + expect(result[0].netRevenue).to.equal(true); + expect(result[0].ttl).to.equal(360); + expect(result[0].ad).to.equal('this is an ad'); + }); + }); + + describe('getPixelUrl', function () { + const REPORT_ENDPOINT = 'https://adapter-analytics.azurewebsites.net/api/adapter-analytics'; + const mockData = [ + { + timestamp: 123456789, + eventName: 'bidRequested', + bid: { + auctionId: 'test-auction-id', + bidderCode: 'appnexus', + bids: [{ bidder: 'appnexus', params: {} }], + }, + + expected: undefined, + }, + + { + timestamp: 123456789, + eventName: 'bidRequested', + bid: { + auctionId: 'test-auction-id', + bidderCode: 'appnexus', + bids: [{ bidder: 'appnexus', params: { account_id: 'test' } }], + }, + + expected: undefined, + }, + + { + timestamp: 123456789, + eventName: 'bidRequested', + bid: { + auctionId: 'test-auction-id', + bidderCode: 'appnexus', + bids: [{ bidder: 'appnexus', params: { placement_id: '123' } }], + }, + + expected: undefined, + }, + + { + timestamp: 123456789, + eventName: 'bidRequested', + bid: { + auctionId: 'test-auction-id', + bidderCode: 'setupad', + bids: [{ bidder: 'setupad', params: { placement_id: '123' } }], + }, + + expected: `${REPORT_ENDPOINT}?event=bidRequested&bidder=setupad&placementIds=123&auctionId=test-auction-id×tamp=123456789`, + }, + + { + timestamp: 123456789, + eventName: 'bidRequested', + bid: { + auctionId: 'test-auction-id', + bidderCode: 'setupad', + bids: [ + { bidder: 'setupad', params: { placement_id: '123' } }, + { bidder: 'setupad', params: { placement_id: '321' } }, + ], + }, + + expected: `${REPORT_ENDPOINT}?event=bidRequested&bidder=setupad&placementIds=123;321&auctionId=test-auction-id×tamp=123456789`, + }, + + { + timestamp: 123456789, + eventName: 'bidResponse', + bid: { + auctionId: 'test-auction-id', + bidderCode: 'appnexus', + }, + + expected: undefined, + }, + + { + timestamp: 123456789, + eventName: 'bidResponse', + bid: { + auctionId: 'test-auction-id', + bidderCode: 'setupad', + originalCpm: 0.8, + originalCurrency: 'USD', + params: { placement_id: '123' }, + }, + + expected: `${REPORT_ENDPOINT}?event=bidResponse&bidder=setupad&placementIds=123&auctionId=test-auction-id&cpm=0.8¤cy=USD×tamp=123456789`, + }, + + { + timestamp: 123456789, + eventName: 'noBid', + bid: { + auctionId: 'test-auction-id', + bidder: 'appnexus', + }, + + expected: undefined, + }, + + { + timestamp: 123456789, + eventName: 'noBid', + bid: { + auctionId: 'test-auction-id', + bidder: 'setupad', + params: { placement_id: '123' }, + }, + + expected: `${REPORT_ENDPOINT}?event=noBid&bidder=setupad&placementIds=123&auctionId=test-auction-id×tamp=123456789`, + }, + + { + timestamp: 123456789, + eventName: 'bidTimeout', + bid: { + auctionId: 'test-auction-id', + bidder: 'appnexus', + }, + + expected: undefined, + }, + + { + timestamp: 123456789, + eventName: 'bidTimeout', + bid: { + auctionId: 'test-auction-id', + bidder: 'setupad', + params: { placement_id: '123' }, + }, + + expected: `${REPORT_ENDPOINT}?event=bidTimeout&bidder=setupad&placementIds=123&auctionId=test-auction-id×tamp=123456789`, + }, + + { + timestamp: 123456789, + eventName: 'bidWon', + bid: { + auctionId: 'test-auction-id', + bidder: 'setupad', + originalCpm: 0.8, + originalCurrency: 'USD', + params: { placement_id: '123', account_id: 'test' }, + }, + + expected: `${REPORT_ENDPOINT}?event=bidWon&bidder=setupad&placementIds=123&auctionId=test-auction-id&cpm=0.8¤cy=USD×tamp=123456789`, + }, + ]; + + it('should return correct url', function () { + mockData.forEach(({ eventName, bid, timestamp, expected }) => { + const url = spec.getPixelUrl(eventName, bid, timestamp); + expect(url).to.equal(expected); + }); + }); + }); +});