From c661ccb027a4dc033a8da0ed416733985a1c96fe Mon Sep 17 00:00:00 2001 From: Pruthvi Muga <143863123+pruthvimuga@users.noreply.github.com> Date: Wed, 1 May 2024 12:47:55 +0530 Subject: [PATCH] Pubx.ai RTD Provider - Initial Release (#11300) * Add pubx RTD module * Add pubx RTD module documentation * Add basic tests for pubx RTD module * Fix the failing tests * Added logic to fetch floors and set to auction * Updated setDataToConfig to attach floors data to bidder requests object * pubx rtd module: * fetch floor rules * timeout defaults * floors config customizations * pubx on/off from floors response * move fetch floors to init and also wait for it before auction * TESTS: update * replace createFloorsDataForAuction to avoid circular dep - WIP * reset default floors when no floor response is received * TESTS: add test * tag integration - fetch floors api events * * Remove coonsole.log * move __pubxFloorRulesPromise__ to window * Remove unused var error * TESTS: add getBidRequestData, refactor stubs * TESTS: setFloorsApiStatus * TESTS: add fetchFloorRules * remove useRtd * make endpoint potional and take pubxId form provider config * TESTS: use xhr to fakeServer instead of sinon * make default data optional * update README * TEST: update tests * add integration example * remove floorProvider from default config * fix linting * update readme.md --------- Co-authored-by: Phaneendra Hegde Co-authored-by: Phaneendra Hegde --- .../gpt/pubxaiRtdProvider_example.html | 113 +++++ modules/.submodules.json | 1 + modules/pubxaiRtdProvider.js | 146 +++++++ modules/pubxaiRtdProvider.md | 68 +++ test/spec/modules/pubxaiRtdProvider_spec.js | 397 ++++++++++++++++++ 5 files changed, 725 insertions(+) create mode 100644 integrationExamples/gpt/pubxaiRtdProvider_example.html create mode 100644 modules/pubxaiRtdProvider.js create mode 100644 modules/pubxaiRtdProvider.md create mode 100644 test/spec/modules/pubxaiRtdProvider_spec.js diff --git a/integrationExamples/gpt/pubxaiRtdProvider_example.html b/integrationExamples/gpt/pubxaiRtdProvider_example.html new file mode 100644 index 00000000000..566cfe0c928 --- /dev/null +++ b/integrationExamples/gpt/pubxaiRtdProvider_example.html @@ -0,0 +1,113 @@ + + + Individual Ad Unit Refresh Example + + + + + + + + +

Individual Ad Unit Refresh Example

+
Div-1
+

+
+ +
+ + diff --git a/modules/.submodules.json b/modules/.submodules.json index 3913f3f5734..3b7f84af245 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -90,6 +90,7 @@ "optimeraRtdProvider", "oxxionRtdProvider", "permutiveRtdProvider", + "pubxaiRtdProvider", "qortexRtdProvider", "reconciliationRtdProvider", "relevadRtdProvider", diff --git a/modules/pubxaiRtdProvider.js b/modules/pubxaiRtdProvider.js new file mode 100644 index 00000000000..b958856df00 --- /dev/null +++ b/modules/pubxaiRtdProvider.js @@ -0,0 +1,146 @@ +import { ajax } from '../src/ajax.js'; +import { config } from '../src/config.js'; +import { submodule } from '../src/hook.js'; +import { deepAccess } from '../src/utils.js'; +/** + * This RTD module has a dependency on the priceFloors module. + * We utilize the createFloorsDataForAuction function from the priceFloors module to incorporate price floors data into the current auction. + */ +import { createFloorsDataForAuction } from './priceFloors.js'; // eslint-disable-line prebid/validate-imports + +const MODULE_NAME = 'realTimeData'; +const SUBMODULE_NAME = 'pubxai'; +window.__pubxFloorRulesPromise__ = null; +export const FloorsApiStatus = Object.freeze({ + IN_PROGRESS: 'IN_PROGRESS', + SUCCESS: 'SUCCESS', + ERROR: 'ERROR', +}); +export const FLOORS_EVENT_HANDLE = 'floorsApi'; +export const FLOORS_END_POINT = 'https://floor.pbxai.com/'; +export const FLOOR_PROVIDER = 'PubxFloorProvider'; + +export const getFloorsConfig = (provider, floorsResponse) => { + const floorsConfig = { + floors: { + enforcement: { floorDeals: true }, + data: floorsResponse, + }, + }; + const { floorMin, enforcement } = deepAccess(provider, 'params'); + if (floorMin) { + floorsConfig.floors.floorMin = floorMin; + } + if (enforcement) { + floorsConfig.floors.enforcement = enforcement; + } + return floorsConfig; +}; + +export const setFloorsConfig = (provider, data) => { + if (data) { + const floorsConfig = getFloorsConfig(provider, data); + config.setConfig(floorsConfig); + window.__pubxLoaded__ = true; + window.__pubxFloorsConfig__ = floorsConfig; + } else { + config.setConfig({ floors: window.__pubxPrevFloorsConfig__ }); + window.__pubxLoaded__ = false; + window.__pubxFloorsConfig__ = null; + } +}; + +export const setDefaultPriceFloors = (provider) => { + const { data } = deepAccess(provider, 'params'); + if (data !== undefined) { + data.floorProvider = FLOOR_PROVIDER; + setFloorsConfig(provider, data); + } +}; + +export const setPriceFloors = async (provider) => { + window.__pubxPrevFloorsConfig__ = config.getConfig('floors'); + setDefaultPriceFloors(provider); + return fetchFloorRules(provider) + .then((floorsResponse) => { + setFloorsConfig(provider, floorsResponse); + setFloorsApiStatus(FloorsApiStatus.SUCCESS); + }) + .catch((_) => { + setFloorsApiStatus(FloorsApiStatus.ERROR); + }); +}; + +export const setFloorsApiStatus = (status) => { + window.__pubxFloorsApiStatus__ = status; + window.dispatchEvent( + new CustomEvent(FLOORS_EVENT_HANDLE, { detail: { status } }) + ); +}; + +export const getUrl = (provider) => { + const { pubxId, endpoint } = deepAccess(provider, 'params'); + return `${endpoint || FLOORS_END_POINT}?pubxId=${pubxId}&page=${ + window.location.href + }`; +}; + +export const fetchFloorRules = async (provider) => { + return new Promise((resolve, reject) => { + setFloorsApiStatus(FloorsApiStatus.IN_PROGRESS); + ajax(getUrl(provider), { + success: (responseText, response) => { + try { + if (response && response.response) { + const floorsResponse = JSON.parse(response.response); + resolve(floorsResponse); + } else { + resolve(null); + } + } catch (error) { + reject(error); + } + }, + error: (responseText, response) => { + reject(response); + }, + }); + }); +}; + +const init = (provider) => { + window.__pubxFloorRulesPromise__ = setPriceFloors(provider); + return true; +}; + +const getBidRequestData = (() => { + let floorsAttached = false; + return (reqBidsConfigObj, onDone) => { + if (!floorsAttached) { + createFloorsDataForAuction( + reqBidsConfigObj.adUnits, + reqBidsConfigObj.auctionId + ); + window.__pubxFloorRulesPromise__.then(() => { + createFloorsDataForAuction( + reqBidsConfigObj.adUnits, + reqBidsConfigObj.auctionId + ); + onDone(); + }); + floorsAttached = true; + } + }; +})(); + +export const pubxaiSubmodule = { + name: SUBMODULE_NAME, + init, + getBidRequestData, +}; + +export const beforeInit = () => { + submodule(MODULE_NAME, pubxaiSubmodule); +}; + +beforeInit(); diff --git a/modules/pubxaiRtdProvider.md b/modules/pubxaiRtdProvider.md new file mode 100644 index 00000000000..d7d89857c62 --- /dev/null +++ b/modules/pubxaiRtdProvider.md @@ -0,0 +1,68 @@ +## Overview + +- Module Name: pubX.ai RTD Provider +- Module Type: RTD Adapter +- Maintainer: phaneendra@pubx.ai + +## Description + +This RTD module, provided by pubx.ai, is used to set dynamic floors within Prebid. + +## Usage + +Ensure that the following modules are listed when building Prebid: `priceFloors`. +For example: + +```shell +gulp build --modules=priceFloors +``` + +To compile the RTD module into your Prebid build: + +```shell +gulp build --modules=rtdModule,pubxaiRtdProvider +``` + +To utilize the pubX.ai RTD module, add `realTimeData` with the parameters mentioned below to the Prebid config. + +```js +const AUCTION_DELAY = 100; +pbjs.setConfig({ + // rest of the config + ..., + realTimeData: { + auctionDelay: AUCTION_DELAY, + dataProviders: { + name: "pubxai", + waitForIt: true, + params: { + pubxId: ``, + endpoint: ``, // (optional) + floorMin: ``, // (optional) + enforcement: ``, // (optional) + data: `` // (optional) + } + } + } + // rest of the config + ..., +}); +``` + +## Parameters + +| Name | Type | Description | Default | +| :----------------- | :------ | :------------------------------------------------------------- | :------------------------- | +| name | String | Name of the real-time data module | Always `pubxai` | +| waitForIt | Boolean | Should be `true` if an `auctionDelay` is defined (optional) | `false` | +| params | Object | | | +| params.pubxId | String | Publisher ID | | +| params.endpoint | String | URL to retrieve floor data (optional) | `https://floor.pbxai.com/` | +| params.floorMin | Number | Minimum CPM floor (optional) | `None` | +| params.enforcement | Object | Enforcement behavior within the Price Floors Module (optional) | `None` | +| params.data | Object | Default floor data provided by pubX.ai (optional) | `None` | + +## What Should Change in the Bid Request? + +There are no direct changes in the bid request due to our RTD module, but floor configuration will be set using the price floors module. These changes will be reflected in adunit bids or bidder requests as floor data. + diff --git a/test/spec/modules/pubxaiRtdProvider_spec.js b/test/spec/modules/pubxaiRtdProvider_spec.js new file mode 100644 index 00000000000..b645b830246 --- /dev/null +++ b/test/spec/modules/pubxaiRtdProvider_spec.js @@ -0,0 +1,397 @@ +import * as priceFloors from '../../../modules/priceFloors'; +import { + FLOORS_END_POINT, + FLOORS_EVENT_HANDLE, + FloorsApiStatus, + beforeInit, + fetchFloorRules, + getFloorsConfig, + getUrl, + pubxaiSubmodule, + setDefaultPriceFloors, + setFloorsApiStatus, + setFloorsConfig, + setPriceFloors, +} from '../../../modules/pubxaiRtdProvider'; +import { config } from '../../../src/config'; +import * as hook from '../../../src/hook.js'; +import { server } from '../../mocks/xhr.js'; + +const getConfig = () => ({ + params: { + useRtd: true, + endpoint: 'http://pubxai.com:3001/floors', + data: { + currency: 'EUR', + floorProvider: 'PubxFloorProvider', + modelVersion: 'gpt-mvm_AB_0.50_dt_0.75_dwt_0.95_dnt_0.25_fm_0.50', + schema: { fields: ['gptSlot', 'mediaType'] }, + values: { '*|banner': 0.02 }, + }, + }, +}); + +const getFloorsResponse = () => ({ + currency: 'USD', + floorProvider: 'PubxFloorProvider', + modelVersion: 'gpt-mvm_AB_0.50_dt_0.75_dwt_0.95_dnt_0.25_fm_0.50', + schema: { fields: ['gptSlot', 'mediaType'] }, + values: { '*|banner': 0.02 }, +}); + +const resetGlobals = () => { + window.__pubxLoaded__ = undefined; + window.__pubxPrevFloorsConfig__ = undefined; + window.__pubxFloorsConfig__ = undefined; + window.__pubxFloorsApiStatus__ = undefined; + window.__pubxFloorRulesPromise__ = null; +}; + +const fakeServer = ( + fakeResponse = '', + providerConfig = undefined, + statusCode = 200 +) => { + const fakeResponseHeaders = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }; + const request = server.requests[0]; + request.respond( + statusCode, + fakeResponseHeaders, + fakeResponse ? JSON.stringify(fakeResponse) : '' + ); + return request; +}; + +const stubConfig = () => { + const stub = sinon.stub(config, 'setConfig'); + return stub; +}; + +describe('pubxaiRtdProvider', () => { + describe('beforeInit', () => { + it('should register RTD submodule provider', function () { + let submoduleStub = sinon.stub(hook, 'submodule'); + beforeInit(); + assert(submoduleStub.calledOnceWith('realTimeData', pubxaiSubmodule)); + submoduleStub.restore(); + }); + }); + describe('submodule', () => { + describe('name', function () { + it('should be pubxai', function () { + expect(pubxaiSubmodule.name).to.equal('pubxai'); + }); + }); + }); + describe('init', () => { + let stub; + beforeEach(() => { + resetGlobals(); + stub = stubConfig(); + }); + afterEach(() => { + stub.restore(); + }); + it('standard case - returns true', () => { + const initResult = pubxaiSubmodule.init({ params: { useRtd: true } }); + expect(initResult).to.be.true; + }); + it('setPriceFloors called when `useRtd` is true in the provider config', () => { + pubxaiSubmodule.init(getConfig()); + expect(window.__pubxLoaded__).to.equal(true); + }); + }); + describe('getBidRequestData', () => { + const reqBidsConfigObj = { + adUnits: [{ code: 'ad-slot-code-0' }], + auctionId: 'auction-id-0', + }; + let stub; + beforeEach(() => { + window.__pubxFloorRulesPromise__ = Promise.resolve(); + stub = sinon.stub(priceFloors, 'createFloorsDataForAuction'); + }); + afterEach(() => { + resetGlobals(); + stub.restore(); + }); + it('createFloorsDataForAuction called once before and once after __pubxFloorRulesPromise__. Also getBidRequestData executed only once', async () => { + pubxaiSubmodule.getBidRequestData(reqBidsConfigObj, () => {}); + assert(priceFloors.createFloorsDataForAuction.calledOnce); + await window.__pubxFloorRulesPromise__; + assert(priceFloors.createFloorsDataForAuction.calledTwice); + assert( + priceFloors.createFloorsDataForAuction.alwaysCalledWith( + reqBidsConfigObj.adUnits, + reqBidsConfigObj.auctionId + ) + ); + pubxaiSubmodule.getBidRequestData(reqBidsConfigObj, () => {}); + await window.__pubxFloorRulesPromise__; + assert(priceFloors.createFloorsDataForAuction.calledTwice); + }); + }); + describe('fetchFloorRules', () => { + const providerConfig = getConfig(); + const floorsResponse = getFloorsResponse(); + it('success with floors response', (done) => { + const promise = fetchFloorRules(providerConfig); + fakeServer(floorsResponse); + promise.then((res) => { + expect(res).to.deep.equal(floorsResponse); + done(); + }); + }); + it('success with no floors response', (done) => { + const promise = fetchFloorRules(providerConfig); + fakeServer(undefined); + promise.then((res) => { + expect(res).to.deep.equal(null); + done(); + }); + }); + it('API call error', (done) => { + const promise = fetchFloorRules(providerConfig); + fakeServer(undefined, undefined, 404); + promise + .then((res) => { + expect(true).to.be.false; + }) + .catch((e) => { + expect(e).to.not.be.undefined; + }) + .finally(() => { + done(); + }); + }); + it('Wrong API response', (done) => { + const promise = fetchFloorRules(providerConfig); + fakeServer('floorsResponse'); + promise + .then((res) => { + expect(true).to.be.false; + }) + .catch((e) => { + expect(e).to.not.be.undefined; + }) + .finally(() => { + done(); + }); + }); + }); + describe('setPriceFloors', () => { + const providerConfig = getConfig(); + const floorsResponse = getFloorsResponse(); + let stub; + beforeEach(() => { + resetGlobals(); + stub = stubConfig(); + }); + afterEach(() => { + stub.restore(); + }); + it('with floors response', (done) => { + const floorsPromise = setPriceFloors(providerConfig); + fakeServer(floorsResponse); + expect(window.__pubxLoaded__).to.be.true; + expect(window.__pubxFloorsConfig__).to.deep.equal( + getFloorsConfig(providerConfig, providerConfig.params.data) + ); + floorsPromise.then(() => { + expect(window.__pubxLoaded__).to.be.true; + expect(window.__pubxFloorsConfig__).to.deep.equal( + getFloorsConfig(providerConfig, floorsResponse) + ); + done(); + }); + }); + it('without floors response', (done) => { + const floorsPromise = setPriceFloors(providerConfig); + fakeServer(undefined); + expect(window.__pubxLoaded__).to.be.true; + expect(window.__pubxFloorsConfig__).to.deep.equal( + getFloorsConfig(providerConfig, providerConfig.params.data) + ); + floorsPromise.then(() => { + expect(window.__pubxLoaded__).to.be.false; + expect(window.__pubxFloorsConfig__).to.deep.equal(null); + done(); + }); + }); + it('default floors', (done) => { + const floorsPromise = setPriceFloors(providerConfig); + fakeServer(undefined, undefined, 404); + expect(window.__pubxLoaded__).to.be.true; + expect(window.__pubxFloorsConfig__).to.deep.equal( + getFloorsConfig(providerConfig, providerConfig.params.data) + ); + floorsPromise + .then(() => { + expect(true).to.be.false; + }) + .catch((e) => { + expect(window.__pubxLoaded__).to.be.true; + expect(window.__pubxFloorsConfig__).to.deep.equal( + getFloorsConfig(providerConfig, providerConfig.params.data) + ); + }) + .finally(() => { + done(); + }); + }); + }); + describe('setFloorsConfig', () => { + const providerConfig = getConfig(); + let stub; + beforeEach(() => { + resetGlobals(); + stub = stubConfig(); + }); + afterEach(function () { + stub.restore(); + }); + it('non-empty floorResponse', () => { + const floorsResponse = getFloorsResponse(); + setFloorsConfig(providerConfig, floorsResponse); + const floorsConfig = getFloorsConfig(providerConfig, floorsResponse); + assert(config.setConfig.calledOnceWith(floorsConfig)); + expect(window.__pubxLoaded__).to.be.true; + expect(window.__pubxFloorsConfig__).to.deep.equal(floorsConfig); + }); + it('empty floorResponse', () => { + const floorsResponse = null; + setFloorsConfig(providerConfig, floorsResponse); + assert(config.setConfig.calledOnceWith({ floors: undefined })); + expect(window.__pubxLoaded__).to.be.false; + expect(window.__pubxFloorsConfig__).to.be.null; + }); + }); + describe('getFloorsConfig', () => { + let providerConfig; + const floorsResponse = getFloorsResponse(); + beforeEach(() => { + providerConfig = getConfig(); + }); + it('no customizations in the provider config', () => { + const result = getFloorsConfig(providerConfig, floorsResponse); + expect(result).to.deep.equal({ + floors: { + enforcement: { floorDeals: true }, + data: floorsResponse, + }, + }); + }); + it('only floormin in the provider config', () => { + providerConfig.params.floorMin = 2; + expect(getFloorsConfig(providerConfig, floorsResponse)).to.deep.equal({ + floors: { + enforcement: { floorDeals: true }, + floorMin: 2, + data: floorsResponse, + }, + }); + }); + it('only enforcement in the provider config', () => { + providerConfig.params.enforcement = { + bidAdjustment: true, + enforceJS: false, + }; + expect(getFloorsConfig(providerConfig, floorsResponse)).to.deep.equal({ + floors: { + enforcement: { + bidAdjustment: true, + enforceJS: false, + }, + data: floorsResponse, + }, + }); + }); + it('both floorMin and enforcement in the provider config', () => { + providerConfig.params.floorMin = 2; + providerConfig.params.enforcement = { + bidAdjustment: true, + enforceJS: false, + }; + expect(getFloorsConfig(providerConfig, floorsResponse)).to.deep.equal({ + floors: { + enforcement: { + bidAdjustment: true, + enforceJS: false, + }, + floorMin: 2, + data: floorsResponse, + }, + }); + }); + }); + describe('setDefaultPriceFloors', () => { + let stub; + beforeEach(() => { + resetGlobals(); + stub = stubConfig(); + }); + afterEach(function () { + stub.restore(); + }); + it('should set default floors config', () => { + const providerConfig = getConfig(); + setDefaultPriceFloors(providerConfig); + assert( + config.setConfig.calledOnceWith( + getFloorsConfig(providerConfig, providerConfig.params.data) + ) + ); + expect(window.__pubxLoaded__).to.be.true; + }); + }); + describe('setFloorsApiStatus', () => { + let stub; + beforeEach(() => { + resetGlobals(); + stub = sinon.stub(window, 'dispatchEvent'); + }); + afterEach(function () { + stub.restore(); + }); + it('set status', () => { + setFloorsApiStatus(FloorsApiStatus.SUCCESS); + expect(window.__pubxFloorsApiStatus__).to.equal(FloorsApiStatus.SUCCESS); + }); + it('dispatch event', () => { + setFloorsApiStatus(FloorsApiStatus.SUCCESS); + assert( + window.dispatchEvent.calledOnceWith( + new CustomEvent(FLOORS_EVENT_HANDLE, { + detail: { status: FloorsApiStatus.SUCCESS }, + }) + ) + ); + }); + }); + describe('getUrl', () => { + const provider = { + name: 'pubxai', + waitForIt: true, + params: { + pubxId: '12345', + }, + }; + it('floors end point', () => { + expect(FLOORS_END_POINT).to.equal('https://floor.pbxai.com/'); + }); + it('standard case', () => { + expect(getUrl(provider)).to.equal( + `https://floor.pbxai.com/?pubxId=12345&page=${window.location.href}` + ); + }); + it('custom url provided', () => { + provider.params.endpoint = 'https://custom.floor.com/'; + expect(getUrl(provider)).to.equal( + `https://custom.floor.com/?pubxId=12345&page=${window.location.href}` + ); + }); + }); +});