From 2a05213fb73d88ac939c0b0dd4b5750649446372 Mon Sep 17 00:00:00 2001 From: pchrominski Date: Tue, 15 Feb 2022 18:23:35 +0100 Subject: [PATCH] Just Id Userid System: add new ID module (#7985) * just id user module * fix * fix * docs fix * basic mode * fixes * fix * . * . Co-authored-by: pchrominski --- modules/.submodules.json | 1 + modules/justIdSystem.js | 206 +++++++++++++++++++++++ modules/justIdSystem.md | 70 ++++++++ modules/userId/eids.js | 6 + modules/userId/eids.md | 8 + src/adloader.js | 3 +- test/spec/modules/justIdSystem_spec.js | 216 +++++++++++++++++++++++++ 7 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 modules/justIdSystem.js create mode 100644 modules/justIdSystem.md create mode 100644 test/spec/modules/justIdSystem_spec.js diff --git a/modules/.submodules.json b/modules/.submodules.json index f7c46b02403..2fb46377a64 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -18,6 +18,7 @@ "idxIdSystem", "imuIdSystem", "intentIqIdSystem", + "justIdSystem", "kinessoIdSystem", "liveIntentIdSystem", "lotamePanoramaIdSystem", diff --git a/modules/justIdSystem.js b/modules/justIdSystem.js new file mode 100644 index 00000000000..d30b9f3073f --- /dev/null +++ b/modules/justIdSystem.js @@ -0,0 +1,206 @@ +/** + * This module adds JustId to the User ID module + * The {@link module:modules/userId} module is required + * @module modules/justIdSystem + * @requires module:modules/userId + */ + +import * as utils from '../src/utils.js' +import { submodule } from '../src/hook.js' +import { loadExternalScript } from '../src/adloader.js' +import includes from 'core-js-pure/features/array/includes.js'; + +const MODULE_NAME = 'justId'; +const EXTERNAL_SCRIPT_MODULE_CODE = 'justtag'; +const LOG_PREFIX = 'User ID - JustId submodule: '; +const GVLID = 160; +const DEFAULT_PARTNER = 'pbjs-just-id-module'; +const DEFAULT_ATM_VAR_NAME = '__atm'; + +const MODE_BASIC = 'BASIC'; +const MODE_COMBINED = 'COMBINED'; +const DEFAULT_MODE = MODE_BASIC; + +export const EX_URL_REQUIRED = new Error(`params.url is required in ${MODE_COMBINED} mode`); +export const EX_INVALID_MODE = new Error(`Invalid params.mode. Allowed values: ${MODE_BASIC}, ${MODE_COMBINED}`); + +/** @type {Submodule} */ +export const justIdSubmodule = { + /** + * used to link submodule with config + * @type {string} + */ + name: MODULE_NAME, + /** + * required for the gdpr enforcement module + */ + gvlid: GVLID, + + /** + * decode the stored id value for passing to bid requests + * @function + * @param {{uid:string}} value + * @returns {{justId:string}} + */ + decode(value) { + utils.logInfo(LOG_PREFIX, 'decode', value); + const justId = value && value.uid; + return justId && {justId: justId}; + }, + + /** + * performs action to obtain id and return a value in the callback's response argument + * @function + * @param {SubmoduleConfig} config + * @param {ConsentData} consentData + * @param {(Object|undefined)} cacheIdObj + * @returns {IdResponse|undefined} + */ + getId(config, consentData, cacheIdObj) { + utils.logInfo(LOG_PREFIX, 'getId', config, consentData, cacheIdObj); + + var configWrapper + try { + configWrapper = new ConfigWrapper(config); + } catch (e) { + utils.logError(LOG_PREFIX, e); + } + + return configWrapper && { + callback: function(cbFun) { + try { + utils.logInfo(LOG_PREFIX, 'fetching uid...'); + + var uidProvider = configWrapper.isCombinedMode() + ? new CombinedUidProvider(configWrapper, consentData, cacheIdObj) + : new BasicUidProvider(configWrapper); + + uidProvider.getUid(justId => { + if (utils.isEmptyStr(justId)) { + utils.logError(LOG_PREFIX, 'empty uid!'); + cbFun(); + return; + } + cbFun({uid: justId}); + }, err => { + utils.logError(LOG_PREFIX, 'error during fetching', err); + cbFun(); + }); + } catch (e) { + utils.logError(LOG_PREFIX, 'Error during fetching...', e); + } + } + }; + } +}; + +export const ConfigWrapper = function(config) { + this.getConfig = function() { + return config; + } + + this.getMode = function() { + return (params().mode || DEFAULT_MODE).toUpperCase(); + } + + this.getPartner = function() { + return params().partner || DEFAULT_PARTNER; + } + + this.isCombinedMode = function() { + return this.getMode() === MODE_COMBINED; + } + + this.getAtmVarName = function() { + return params().atmVarName || DEFAULT_ATM_VAR_NAME; + } + + this.getUrl = function() { + const u = params().url; + const url = new URL(u); + url.searchParams.append('sourceId', this.getPartner()); + return url.toString(); + } + + function params() { + return config.params || {}; + } + + // validation + if (!includes([MODE_BASIC, MODE_COMBINED], this.getMode())) { + throw EX_INVALID_MODE; + } + + var url = params().url; + if (this.isCombinedMode() && (utils.isEmptyStr(url) || !utils.isStr(url))) { + throw EX_URL_REQUIRED; + } +} + +const CombinedUidProvider = function(configWrapper, consentData, cacheIdObj) { + const url = configWrapper.getUrl(); + + this.getUid = function(idCallback, errCallback) { + const scriptTag = loadExternalScript(url, EXTERNAL_SCRIPT_MODULE_CODE, () => { + utils.logInfo(LOG_PREFIX, 'script loaded', url); + + const eventDetails = { + detail: { + config: configWrapper.getConfig(), + consentData: consentData, + cacheIdObj: cacheIdObj + } + } + + scriptTag.dispatchEvent(new CustomEvent('prebidGetId', eventDetails)); + }) + + scriptTag.addEventListener('justIdReady', event => { + utils.logInfo(LOG_PREFIX, 'received justId', event); + idCallback(event.detail && event.detail.justId); + }); + + scriptTag.onerror = errCallback; + } +} + +const BasicUidProvider = function(configWrapper) { + const atmVarName = configWrapper.getAtmVarName(); + + this.getUid = function(idCallback, errCallback) { + var atm = getAtm(); + if (typeof atm !== 'function') { // it may be AsyncFunction, so we can't use utils.isFn + utils.logInfo(LOG_PREFIX, 'ATM function not found!', atmVarName, atm); + errCallback('ATM not found'); + return + } + + atm = function() { // stub is replaced after ATM is loaded so we must refer them directly by global variable + return getAtm().apply(this, arguments); + } + + atm('getReadyState', () => { + Promise.resolve(atm('getVersion')) // atm('getVersion') returns string || Promise + .then(atmVersion => { + utils.logInfo(LOG_PREFIX, 'ATM Version', atmVersion); + if (utils.isStr(atmVersion)) { // getVersion command was introduced in same ATM version as getUid command + atm('getUid', idCallback); + } else { + errCallback('ATM getUid not supported'); + } + }) + }); + } + + function getAtm() { + return jtUtils.getAtm(atmVarName); + } +} + +export const jtUtils = { + getAtm(atmVarName) { + return window[atmVarName]; + } +} + +submodule('userId', justIdSubmodule); diff --git a/modules/justIdSystem.md b/modules/justIdSystem.md new file mode 100644 index 00000000000..f58deef8010 --- /dev/null +++ b/modules/justIdSystem.md @@ -0,0 +1,70 @@ +## Just ID User ID Submodule + +For assistance setting up your module please contact us at [prebid@justtag.com](prebid@justtag.com). + +First, make sure to add the Just ID submodule to your Prebid.js package with: + +``` +gulp build --modules=userId,justIdSystem +``` + +### Modes + +- **BASIC** - in this mode we rely on Justtag library that already exists on publisher page. Typicaly that library expose global variable called `__atm` + +- **COMBINED** - Just ID generation process may differ between various cases depends on publishers. This mode combines our js library with prebid for ease of integration + +### Disclosure + +This module in `COMBINED` mode loads external JavaScript to generate optimal quality user ID. It is possible to retrieve user ID, without loading additional script by this module in `BASIC` mode. + +### Just ID Example + +ex. 1. Mode `COMBINED` + +``` +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'justId', + params: { + mode: 'COMBINED', + url: 'https://id.nsaudience.pl/getId.js', // required in COMBINED mode + partner: 'pbjs-just-id-module' // optional, may be required in some custom integrations with Justtag + } + }] + } +}); +``` + +ex. 2. Mode `BASIC` + +``` +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'justId', + params: { + mode: 'BASIC', // default + atmVarName: '__atm' // optional + } + }] + } +}); +``` + +### Prebid Params + +Individual params may be set for the Just ID Submodule. + +## Parameter Descriptions for the `userSync` Configuration Section +The below parameters apply only to the Just ID integration. + +| Param under usersync.userIds[] | Scope | Type | Description | Example | +| --- | --- | --- | --- | --- | +| name | Required | String | ID of the module - `'justId'` | `'justId'` | +| params | Optional | Object | Details for Just ID syncing. | | +| params.mode | Optional | String | Mode in which the module works. Available Modes: `'COMBINED'`, `'BASIC'`(default) | `'COMBINED'` | +| params.atmVarName | Optional | String | Name of global object property that point to Justtag ATM Library. Defaults to `'__atm'` | `'__atm'` | +| params.url | Optional | String | API Url, **required** in `COMBINED` mode | `'https://id.nsaudience.pl/getId.js'` | +| params.partner | Optional | String | This is the Justtag Partner Id which may be required in some custom integrations with Justtag | `'some-publisher'` | diff --git a/modules/userId/eids.js b/modules/userId/eids.js index 3ffe80afe42..e78cffdcbac 100644 --- a/modules/userId/eids.js +++ b/modules/userId/eids.js @@ -26,6 +26,12 @@ const USER_IDS_CONFIG = { atype: 1 }, + // justId + 'justId': { + source: 'justtag.com', + atype: 1 + }, + // pubCommonId 'pubcid': { source: 'pubcid.org', diff --git a/modules/userId/eids.md b/modules/userId/eids.md index 899e03b8dbf..4c516d5441c 100644 --- a/modules/userId/eids.md +++ b/modules/userId/eids.md @@ -37,6 +37,14 @@ userIdAsEids = [ }] }, + { + source: 'justtag.com', + uids: [{ + id: 'justId', + atype: 1 + }] + }, + { source: 'neustar.biz', uids: [{ diff --git a/src/adloader.js b/src/adloader.js index 4e043e362bf..480c855821c 100644 --- a/src/adloader.js +++ b/src/adloader.js @@ -9,7 +9,8 @@ const _approvedLoadExternalJSList = [ 'outstream', 'adagio', 'browsi', - 'brandmetrics' + 'brandmetrics', + 'justtag' ] /** diff --git a/test/spec/modules/justIdSystem_spec.js b/test/spec/modules/justIdSystem_spec.js new file mode 100644 index 00000000000..b6a8cd2d310 --- /dev/null +++ b/test/spec/modules/justIdSystem_spec.js @@ -0,0 +1,216 @@ +import { justIdSubmodule, ConfigWrapper, jtUtils, EX_URL_REQUIRED, EX_INVALID_MODE } from 'modules/justIdSystem.js'; +import { loadExternalScriptStub } from 'test/mocks/adloaderStub.js'; +import * as utils from 'src/utils.js'; + +const DEFAULT_PARTNER = 'pbjs-just-id-module'; + +const url = 'https://example.com/getId.js'; + +describe('JustIdSystem', function () { + describe('configWrapper', function() { + it('invalid mode', function() { + expect(() => new ConfigWrapper({ params: { mode: 'invalidmode' } })).to.throw(EX_INVALID_MODE); + }) + + it('url is required', function() { + expect(() => new ConfigWrapper(configModeCombined())).to.throw(EX_URL_REQUIRED); + }) + + it('defaultPartner', function() { + expect(new ConfigWrapper(configModeCombined(url)).getUrl()).to.eq(expectedUrl(url, DEFAULT_PARTNER)); + }) + + it('customPartner', function() { + const partner = 'abc'; + expect(new ConfigWrapper(configModeCombined(url, partner)).getUrl()).to.eq(expectedUrl(url, partner)); + }) + }); + + describe('decode', function() { + it('decode justId', function() { + const justId = 'aaa'; + expect(justIdSubmodule.decode({uid: justId})).to.deep.eq({justId: justId}); + }) + }); + + describe('getId basic', function() { + var atmMock = (cmd, param) => { + switch (cmd) { + case 'getReadyState': + param('ready') + return; + case 'getVersion': + return Promise.resolve('1.0'); + case 'getUid': + param('user123'); + } + } + + var currentAtm; + + var getAtmStub = sinon.stub(jtUtils, 'getAtm').callsFake(() => currentAtm); + + var logErrorStub; + + beforeEach(function() { + logErrorStub = sinon.spy(utils, 'logError'); + }); + + afterEach(function() { + logErrorStub.restore(); + }); + + it('all ok', function(done) { + currentAtm = atmMock; + const callbackSpy = sinon.stub(); + + callbackSpy.callsFake(idObj => { + try { + expect(idObj.uid).to.equal('user123'); + done(); + } catch (err) { + done(err); + } + }) + + const atmVarName = '__fakeAtm'; + + justIdSubmodule.getId({params: {atmVarName: atmVarName}}).callback(callbackSpy); + + expect(getAtmStub.lastCall.lastArg).to.equal(atmVarName); + }); + + it('unsuported version', function(done) { + currentAtm = (cmd, param) => { + switch (cmd) { + case 'getReadyState': + param('ready') + } + } + + const callbackSpy = sinon.stub(); + + callbackSpy.callsFake(idObj => { + try { + expect(logErrorStub.calledOnce).to.be.true; + expect(idObj).to.be.undefined + done(); + } catch (err) { + done(err); + } + }) + + justIdSubmodule.getId({}).callback(callbackSpy); + }); + + it('work with stub', function(done) { + var calls = []; + currentAtm = (cmd, param) => { + calls.push({cmd: cmd, param: param}); + } + + const callbackSpy = sinon.stub(); + + callbackSpy.callsFake(idObj => { + try { + expect(idObj.uid).to.equal('user123'); + done(); + } catch (err) { + done(err); + } + }) + + justIdSubmodule.getId({}).callback(callbackSpy); + + currentAtm = atmMock; + expect(calls.length).to.equal(1); + expect(calls[0].cmd).to.equal('getReadyState'); + calls[0].param('ready') + }); + }); + + describe('getId combined', function() { + const scriptTag = document.createElement('script'); + + const onPrebidGetId = sinon.stub().callsFake(event => { + var cacheIdObj = event.detail && event.detail.cacheIdObj; + var justId = (cacheIdObj && cacheIdObj.uid && cacheIdObj.uid + '-x') || 'user123'; + scriptTag.dispatchEvent(new CustomEvent('justIdReady', { detail: { justId: justId } })); + }); + + scriptTag.addEventListener('prebidGetId', onPrebidGetId) + + var scriptTagCallback; + + beforeEach(() => { + loadExternalScriptStub.callsFake((url, moduleCode, callback) => { + scriptTagCallback = callback; + return scriptTag; + }); + }) + + var logErrorStub; + + beforeEach(() => { + logErrorStub = sinon.spy(utils, 'logError'); + }); + + afterEach(() => { + logErrorStub.restore(); + }); + + it('url is required', function() { + expect(justIdSubmodule.getId(configModeCombined())).to.be.undefined; + expect(logErrorStub.calledOnce).to.be.true; + }); + + it('without cachedIdObj', function() { + const callbackSpy = sinon.spy(); + justIdSubmodule.getId(configModeCombined(url)).callback(callbackSpy); + + scriptTagCallback(); + + expect(callbackSpy.lastCall.lastArg.uid).to.equal('user123'); + }); + + it('with cachedIdObj', function() { + const callbackSpy = sinon.spy(); + + justIdSubmodule.getId(configModeCombined(url), undefined, { uid: 'userABC' }).callback(callbackSpy); + + scriptTagCallback(); + + expect(callbackSpy.lastCall.lastArg.uid).to.equal('userABC-x'); + }); + + it('check if getId arguments are passed to prebidGetId event', function() { + const callbackSpy = sinon.spy(); + + const a = configModeCombined(url); + const b = { y: 'y' } + const c = { z: 'z' } + + justIdSubmodule.getId(a, b, c).callback(callbackSpy); + + scriptTagCallback(); + + expect(onPrebidGetId.lastCall.lastArg.detail).to.deep.eq({ config: a, consentData: b, cacheIdObj: c }); + }); + }); +}); + +function expectedUrl(url, srcId) { + return `${url}?sourceId=${srcId}` +} + +function configModeCombined(url, partner) { + var conf = { + params: { + mode: 'COMBINED' + } + } + url && (conf.params.url = url); + partner && (conf.params.partner = partner); + + return conf; +}