diff --git a/modules/.submodules.json b/modules/.submodules.json index c0e30037660..81c82603083 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -2,7 +2,8 @@ "userId": [ "digiTrustIdSystem", "id5IdSystem", - "criteortusIdSystem" + "criteortusIdSystem", + "liveIntentIdSystem" ], "adpod": [ "freeWheelAdserverVideo", diff --git a/modules/liveIntentIdSystem.js b/modules/liveIntentIdSystem.js new file mode 100644 index 00000000000..8e1a092f51d --- /dev/null +++ b/modules/liveIntentIdSystem.js @@ -0,0 +1,97 @@ +/** + * This module adds LiveIntentId to the User ID module + * The {@link module:modules/userId} module is required + * @module modules/liveIntentIdSystem + * @requires module:modules/userId + */ +import * as utils from '../src/utils' +import {ajax} from '../src/ajax'; +import {submodule} from '../src/hook'; + +const MODULE_NAME = 'liveIntentId'; +const LIVE_CONNECT_DUID_KEY = '_li_duid'; +const DOMAIN_USER_ID_QUERY_PARAM_KEY = 'duid'; +const DEFAULT_LIVEINTENT_IDENTITY_URL = '//idx.liadm.com'; +const DEFAULT_PREBID_SOURCE = 'prebid'; + +/** @type {Submodule} */ +export const liveIntentIdSubmodule = { + /** + * used to link submodule with config + * @type {string} + */ + name: MODULE_NAME, + + /** + * decode the stored id value for passing to bid requests. Note that lipb object is a wrapper for everything, and + * internally it could contain more data other than `lipbid`(e.g. `segments`) depending on the `partner` and + * `publisherId` params. + * @function + * @param {{unifiedId:string}} value + * @returns {{lipb:Object}} + */ + decode(value) { + function composeIdObject(value) { + const base = {'lipbid': value['unifiedId']}; + delete value.unifiedId; + return {'lipb': {...base, ...value}}; + } + return (value && typeof value['unifiedId'] === 'string') ? composeIdObject(value) : undefined; + }, + + /** + * performs action to obtain id and return a value in the callback's response argument + * @function + * @param {SubmoduleParams} [configParams] + * @returns {function(callback:function)} + */ + getId(configParams) { + const publisherId = configParams && configParams.publisherId; + if (!publisherId && typeof publisherId !== 'string') { + utils.logError(`${MODULE_NAME} - publisherId must be defined, not a '${publisherId}'`); + return; + } + let baseUrl = DEFAULT_LIVEINTENT_IDENTITY_URL; + let source = DEFAULT_PREBID_SOURCE; + if (configParams.url) { + baseUrl = configParams.url + } + if (configParams.partner) { + source = configParams.partner + } + + const additionalIdentifierNames = configParams.identifiersToResolve || []; + + const additionalIdentifiers = additionalIdentifierNames.concat([LIVE_CONNECT_DUID_KEY]).reduce((obj, identifier) => { + const value = utils.getCookie(identifier) || utils.getDataFromLocalStorage(identifier); + const key = identifier.replace(LIVE_CONNECT_DUID_KEY, DOMAIN_USER_ID_QUERY_PARAM_KEY); + if (value) { + if (typeof value === 'object') { + obj[key] = JSON.stringify(value); + } else { + obj[key] = value; + } + } + return obj + }, {}); + + const queryString = utils.parseQueryStringParameters(additionalIdentifiers) + const url = `${baseUrl}/idex/${source}/${publisherId}?${queryString}`; + + return function (callback) { + ajax(url, response => { + let responseObj = {}; + if (response) { + try { + responseObj = JSON.parse(response); + } catch (error) { + utils.logError(error); + } + } + callback(responseObj); + }, undefined, { method: 'GET', withCredentials: true }); + } + } +}; + +submodule('userId', liveIntentIdSubmodule); diff --git a/modules/prebidServerBidAdapter/index.js b/modules/prebidServerBidAdapter/index.js index 7ffaf9988dd..d9dfde80b88 100644 --- a/modules/prebidServerBidAdapter/index.js +++ b/modules/prebidServerBidAdapter/index.js @@ -698,7 +698,7 @@ const OPEN_RTB_PROTOCOL = { } const bidUserId = utils.deepAccess(bidRequests, '0.bids.0.userId'); - if (bidUserId && typeof bidUserId === 'object' && (bidUserId.tdid || bidUserId.pubcid)) { + if (bidUserId && typeof bidUserId === 'object' && (bidUserId.tdid || bidUserId.pubcid || bidUserId.lipb)) { utils.deepSetValue(request, 'user.ext.eids', []); if (bidUserId.tdid) { @@ -721,6 +721,15 @@ const OPEN_RTB_PROTOCOL = { }] }); } + + if (bidUserId.lipb && bidUserId.lipb.lipbid) { + request.user.ext.eids.push({ + source: 'liveIntent', + uids: [{ + id: bidUserId.lipb.lipbid + }] + }); + } } if (bidRequests && bidRequests[0].gdprConsent) { diff --git a/modules/userId/index.js b/modules/userId/index.js index 8302a7a89e3..baeb1e2574c 100644 --- a/modules/userId/index.js +++ b/modules/userId/index.js @@ -71,6 +71,8 @@ * @property {(boolean|undefined)} create - create id if missing. default is true. * @property {(boolean|undefined)} extend - extend expiration time on each access. default is false. * @property {(string|undefined)} pid - placement id url param value + * @property {(string|undefined)} publisherId - the unique identifier of the publisher in question + * @property {(array|undefined)} identifiersToResolve - the identifiers from either ls|cookie to be attached to the getId query */ /** diff --git a/modules/userId/userId.md b/modules/userId/userId.md index 9f71d59e5e1..b5b8c216ead 100644 --- a/modules/userId/userId.md +++ b/modules/userId/userId.md @@ -43,6 +43,16 @@ pbjs.setConfig({ name: 'idl_env', expires: 30 } + }, { + name: 'liveIntentId', + params: { + publisherId: '7798696' // Set an identifier of a publisher know to your systems + }, + storage: { + type: 'cookie', + name: '_li_pbid', + expires: 60 + } }], syncDelay: 5000, auctionDelay: 1000 @@ -82,6 +92,16 @@ pbjs.setConfig({ name: 'idl_env', expires: 30 } + }, { + name: 'liveIntentId', + params: { + publisherId: '7798696' // Set an identifier of a publisher know to your systems + }, + storage: { + type: 'html5', + name: '_li_pbid', + expires: 60 + } }], syncDelay: 5000 } diff --git a/test/spec/modules/liveIntentIdSystem_spec.js b/test/spec/modules/liveIntentIdSystem_spec.js new file mode 100644 index 00000000000..dc525ea56ab --- /dev/null +++ b/test/spec/modules/liveIntentIdSystem_spec.js @@ -0,0 +1,145 @@ +import { liveIntentIdSubmodule } from 'modules/liveIntentIdSystem'; +import * as utils from 'src/utils'; + +describe('LiveIntentId', function() { + let xhr; + let requests; + let getCookieStub; + let getDataFromLocalStorageStub; + let logErrorStub; + + const defaultConfigParams = {'publisherId': '89899'}; + const responseHeader = { 'Content-Type': 'application/json' } + + beforeEach(function () { + xhr = sinon.useFakeXMLHttpRequest(); + requests = []; + xhr.onCreate = request => requests.push(request); + getCookieStub = sinon.stub(utils, 'getCookie'); + getDataFromLocalStorageStub = sinon.stub(utils, 'getDataFromLocalStorage'); + logErrorStub = sinon.stub(utils, 'logError'); + }); + + afterEach(function () { + xhr.restore(); + getCookieStub.restore(); + getDataFromLocalStorageStub.restore(); + logErrorStub.restore(); + }); + + it('should log an error if no configParams were passed', function() { + liveIntentIdSubmodule.getId(); + expect(logErrorStub.calledOnce).to.be.true; + }); + + it('should log an error if publisherId configParam was not passed', function() { + liveIntentIdSubmodule.getId({}); + expect(logErrorStub.calledOnce).to.be.true; + }); + + it('should call the Custom URL of the LiveIntent Identity Exchange endpoint', function() { + getCookieStub.returns(null); + let callBackSpy = sinon.spy(); + let submoduleCallback = liveIntentIdSubmodule.getId({...defaultConfigParams, ...{'url': 'https://dummy.liveintent.com'}}); + submoduleCallback(callBackSpy); + let request = requests[0]; + expect(request.url).to.be.eq('https://dummy.liveintent.com/idex/prebid/89899?'); + request.respond( + 200, + responseHeader, + JSON.stringify({}) + ); + expect(callBackSpy.calledOnce).to.be.true; + }); + + it('should call the default url of the LiveIntent Identity Exchange endpoint, with a partner', function() { + getCookieStub.returns(null); + let callBackSpy = sinon.spy(); + let submoduleCallback = liveIntentIdSubmodule.getId({...defaultConfigParams, ...{'url': 'https://dummy.liveintent.com', 'partner': 'rubicon'}}); + submoduleCallback(callBackSpy); + let request = requests[0]; + expect(request.url).to.be.eq('https://dummy.liveintent.com/idex/rubicon/89899?'); + request.respond( + 200, + responseHeader, + JSON.stringify({}) + ); + expect(callBackSpy.calledOnce).to.be.true; + }); + + it('should call the LiveIntent Identity Exchange endpoint, with no additional query params', function() { + getCookieStub.returns(null); + let callBackSpy = sinon.spy(); + let submoduleCallback = liveIntentIdSubmodule.getId(defaultConfigParams); + submoduleCallback(callBackSpy); + let request = requests[0]; + expect(request.url).to.be.eq('//idx.liadm.com/idex/prebid/89899?'); + request.respond( + 200, + responseHeader, + JSON.stringify({}) + ); + expect(callBackSpy.calledOnce).to.be.true; + }); + + it('should include the LiveConnect identifier when calling the LiveIntent Identity Exchange endpoint', function() { + getCookieStub.withArgs('_li_duid').returns('li-fpc'); + let callBackSpy = sinon.spy(); + let submoduleCallback = liveIntentIdSubmodule.getId(defaultConfigParams); + submoduleCallback(callBackSpy); + let request = requests[0]; + expect(request.url).to.be.eq('//idx.liadm.com/idex/prebid/89899?duid=li-fpc&'); + request.respond( + 200, + responseHeader, + JSON.stringify({}) + ); + expect(callBackSpy.calledOnce).to.be.true; + }); + + it('should include the LiveConnect identifier and additional Identifiers to resolve', function() { + getCookieStub.withArgs('_li_duid').returns('li-fpc'); + getDataFromLocalStorageStub.withArgs('_thirdPC').returns('third-pc'); + let configParams = { + ...defaultConfigParams, + ...{ + 'identifiersToResolve': ['_thirdPC'] + } + }; + + let callBackSpy = sinon.spy(); + let submoduleCallback = liveIntentIdSubmodule.getId(configParams); + submoduleCallback(callBackSpy); + let request = requests[0]; + expect(request.url).to.be.eq('//idx.liadm.com/idex/prebid/89899?_thirdPC=third-pc&duid=li-fpc&'); + request.respond( + 200, + responseHeader, + JSON.stringify({}) + ); + expect(callBackSpy.calledOnce).to.be.true; + }); + + it('should include an additional identifier value to resolve even if it is an object', function() { + getCookieStub.returns(null); + getDataFromLocalStorageStub.withArgs('_thirdPC').returns({'key': 'value'}); + let configParams = { + ...defaultConfigParams, + ...{ + 'identifiersToResolve': ['_thirdPC'] + } + }; + + let callBackSpy = sinon.spy(); + let submoduleCallback = liveIntentIdSubmodule.getId(configParams); + submoduleCallback(callBackSpy); + let request = requests[0]; + expect(request.url).to.be.eq('//idx.liadm.com/idex/prebid/89899?_thirdPC=%7B%22key%22%3A%22value%22%7D&'); + request.respond( + 200, + responseHeader, + JSON.stringify({}) + ); + expect(callBackSpy.calledOnce).to.be.true; + }); +}); diff --git a/test/spec/modules/prebidServerBidAdapter_spec.js b/test/spec/modules/prebidServerBidAdapter_spec.js index c823e5aa370..a257e7c1338 100644 --- a/test/spec/modules/prebidServerBidAdapter_spec.js +++ b/test/spec/modules/prebidServerBidAdapter_spec.js @@ -1043,7 +1043,10 @@ describe('S2S Adapter', function () { let userIdBidRequest = utils.deepClone(BID_REQUESTS); userIdBidRequest[0].bids[0].userId = { tdid: 'abc123', - pubcid: '1234' + pubcid: '1234', + lipb: { + lipbid: 'li-xyz' + } }; adapter.callBids(REQUEST, userIdBidRequest, addBidResponse, done, ajax); @@ -1054,6 +1057,8 @@ describe('S2S Adapter', function () { expect(requestBid.user.ext.eids.filter(eid => eid.source === 'adserver.org')[0].uids[0].id).is.equal('abc123'); expect(requestBid.user.ext.eids.filter(eid => eid.source === 'pubcommon')).is.not.empty; expect(requestBid.user.ext.eids.filter(eid => eid.source === 'pubcommon')[0].uids[0].id).is.equal('1234'); + expect(requestBid.user.ext.eids.filter(eid => eid.source === 'liveIntent')).is.not.empty; + expect(requestBid.user.ext.eids.filter(eid => eid.source === 'liveIntent')[0].uids[0].id).is.equal('li-xyz'); }); it('when config \'currency.adServerCurrency\' value is an array: ORTB has property \'cur\' value set to a single item array', function () { diff --git a/test/spec/modules/userId_spec.js b/test/spec/modules/userId_spec.js index e1740dede85..cd72ca9670f 100644 --- a/test/spec/modules/userId_spec.js +++ b/test/spec/modules/userId_spec.js @@ -14,6 +14,7 @@ import {unifiedIdSubmodule} from 'modules/userId/unifiedIdSystem'; import {pubCommonIdSubmodule} from 'modules/userId/pubCommonIdSystem'; import {id5IdSubmodule} from 'modules/id5IdSystem'; import {identityLinkSubmodule} from 'modules/identityLinkIdSystem'; +import {liveIntentIdSubmodule} from 'modules/liveIntentIdSystem'; let assert = require('chai').assert; let expect = require('chai').expect; @@ -305,8 +306,8 @@ describe('User ID', function() { expect(utils.logInfo.args[0][0]).to.exist.and.to.equal('User ID - usersync config updated for 1 submodules'); }); - it('config with 4 configurations should result in 4 submodules add', function () { - setSubmoduleRegistry([pubCommonIdSubmodule, unifiedIdSubmodule, id5IdSubmodule, identityLinkSubmodule]); + it('config with 5 configurations should result in 5 submodules add', function () { + setSubmoduleRegistry([pubCommonIdSubmodule, unifiedIdSubmodule, id5IdSubmodule, identityLinkSubmodule, liveIntentIdSubmodule]); init(config); config.setConfig({ usersync: { @@ -322,10 +323,13 @@ describe('User ID', function() { }, { name: 'identityLink', storage: { name: 'idl_env', type: 'cookie' } + }, { + name: 'liveIntentId', + storage: { name: '_li_pbid', type: 'cookie' } }] } }); - expect(utils.logInfo.args[0][0]).to.exist.and.to.equal('User ID - usersync config updated for 4 submodules'); + expect(utils.logInfo.args[0][0]).to.exist.and.to.equal('User ID - usersync config updated for 5 submodules'); }); it('config syncDelay updates module correctly', function () { @@ -689,6 +693,46 @@ describe('User ID', function() { }, {adUnits}); }); + it('test hook from liveIntentId html5', function(done) { + // simulate existing browser local storage values + localStorage.setItem('_li_pbid', JSON.stringify({'unifiedId': 'random-ls-identifier'})); + localStorage.setItem('_li_pbid_exp', ''); + + setSubmoduleRegistry([liveIntentIdSubmodule]); + init(config); + config.setConfig(getConfigMock(['liveIntentId', '_li_pbid', 'html5'])); + requestBidsHook(function() { + adUnits.forEach(unit => { + unit.bids.forEach(bid => { + expect(bid).to.have.deep.nested.property('userId.lipb'); + expect(bid.userId.lipb.lipbid).to.equal('random-ls-identifier'); + }); + }); + localStorage.removeItem('_li_pbid'); + localStorage.removeItem('_li_pbid_exp'); + done(); + }, {adUnits}); + }); + + it('test hook from liveIntentId cookie', function(done) { + utils.setCookie('_li_pbid', JSON.stringify({'unifiedId': 'random-cookie-identifier'}), (new Date(Date.now() + 100000).toUTCString())); + + setSubmoduleRegistry([liveIntentIdSubmodule]); + init(config); + config.setConfig(getConfigMock(['liveIntentId', '_li_pbid', 'cookie'])); + + requestBidsHook(function() { + adUnits.forEach(unit => { + unit.bids.forEach(bid => { + expect(bid).to.have.deep.nested.property('userId.lipb'); + expect(bid.userId.lipb.lipbid).to.equal('random-cookie-identifier'); + }); + }); + utils.setCookie('_li_pbid', '', EXPIRED_COOKIE_DATE); + done(); + }, {adUnits}); + }); + it('test hook from id5id cookies when refresh needed', function(done) { // simulate existing browser local storage values utils.setCookie('id5id', JSON.stringify({'ID5ID': 'testid5id'}), (new Date(Date.now() + 5000).toUTCString())); @@ -730,6 +774,48 @@ describe('User ID', function() { }, {adUnits}); }); + it('test hook from liveIntentId html5', function(done) { + // simulate existing browser local storage values + localStorage.setItem('_li_pbid', JSON.stringify({'unifiedId': 'random-ls-identifier', 'segments': ['123']})); + localStorage.setItem('_li_pbid_exp', ''); + + setSubmoduleRegistry([liveIntentIdSubmodule]); + init(config); + config.setConfig(getConfigMock(['liveIntentId', '_li_pbid', 'html5'])); + requestBidsHook(function() { + adUnits.forEach(unit => { + unit.bids.forEach(bid => { + expect(bid).to.have.deep.nested.property('userId.lipb'); + expect(bid.userId.lipb.lipbid).to.equal('random-ls-identifier'); + expect(bid.userId.lipb.segments).to.include('123'); + }); + }); + localStorage.removeItem('_li_pbid'); + localStorage.removeItem('_li_pbid_exp'); + done(); + }, {adUnits}); + }); + + it('test hook from liveIntentId cookie', function(done) { + utils.setCookie('_li_pbid', JSON.stringify({'unifiedId': 'random-cookie-identifier', 'segments': ['123']}), (new Date(Date.now() + 100000).toUTCString())); + + setSubmoduleRegistry([liveIntentIdSubmodule]); + init(config); + config.setConfig(getConfigMock(['liveIntentId', '_li_pbid', 'cookie'])); + + requestBidsHook(function() { + adUnits.forEach(unit => { + unit.bids.forEach(bid => { + expect(bid).to.have.deep.nested.property('userId.lipb'); + expect(bid.userId.lipb.lipbid).to.equal('random-cookie-identifier'); + expect(bid.userId.lipb.segments).to.include('123'); + }); + }); + utils.setCookie('_li_pbid', '', EXPIRED_COOKIE_DATE); + done(); + }, {adUnits}); + }); + it('test hook when pubCommonId, unifiedId and id5Id have data to pass', function(done) { utils.setCookie('pubcid', 'testpubcid', (new Date(Date.now() + 5000).toUTCString())); utils.setCookie('unifiedid', JSON.stringify({'TDID': 'testunifiedid'}), (new Date(Date.now() + 5000).toUTCString()));