diff --git a/integrationExamples/gpt/1plusXRtdProviderExample.html b/integrationExamples/gpt/1plusXRtdProviderExample.html index 4da659fb0c4..615e952e576 100644 --- a/integrationExamples/gpt/1plusXRtdProviderExample.html +++ b/integrationExamples/gpt/1plusXRtdProviderExample.html @@ -48,6 +48,7 @@ waitForIt: true, params: { customerId: 'acme', + bidders: ['appnexus'], timeout: 1000 } diff --git a/modules/1plusXRtdProvider.js b/modules/1plusXRtdProvider.js index b9acc5a6e52..40bbf1d2e5a 100644 --- a/modules/1plusXRtdProvider.js +++ b/modules/1plusXRtdProvider.js @@ -1,26 +1,64 @@ -import { submodule } from '../src/hook.js' -import { ajax } from '../src/ajax.js' -import { logMessage, logError, deepAccess, isNumber } from '../src/utils.js'; +import { submodule } from '../src/hook.js'; +import { config } from '../src/config.js'; +import { ajax } from '../src/ajax.js'; +import { + logMessage, logError, + deepAccess, mergeDeep, + isNumber, isArray, deepSetValue +} from '../src/utils.js'; // Constants const REAL_TIME_MODULE = 'realTimeData'; const MODULE_NAME = '1plusX'; const PAPI_VERSION = 'v1.0'; +const SUPPORTED_BIDDERS = ['appnexus', 'rubicon'] // Functions -const extractConfig = (config) => { +/** + * Extracts the parameters for 1plusX RTD module from the config object passed at instanciation + * @param {Object} moduleConfig Config object passed to the module + * @param {Object} reqBidsConfigObj Config object for the bidders; each adapter has its own entry + * @returns + */ +const extractConfig = (moduleConfig, reqBidsConfigObj) => { // CustomerId - const customerId = deepAccess(config, 'params.customerId'); + const customerId = deepAccess(moduleConfig, 'params.customerId'); if (!customerId) { throw new Error('REQUIRED CUSTOMER ID'); } // Timeout - const tempTimeout = deepAccess(config, 'params.timeout'); + const tempTimeout = deepAccess(moduleConfig, 'params.timeout'); const timeout = isNumber(tempTimeout) && tempTimeout > 300 ? tempTimeout : 1000; - return { customerId, timeout }; + // Bidders + const biddersTemp = deepAccess(moduleConfig, 'params.bidders'); + if (!isArray(biddersTemp) || !biddersTemp.length) { + throw new Error('REQUIRED BIDDERS IN SUBMODULE CONFIG'); + } + + const adUnitBidders = reqBidsConfigObj.adUnits + .flatMap(({ bids }) => bids.map(({ bidder }) => bidder)) + .filter((e, i, a) => a.indexOf(e) === i); + if (!isArray(adUnitBidders) || !adUnitBidders.length) { + throw new Error('REQUIRED BIDDERS IN BID REQUEST CONFIG'); + } + + const bidders = biddersTemp.filter( + bidder => + SUPPORTED_BIDDERS.includes(bidder) && adUnitBidders.includes(bidder) + ); + if (!bidders.length) { + throw new Error('NO SUPPORTED BIDDER FOUND IN SUBMODULE/ BID REQUEST CONFIG'); + } + + return { customerId, timeout, bidders }; } +/** + * Gets the URL of Profile Api from which targeting data will be fetched + * @param {*} param0 + * @returns + */ const getPapiUrl = ({ customerId }) => { logMessage('GET PAPI URL'); // https://[yourClientId].profiles.tagger.opecloud.com/[VERSION]/targeting?url= @@ -29,6 +67,11 @@ const getPapiUrl = ({ customerId }) => { return papiUrl; } +/** + * Fetches targeting data. It contains the audience segments & the contextual topics + * @param {string} papiUrl URL of profile API + * @returns + */ const getTargetingDataFromPapi = (papiUrl) => { return new Promise((resolve, reject) => { const requestOptions = { @@ -38,7 +81,7 @@ const getTargetingDataFromPapi = (papiUrl) => { } const callbacks = { success(responseText, response) { - logMessage("Say it has been successful"); + logMessage('Say it has been successful'); resolve(JSON.parse(response.response)); }, error(errorText, error) { @@ -51,26 +94,102 @@ const getTargetingDataFromPapi = (papiUrl) => { }) } +/** + * Prepares the update for the ORTB2 object + * @param {*} param0 + * @returns + */ +export const buildOrtb2Updates = ({ segments = [], topics = [] }) => { + const userData = { + name: '1plusX.com', + segment: segments.map((segmentId) => ({ id: segmentId })) + }; + const site = { + keywords: topics.join(',') + }; + return { userData, site }; +} + +/** + * Merges the targeting data with the existing config for bidder and updates + * @param {string} bidder Bidder for which to set config + * @param {Object} ortb2 + * @param {Object} bidderConfigs + * @returns + */ +export const updateBidderConfig = (bidder, ortb2Updates, bidderConfigs) => { + if (!SUPPORTED_BIDDERS.includes(bidder)) { + return null; + } + const { site, userData } = ortb2Updates; + const bidderConfigCopy = mergeDeep({}, bidderConfigs[bidder]); + + const currentSite = deepAccess(bidderConfigCopy, 'ortb2.site') + const updatedSite = mergeDeep(currentSite, site); + + const currentUserData = deepAccess(bidderConfigCopy, 'ortb2.user.data') || []; + const updatedUserData = [ + ...currentUserData.filter(({ name }) => name != userData.name), + userData + ]; + + deepSetValue(bidderConfigCopy, 'ortb2.site', updatedSite); + deepSetValue(bidderConfigCopy, 'ortb2.user.data', updatedUserData); + + return bidderConfigCopy +}; + +/** + * Updates bidder configs with the targeting data retreived from Profile API + * @param {*} papiResponse + * @param {*} param1 + */ +export const setTargetingDataToConfig = (papiResponse, { bidders }) => { + const bidderConfigs = config.getBidderConfig(); + const { s: segments, t: topics } = papiResponse; + const ortb2Updates = buildOrtb2Updates({ segments, topics }); + + for (const bidder of bidders) { + const updatedBidderConfig = updateBidderConfig(bidder, ortb2Updates, bidderConfigs); + if (updatedBidderConfig) { + config.setBidderConfig({ + bidders: [bidder], + config: updatedBidderConfig + }); + } + } +} + // Functions exported in submodule object +/** + * Init + * @param {*} config + * @param {*} userConsent + * @returns + */ const init = (config, userConsent) => { // We prolly get the config again in getBidRequestData return true; } -const getBidRequestData = (reqBidsConfigObj, callback, config, userConsent) => { +/** + * + * @param {*} reqBidsConfigObj + * @param {*} callback + * @param {*} moduleConfig + * @param {*} userConsent + */ +const getBidRequestData = (reqBidsConfigObj, callback, moduleConfig, userConsent) => { try { // Get the required config - const { customerId } = extractConfig(config); + const { customerId, bidders } = extractConfig(moduleConfig, reqBidsConfigObj); // Get PAPI URL const papiUrl = getPapiUrl({ customerId }) // Call PAPI getTargetingDataFromPapi(papiUrl) - .then((response) => { - // -- Then : - // ---- extract relevant data - // ---- set the data to the bid + .then((papiResponse) => { logMessage('REQUEST TO PAPI SUCCESS'); - const { s: segments, t: targeting } = response; + setTargetingDataToConfig(papiResponse, { bidders }); callback(); }) .catch((error) => { diff --git a/test/spec/modules/1plusXRtdProvider_spec.js b/test/spec/modules/1plusXRtdProvider_spec.js index c0eb2d6a3b2..7ae186dee3e 100644 --- a/test/spec/modules/1plusXRtdProvider_spec.js +++ b/test/spec/modules/1plusXRtdProvider_spec.js @@ -1,11 +1,47 @@ import { config } from 'src/config'; import { logMessage } from 'src/utils'; import { server } from 'test/mocks/xhr.js'; -import { onePlusXSubmodule } from 'modules/1plusXRtdProvider'; +import { + onePlusXSubmodule, + buildOrtb2Updates, + updateBidderConfig, + setTargetingDataToConfig +} from 'modules/1plusXRtdProvider'; describe('1plusXRtdProvider', () => { const reqBidsConfigObj = {}; let fakeServer; + const fakeResponseHeaders = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + }; + const fakeResponse = { + s: ['segment1', 'segment2', 'segment3'], + t: ['targeting1', 'targeting2', 'targeting3'] + }; + + const bidderConfigInitial = { + ortb2: { + user: { keywords: '' }, + site: { content: { data: [] } } + } + } + const bidderConfigInitialWith1plusXEntry = { + ortb2: { + user: { + data: [{ name: '1plusX.com', segment: [{ id: 'initial' }] }] + }, + site: { content: { data: [] } } + } + } + const bidderConfigInitialWithUserData = { + ortb2: { + user: { + data: [{ name: 'hello.world', segment: [{ id: 'initial' }] }] + }, + site: { content: { data: [] } } + } + } before(() => { config.resetConfig(); @@ -15,7 +51,7 @@ describe('1plusXRtdProvider', () => { beforeEach(() => { fakeServer = sinon.createFakeServer(); - fakeServer.respondWith('GET', '*', [200, {}, '']); + fakeServer.respondWith('GET', '*', [200, fakeResponseHeaders, JSON.stringify(fakeResponse)]); fakeServer.respondImmediately = true; fakeServer.autoRespond = true; }) @@ -47,4 +83,228 @@ describe('1plusXRtdProvider', () => { } }) }) + + describe('buildOrtb2Updates', () => { + it('fills site.keywords & user.data in the ortb2 config', () => { + const rtdData = { segments: fakeResponse.s, topics: fakeResponse.t }; + const ortb2Updates = buildOrtb2Updates(rtdData); + + const expectedOutput = { + site: { + keywords: rtdData.topics.join(','), + }, + userData: { + name: '1plusX.com', + segment: rtdData.segments.map((segmentId) => ({ id: segmentId })) + } + } + expect([ortb2Updates]).to.deep.include.members([expectedOutput]); + }); + + it('defaults to empty array if no segment is given', () => { + const rtdData = { topics: fakeResponse.t }; + const ortb2Updates = buildOrtb2Updates(rtdData); + + const expectedOutput = { + site: { + keywords: rtdData.topics.join(','), + }, + userData: { + name: '1plusX.com', + segment: [] + } + } + expect(ortb2Updates).to.deep.include(expectedOutput); + }) + + it('defaults to empty string if no topic is given', () => { + const rtdData = { segments: fakeResponse.s }; + const ortb2Updates = buildOrtb2Updates(rtdData); + + const expectedOutput = { + site: { + keywords: '', + }, + userData: { + name: '1plusX.com', + segment: rtdData.segments.map((segmentId) => ({ id: segmentId })) + } + } + expect(ortb2Updates).to.deep.include(expectedOutput); + }) + }) + + describe('updateBidderConfig', () => { + const ortb2Updates = { + site: { + keywords: fakeResponse.t.join(','), + }, + userData: { + name: '1plusX.com', + segment: fakeResponse.s.map((segmentId) => ({ id: segmentId })) + } + } + + + it("doesn't write in config of unsupported bidder", () => { + const unsupportedBidder = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 5); + // Set initial config for this bidder + config.setBidderConfig({ + bidders: [unsupportedBidder], + config: bidderConfigInitial + }) + // Call my own setBidderConfig with targeting data + const newBidderConfig = updateBidderConfig(unsupportedBidder, ortb2Updates, config.getBidderConfig()); + // Check that the config has not been changed for unsupported bidder + expect(newBidderConfig).to.be.null; + }) + + it('merges config for supported bidders (appnexus)', () => { + const bidder = 'appnexus'; + // Set initial config + config.setBidderConfig({ + bidders: [bidder], + config: bidderConfigInitial + }); + // Call submodule's setBidderConfig + const newBidderConfig = updateBidderConfig(bidder, ortb2Updates, config.getBidderConfig()); + + // Check that the targeting data has been set in the config + expect(newBidderConfig).not.to.be.null; + expect(newBidderConfig.ortb2.site).to.deep.include(ortb2Updates.site); + expect(newBidderConfig.ortb2.user.data).to.deep.include(ortb2Updates.userData); + // Check that existing config didn't get erased + expect(newBidderConfig.ortb2.site).to.deep.include(bidderConfigInitial.ortb2.site); + expect(newBidderConfig.ortb2.user).to.deep.include(bidderConfigInitial.ortb2.user); + }) + + it('merges config for supported bidders (rubicon)', () => { + const bidder = 'rubicon'; + // Set initial config + config.setBidderConfig({ + bidders: [bidder], + config: bidderConfigInitial + }); + // Call submodule's setBidderConfig + const newBidderConfig = updateBidderConfig(bidder, ortb2Updates, config.getBidderConfig()); + // Check that the targeting data has been set in the config + expect(newBidderConfig).not.to.be.null; + expect(newBidderConfig.ortb2.site).to.deep.include(ortb2Updates.site); + expect(newBidderConfig.ortb2.user.data).to.deep.include(ortb2Updates.userData); + // Check that existing config didn't get erased + expect(newBidderConfig.ortb2.site).to.deep.include(bidderConfigInitial.ortb2.site); + expect(newBidderConfig.ortb2.user).to.deep.include(bidderConfigInitial.ortb2.user); + }) + + it('overwrites an existing 1plus.com entry in ortb2.user.data', () => { + const bidder = 'appnexus'; + // Set initial config + config.setBidderConfig({ + bidders: [bidder], + config: bidderConfigInitialWith1plusXEntry + }); + // Save previous user.data entry + const previousUserData = bidderConfigInitialWithUserData.ortb2.user.data[0] + // Call submodule's setBidderConfig + const newBidderConfig = updateBidderConfig(bidder, ortb2Updates, config.getBidderConfig()); + // Check that the targeting data has been set in the config + expect(newBidderConfig).not.to.be.null; + expect(newBidderConfig.ortb2.user.data).to.deep.include(ortb2Updates.userData); + expect(newBidderConfig.ortb2.user.data).not.to.include(previousUserData); + }) + + it("doesn't overwrite entries in ortb2.user.data that aren't 1plusx.com", () => { + const bidder = 'appnexus'; + // Set initial config + config.setBidderConfig({ + bidders: [bidder], + config: bidderConfigInitialWithUserData + }); + // Save previous user.data entry + const previousUserData = bidderConfigInitialWithUserData.ortb2.user.data[0] + // Call submodule's setBidderConfig + const newBidderConfig = updateBidderConfig(bidder, ortb2Updates, config.getBidderConfig()); + // Check that the targeting data has been set in the config + expect(newBidderConfig).not.to.be.null; + expect(newBidderConfig.ortb2.user.data).to.deep.include(ortb2Updates.userData); + expect(newBidderConfig.ortb2.user.data).to.deep.include(previousUserData); + }) + + }) + + describe('setTargetingDataToConfig', () => { + const expectedOrtb2 = { + site: { + keywords: fakeResponse.t.join(',') + }, + user: { + data: [{ + name: '1plusX.com', + segment: fakeResponse.s.map((segmentId) => ({ id: segmentId })) + }] + } + } + + it("doesn't set config for unsupported bidders", () => { + const unsupportedBidder = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 5); + // setting initial config for this bidder + config.setBidderConfig({ + bidders: [unsupportedBidder], + config: bidderConfigInitial + }) + // call setTargetingDataToConfig + setTargetingDataToConfig(fakeResponse, { bidders: [unsupportedBidder] }); + // Check that the config has not been changed for unsupported bidder + const newConfig = config.getBidderConfig()[unsupportedBidder]; + expect(newConfig.ortb2.user.data).to.be.undefined; + expect(newConfig.ortb2.site).to.not.have.any.keys('keywords') + expect(newConfig).to.deep.include(bidderConfigInitial); + }) + + it('sets the config for the selected bidders', () => { + const bidders = ['appnexus', 'rubicon']; + // setting initial config for those bidders + config.setBidderConfig({ + bidders, + config: bidderConfigInitial + }) + // call setTargetingDataToConfig + setTargetingDataToConfig(fakeResponse, { bidders }); + + // Check that the targeting data has been set in both configs + for (const bidder of bidders) { + const newConfig = config.getBidderConfig()[bidder]; + expect(newConfig.ortb2.site).to.deep.include(expectedOrtb2.site); + expect(newConfig.ortb2.user).to.deep.include(expectedOrtb2.user); + // Check that existing config didn't get erased + expect(newConfig.ortb2.site).to.deep.include(bidderConfigInitial.ortb2.site); + expect(newConfig.ortb2.user).to.deep.include(bidderConfigInitial.ortb2.user); + } + }) + it('ignores unsupported bidders', () => { + const unsupportedBidder = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 5); + const bidders = ['appnexus', unsupportedBidder]; + // setting initial config for those bidders + config.setBidderConfig({ + bidders, + config: bidderConfigInitial + }) + // call setTargetingDataToConfig + setTargetingDataToConfig(fakeResponse, { bidders }); + + // Check that the targeting data has been set for supported bidder + const appnexusConfig = config.getBidderConfig()['appnexus']; + expect(appnexusConfig.ortb2.site).to.deep.include(expectedOrtb2.site); + expect(appnexusConfig.ortb2.user).to.deep.include(expectedOrtb2.user); + // Check that existing config didn't get erased + expect(appnexusConfig.ortb2.site).to.deep.include(bidderConfigInitial.ortb2.site); + expect(appnexusConfig.ortb2.user).to.deep.include(bidderConfigInitial.ortb2.user); + + // Check that config for unsupported bidder remained unchanged + const newConfig = config.getBidderConfig()[unsupportedBidder]; + expect(newConfig.ortb2.user.data).to.be.undefined; + expect(newConfig.ortb2.site).to.not.have.any.keys('keywords') + expect(newConfig).to.deep.include(bidderConfigInitial); + }) + }) })