prebid/Prebid.js

View on GitHub
modules/sonobiBidAdapter.js

Summary

Maintainability
D
2 days
Test Coverage
import { registerBidder } from '../src/adapters/bidderFactory.js';
import { parseSizesInput, logError, generateUUID, isEmpty, deepAccess, logWarn, logMessage, isFn, isPlainObject } from '../src/utils.js';
import { BANNER, VIDEO } from '../src/mediaTypes.js';
import { config } from '../src/config.js';
import { Renderer } from '../src/Renderer.js';
import { userSync } from '../src/userSync.js';
import { bidderSettings } from '../src/bidderSettings.js';
import { getAllOrtbKeywords } from '../libraries/keywords/keywords.js';
import { getGptSlotInfoForAdUnitCode } from '../libraries/gptUtils/gptUtils.js';

/**
 * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest
 * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid
 */

const BIDDER_CODE = 'sonobi';
const STR_ENDPOINT = 'https://apex.go.sonobi.com/trinity.json';
const PAGEVIEW_ID = generateUUID();
const OUTSTREAM_REDNERER_URL = 'https://mtrx.go.sonobi.com/sbi_outstream_renderer.js';

export const spec = {
  code: BIDDER_CODE,
  gvlid: 104,
  supportedMediaTypes: [BANNER, VIDEO],
  /**
   * Determines whether or not the given bid request is valid.
   *
   * @param {BidRequest} bid - The bid params to validate.
   * @return {boolean} True if this is a valid bid, and false otherwise.
   */
  isBidRequestValid: (bid) => {
    if (!bid.params) {
      return false;
    }
    if (!bid.params.ad_unit && !bid.params.placement_id) {
      return false;
    }

    if (!deepAccess(bid, 'mediaTypes.banner') && !deepAccess(bid, 'mediaTypes.video')) {
      return false;
    }

    if (deepAccess(bid, 'mediaTypes.banner')) { // Sonobi does not support multi type bids, favor banner over video
      if (!deepAccess(bid, 'mediaTypes.banner.sizes') && !bid.params.sizes) {
        // sizes at the banner or params level is required.
        return false;
      }
    } else if (deepAccess(bid, 'mediaTypes.video')) {
      if (deepAccess(bid, 'mediaTypes.video.context') === 'outstream' && !deepAccess(bid, 'mediaTypes.video.playerSize')) {
        // playerSize is required for outstream video adUnits
        return false;
      }
      if (deepAccess(bid, 'mediaTypes.video.context') === 'instream' && !deepAccess(bid, 'mediaTypes.video.playerSize')) {
        // playerSize is required for instream adUnits.
        return false;
      }
    }

    return true;
  },

  /**
   * Make a server request from the list of BidRequests.
   *
   * @param {BidRequest[]} validBidRequests - an array of bids
   * @return {object} ServerRequest - Info describing the request to the server.
   */
  buildRequests: (validBidRequests, bidderRequest) => {
    const bids = validBidRequests.map(bid => {
      let slotIdentifier = _validateSlot(bid);
      if (/^[\/]?[\d]+[[\/].+[\/]?]?$/.test(slotIdentifier)) {
        slotIdentifier = slotIdentifier.charAt(0) === '/' ? slotIdentifier : '/' + slotIdentifier;
        return {
          [`${slotIdentifier}|${bid.bidId}`]: `${_validateSize(bid)}|${_validateFloor(bid)}${_validateGPID(bid)}${_validateMediaType(bid)}`
        }
      } else if (/^[0-9a-fA-F]{20}$/.test(slotIdentifier) && slotIdentifier.length === 20) {
        return {
          [bid.bidId]: `${slotIdentifier}|${_validateSize(bid)}|${_validateFloor(bid)}${_validateGPID(bid)}${_validateMediaType(bid)}`
        }
      } else {
        logError(`The ad unit code or Sonobi Placement id for slot ${bid.bidId} is invalid`);
      }
    });

    let data = {};
    bids.forEach((bid) => { Object.assign(data, bid); });

    const payload = {
      'key_maker': JSON.stringify(data),
      // TODO: is 'page' the right value here?
      'ref': bidderRequest.refererInfo.page,
      's': generateUUID(),
      'pv': PAGEVIEW_ID,
      'vp': _getPlatform(),
      'lib_name': 'prebid',
      'lib_v': '$prebid.version$',
      'us': 0,
      'iqid': bidderSettings.get(BIDDER_CODE, 'storageAllowed') ? JSON.stringify(loadOrCreateFirstPartyData()) : null,
    };

    const fpd = bidderRequest.ortb2;

    if (fpd) {
      delete fpd.experianRtidData; // Omit the experian data since we already pass this through a dedicated query param
      delete fpd.experianRtidKey
      payload.fpd = JSON.stringify(fpd);
    }

    if (config.getConfig('userSync') && config.getConfig('userSync').syncsPerBidder) {
      payload.us = config.getConfig('userSync').syncsPerBidder;
    }

    // use userSync's internal function to determine if we can drop an iframe sync pixel
    if (_iframeAllowed()) {
      payload.ius = 1;
    } else {
      payload.ius = 0;
    }

    if (deepAccess(validBidRequests[0], 'params.hfa')) {
      payload.hfa = deepAccess(validBidRequests[0], 'params.hfa');
    }

    if (validBidRequests[0].params.referrer) {
      payload.ref = validBidRequests[0].params.referrer;
    }

    // Apply GDPR parameters to request.
    if (bidderRequest && bidderRequest.gdprConsent) {
      payload.gdpr = bidderRequest.gdprConsent.gdprApplies ? 'true' : 'false';
      if (bidderRequest.gdprConsent.consentString) {
        payload.consent_string = bidderRequest.gdprConsent.consentString;
      }
    }

    if (validBidRequests[0].schain) {
      payload.schain = JSON.stringify(validBidRequests[0].schain);
    }

    const eids = deepAccess(validBidRequests[0], 'userIdAsEids');
    if (Array.isArray(eids) && eids.length > 0) {
      payload.eids = JSON.stringify(eids);
    }

    let keywords = getAllOrtbKeywords(bidderRequest.ortb2, ...validBidRequests.map(br => br.params.keywords)).join(',');

    if (keywords) {
      payload.kw = keywords;
    }

    if (bidderRequest && bidderRequest.uspConsent) {
      payload.us_privacy = bidderRequest.uspConsent;
    }

    if (config.getConfig('coppa') === true) {
      payload.coppa = 1;
    } else {
      payload.coppa = 0;
    }

    // If there is no key_maker data, then don't make the request.
    if (isEmpty(data)) {
      return null;
    }

    let url = STR_ENDPOINT;

    if (deepAccess(validBidRequests[0], 'params.bid_request_url')) {
      url = deepAccess(validBidRequests[0], 'params.bid_request_url');
    }

    return {
      method: 'GET',
      url: url,
      withCredentials: true,
      data: payload,
      bidderRequests: validBidRequests
    };
  },
  /**
   * Unpack the response from the server into a list of bids.
   *
   * @param {*} serverResponse A successful response from the server.
   * @param {*} bidderRequest - Info describing the request to the server.
   * @return {Bid[]} An array of bids which were nested inside the server.
   */
  interpretResponse: (serverResponse, bidderRequest) => {
    const bidResponse = serverResponse.body;
    const bidsReturned = [];
    const referrer = bidderRequest.data.ref;
    if (Object.keys(bidResponse.slots).length === 0) {
      return bidsReturned;
    }

    Object.keys(bidResponse.slots).forEach(slot => {
      const bid = bidResponse.slots[slot];
      const bidId = _getBidIdFromTrinityKey(slot);
      const bidRequest = _findBidderRequest(bidderRequest.bidderRequests, bidId);
      let mediaType = null;
      if (bid.sbi_ct === 'video') {
        mediaType = 'video';
        const context = deepAccess(bidRequest, 'mediaTypes.video.context');
        if (context === 'outstream') {
          mediaType = 'outstream';
        }
      }

      const createCreative = _creative(mediaType, referrer);
      if (bid.sbi_aid && bid.sbi_mouse && bid.sbi_size) {
        const [
          width = 1,
          height = 1
        ] = bid.sbi_size.split('x');
        let aDomains = [];
        if (bid.sbi_adomain) {
          aDomains = [bid.sbi_adomain];
        }
        const bids = {
          requestId: bidId,
          cpm: Number(bid.sbi_mouse),
          width: Number(width),
          height: Number(height),
          ad: createCreative(bidResponse.sbi_dc, bid.sbi_aid),
          ttl: 500,
          creativeId: bid.sbi_crid || bid.sbi_aid,
          aid: bid.sbi_aid,
          netRevenue: true,
          currency: 'USD',
          meta: {
            advertiserDomains: aDomains
          }
        };

        if (bid.sbi_dozer) {
          bids.dealId = bid.sbi_dozer;
        }

        if (mediaType === 'video') {
          bids.mediaType = 'video';
          bids.vastUrl = createCreative(bidResponse.sbi_dc, bid.sbi_aid);
          delete bids.ad;
          delete bids.width;
          delete bids.height;
        } else if (mediaType === 'outstream' && bidRequest) {
          delete bids.ad; // Some pubs expect bids.ad to be a vast xml structure, we have a vatUrl so lets delete this.
          bids.mediaType = 'video';
          bids.vastUrl = createCreative(bidResponse.sbi_dc, bid.sbi_aid);
          bids.renderer = newRenderer(bidRequest.adUnitCode, bids, deepAccess(
            bidRequest,
            'renderer.options'
          ));
          let videoSize = deepAccess(bidRequest, 'mediaTypes.video.playerSize');
          if (videoSize) {
            bids.width = videoSize[0];
            bids.height = videoSize[1];
          }
        }
        bidsReturned.push(bids);
      }
    });
    return bidsReturned;
  },
  /**
   * Register User Sync.
   */
  getUserSyncs: (syncOptions, serverResponses, gdprConsent, uspConsent) => {
    const syncs = [];
    try {
      if (syncOptions.pixelEnabled) {
        serverResponses[0].body.sbi_px.forEach(pixel => {
          syncs.push({
            type: pixel.type,
            url: pixel.url
          });
        });
      }
    } catch (e) { }
    return syncs;
  }
};

function _findBidderRequest(bidderRequests, bidId) {
  for (let i = 0; i < bidderRequests.length; i++) {
    if (bidderRequests[i].bidId === bidId) {
      return bidderRequests[i];
    }
  }
}

// This function takes all the possible sizes.
// returns string csv.
function _validateSize(bid) {
  let size = [];
  if (deepAccess(bid, 'mediaTypes.video.playerSize')) {
    size.push(deepAccess(bid, 'mediaTypes.video.playerSize'))
  }
  if (deepAccess(bid, 'mediaTypes.video.sizes')) {
    size.push(deepAccess(bid, 'mediaTypes.video.sizes'))
  }
  if (deepAccess(bid, 'params.sizes')) {
    size.push(deepAccess(bid, 'params.sizes'));
  }
  if (deepAccess(bid, 'mediaTypes.banner.sizes')) {
    size.push(deepAccess(bid, 'mediaTypes.banner.sizes'))
  }
  if (deepAccess(bid, 'sizes')) {
    size.push(deepAccess(bid, 'sizes'))
  }
  // Pass the 2d sizes array into parseSizeInput to flatten it into an array of x separated sizes.
  // Then throw it into Set to uniquify it.
  // Then spread it to an array again. Then join it into a csv of sizes.
  return [...new Set(parseSizesInput(...size))].join(',');
}

function _validateSlot(bid) {
  if (bid.params.ad_unit) {
    return bid.params.ad_unit;
  }
  return bid.params.placement_id;
}

function _validateFloor(bid) {
  const floor = getBidFloor(bid);

  if (floor) {
    return `f=${floor},`;
  }
  return '';
}

function _validateGPID(bid) {
  const gpid = deepAccess(bid, 'ortb2Imp.ext.gpid') || deepAccess(bid, 'ortb2Imp.ext.data.pbadslot') || deepAccess(getGptSlotInfoForAdUnitCode(bid.adUnitCode), 'gptSlot') || bid.params.ad_unit;

  if (gpid) {
    return `gpid=${gpid},`
  }
  return ''
}

function _validateMediaType(bidRequest) {
  let mediaType;
  if (deepAccess(bidRequest, 'mediaTypes.video')) {
    mediaType = 'video';
  } else if (deepAccess(bidRequest, 'mediaTypes.banner')) {
    mediaType = 'display';
  }

  let mediaTypeValidation = '';
  if (mediaType === 'video') {
    mediaTypeValidation = 'c=v,';
    if (deepAccess(bidRequest, 'mediaTypes.video.playbackmethod')) {
      mediaTypeValidation = `${mediaTypeValidation}pm=${deepAccess(bidRequest, 'mediaTypes.video.playbackmethod').join(':')},`;
    }
    if (deepAccess(bidRequest, 'mediaTypes.video.placement')) {
      let placement = deepAccess(bidRequest, 'mediaTypes.video.placement');
      mediaTypeValidation = `${mediaTypeValidation}p=${placement},`;
    }
    if (deepAccess(bidRequest, 'mediaTypes.video.plcmt')) {
      let plcmt = deepAccess(bidRequest, 'mediaTypes.video.plcmt');
      mediaTypeValidation = `${mediaTypeValidation}pl=${plcmt},`;
    }
  } else if (mediaType === 'display') {
    mediaTypeValidation = 'c=d,';
  }

  return mediaTypeValidation;
}

const _creative = (mediaType, referer) => (sbiDc, sbiAid) => {
  if (mediaType === 'video' || mediaType === 'outstream') {
    return _videoCreative(sbiDc, sbiAid, referer)
  }
  const src = `https://${sbiDc}apex.go.sonobi.com/sbi.js?aid=${sbiAid}&as=null&ref=${encodeURIComponent(referer)}`;
  return '<script type="text/javascript" src="' + src + '"></script>';
};

function _videoCreative(sbiDc, sbiAid, referer) {
  return `https://${sbiDc}apex.go.sonobi.com/vast.xml?vid=${sbiAid}&ref=${encodeURIComponent(referer)}`
}

function _getBidIdFromTrinityKey(key) {
  return key.split('|').slice(-1)[0]
}

/**
 * @param context - the window to determine the innerWidth from. This is purely for test purposes as it should always be the current window
 */
export const _isInbounds = (context = window) => (lowerBound = 0, upperBound = Number.MAX_SAFE_INTEGER) => context.innerWidth >= lowerBound && context.innerWidth < upperBound;

/**
 * @param context - the window to determine the innerWidth from. This is purely for test purposes as it should always be the current window
 */
export function _getPlatform(context = window) {
  const isInBounds = _isInbounds(context);
  const MOBILE_VIEWPORT = {
    lt: 768
  };
  const TABLET_VIEWPORT = {
    lt: 992,
    ge: 768
  };
  if (isInBounds(0, MOBILE_VIEWPORT.lt)) {
    return 'mobile'
  }
  if (isInBounds(TABLET_VIEWPORT.ge, TABLET_VIEWPORT.lt)) {
    return 'tablet'
  }
  return 'desktop';
}
/**
 * Check for local storage
 * Generate a UUID for the user if one does not exist in local storage
 * Store the UUID in local storage for future use
 * @return {object} firstPartyData - Data object containing first party information
 */
function loadOrCreateFirstPartyData() {
  var FIRST_PARTY_KEY = '_iiq_fdata';
  var tryParse = function (data) {
    try {
      return JSON.parse(data);
    } catch (err) {
      return null;
    }
  };
  var readData = function (key) {
    if (hasLocalStorage()) {
      // TODO FIX RULES VIOLATION
      // eslint-disable-next-line prebid/no-global
      return window.localStorage.getItem(key);
    }
    return null;
  };
  // TODO FIX RULES VIOLATION - USE STORAGE MANAGER
  var hasLocalStorage = function () {
    return false;
  };
  var generateGUID = function () {
    var d = new Date().getTime();
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
      var r = (d + Math.random() * 16) % 16 | 0;
      d = Math.floor(d / 16);
      return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
    });
  };
  var storeData = function (key, value) {
    try {
      if (hasLocalStorage()) {
        // TODO FIX RULES VIOLATION
        // eslint-disable-next-line prebid/no-global
        window.localStorage.setItem(key, value);
      }
    } catch (error) {
      return null;
    }
  };
  var firstPartyData = tryParse(readData(FIRST_PARTY_KEY));
  if (!firstPartyData || !firstPartyData.pcid) {
    var firstPartyId = generateGUID();
    firstPartyData = { pcid: firstPartyId, pcidDate: Date.now() };
  } else if (firstPartyData && !firstPartyData.pcidDate) {
    firstPartyData.pcidDate = Date.now();
  }
  storeData(FIRST_PARTY_KEY, JSON.stringify(firstPartyData));
  return firstPartyData;
};

function newRenderer(adUnitCode, bid, rendererOptions = {}) {
  const renderer = Renderer.install({
    id: bid.aid,
    url: OUTSTREAM_REDNERER_URL,
    config: rendererOptions,
    loaded: false,
    adUnitCode
  });

  try {
    renderer.setRender(outstreamRender);
  } catch (err) {
    logWarn('Prebid Error calling setRender on renderer', err);
  }

  renderer.setEventHandlers({
    impression: () => logMessage('Sonobi outstream video impression event'),
    loaded: () => logMessage('Sonobi outstream video loaded event'),
    ended: () => {
      logMessage('Sonobi outstream renderer video event');
      // document.querySelector(`#${adUnitCode}`).style.display = 'none';
    }
  });
  return renderer;
}

function outstreamRender(bid) {
  // push to render queue because SbiOutstreamRenderer may not be loaded yet
  bid.renderer.push(() => {
    const [
      width,
      height
    ] = bid.getSize().split('x');
    const renderer = new window.SbiOutstreamRenderer();
    renderer.init({
      vastUrl: bid.vastUrl,
      height,
      width,
    });
    renderer.setRootElement(bid.adUnitCode);
  });
}

function _iframeAllowed() {
  return userSync.canBidderRegisterSync('iframe', BIDDER_CODE);
}

function getBidFloor(bid) {
  if (!isFn(bid.getFloor)) {
    return (bid.params.floor) ? bid.params.floor : null;
  }

  let floor = bid.getFloor({
    currency: 'USD',
    mediaType: '*',
    size: '*'
  });

  if (isPlainObject(floor) && !isNaN(floor.floor) && floor.currency === 'USD') {
    return floor.floor;
  }
  return '';
}

registerBidder(spec);