prebid/Prebid.js

View on GitHub
modules/relevantdigitalBidAdapter.js

Summary

Maintainability
A
3 hrs
Test Coverage
import {registerBidder} from '../src/adapters/bidderFactory.js';
import {ortbConverter} from '../libraries/ortbConverter/converter.js'
import {BANNER, NATIVE, VIDEO} from '../src/mediaTypes.js';
import {config} from '../src/config.js';
import {pbsExtensions} from '../libraries/pbsExtensions/pbsExtensions.js'
import {deepSetValue, isEmpty, deepClone, shuffle, triggerPixel, deepAccess} from '../src/utils.js';

const BIDDER_CODE = 'relevantdigital';

/** Global settings per bidder-code for this adapter (which might be > 1 if using aliasing) */
let configByBidder = {};

/** Used by the tests */
export const resetBidderConfigs = () => {
  configByBidder = {};
};

/** Settings ber bidder-code. checkParams === true means that it can optionally be set in bid-params   */
const FIELDS = [
  { name: 'pbsHost', checkParams: true, required: true },
  { name: 'accountId', checkParams: true, required: true },
  { name: 'pbsBufferMs', checkParams: false, required: false, default: 250 },
  { name: 'useSourceBidderCode', checkParams: false, required: false, default: false },
];

const SYNC_HTML = 'https://cdn.relevant-digital.com/resources/load-cookie.html';
const MAX_SYNC_COUNT = 10; // Max server-side bidder to sync at once via the iframe

/** Get settings for a bidder-code via config and, if needed, bid parameters */
const getBidderConfig = (bids) => {
  const { bidder } = bids[0];
  const cfg = configByBidder[bidder] || {
    ...Object.fromEntries(FIELDS.filter((f) => 'default' in f).map((f) => [f.name, f.default])),
    syncedBidders: {}, // To keep track of S2S-bidders we already (started to) synced
  };
  if (cfg.complete) {
    return cfg; // Most common case, we already have the settings we need (and we won't re-read them)
  }
  configByBidder[bidder] = cfg;
  const bidderConfiguration = config.getConfig(bidder) || {};

  // Read settings set by setConfig({ [bidder]: { ... }}) and if not available - from bid params
  FIELDS.forEach(({ name, checkParams }) => {
    cfg[name] = bidderConfiguration[name] || cfg[name];
    if (!cfg[name] && checkParams) {
      bids.forEach((bid) => {
        cfg[name] = cfg[name] || bid.params?.[name];
      });
    }
  });
  cfg.complete = FIELDS.every((field) => !field.required || cfg[field.name]);
  if (cfg.complete) {
    cfg.pbsHost = cfg.pbsHost.trim().replace('http://', 'https://');
    if (cfg.pbsHost.indexOf('https://') < 0) {
      cfg.pbsHost = `https://${cfg.pbsHost}`;
    }
  }
  return cfg;
}

const converter = ortbConverter({
  context: {
    netRevenue: true,
    ttl: 300
  },
  processors: pbsExtensions,
  imp(buildImp, bidRequest, context) {
    // Set stored request id from placementId
    const imp = buildImp(bidRequest, context);
    const { placementId } = bidRequest.params;
    deepSetValue(imp, 'ext.prebid.storedrequest.id', placementId);
    delete imp.ext.prebid.bidder;
    return imp;
  },
  overrides: {
    bidResponse: {
      bidderCode(orig, bidResponse, bid, { bidRequest }) {
        const { bidder, params = {} } = bidRequest || {};
        let useSourceBidderCode = configByBidder[bidder]?.useSourceBidderCode;
        if ('useSourceBidderCode' in params) {
          useSourceBidderCode = params.useSourceBidderCode;
        }
        // Only use the orignal function when useSourceBidderCode is true, else our own bidder code will be used
        if (useSourceBidderCode) {
          orig.apply(this, [...arguments].slice(1));
        }
      },
    },
  }
});

export const spec = {
  code: BIDDER_CODE,
  gvlid: 1100,
  supportedMediaTypes: [BANNER, VIDEO, NATIVE],

  /** We need both params.placementId + a complete configuration (pbsHost + accountId) to continue */
  isBidRequestValid: (bid) => bid.params?.placementId && getBidderConfig([bid]).complete,

  /** Trigger impression-pixel */
  onBidWon(bid) {
    if (bid.pbsWurl) {
      triggerPixel(bid.pbsWurl)
    }
    if (bid.burl) {
      triggerPixel(bid.burl)
    }
  },

  /** Build BidRequest for PBS */
  buildRequests(bidRequests, bidderRequest) {
    const { bidder } = bidRequests[0];
    const cfg = getBidderConfig(bidRequests);
    const data = converter.toORTB({bidRequests, bidderRequest});

    /** Set tmax, in general this will be timeout - pbsBufferMs */
    const pbjsTimeout = bidderRequest.timeout || 1000;
    data.tmax = Math.min(Math.max(pbjsTimeout - cfg.pbsBufferMs, cfg.pbsBufferMs), pbjsTimeout);

    delete data.ext?.prebid?.aliases; // We don't need/want to send aliases to PBS
    deepSetValue(data, 'ext.relevant', {
      ...data.ext?.relevant,
      adapter: true, // For internal analytics
    });
    deepSetValue(data, 'ext.prebid.storedrequest.id', cfg.accountId);
    data.ext.prebid.passthrough = {
      ...data.ext.prebid.passthrough,
      relevant: { bidder }, // to find config for the right bidder-code in interpretResponse / getUserSyncs
    };
    return [{
      method: 'POST',
      url: `${cfg.pbsHost}/openrtb2/auction`,
      data
    }];
  },

  /** Read BidResponse from PBS and make necessary adjustments to not make it appear to come from unknown bidders */
  interpretResponse(response, request) {
    const resp = deepClone(response.body);
    const { bidder } = request.data.ext.prebid.passthrough.relevant;

    // Modify response times / errors for actual PBS bidders into a single value
    const MODIFIERS = {
      responsetimemillis: (values) => Math.max(...values),
      errors: (values) => [].concat(...values),
    };
    Object.entries(MODIFIERS).forEach(([field, combineFn]) => {
      const obj = resp.ext?.[field];
      if (!isEmpty(obj)) {
        resp.ext[field] = {[bidder]: combineFn(Object.values(obj))};
      }
    });

    const bids = converter.fromORTB({response: resp, request: request.data}).bids;
    return bids;
  },

  /** Do syncing, but avoid running the sync > 1 time for S2S bidders */
  getUserSyncs(syncOptions, serverResponses, gdprConsent, uspConsent) {
    if (!syncOptions.iframeEnabled && !syncOptions.pixelEnabled) {
      return [];
    }
    const syncs = [];
    serverResponses.forEach(({ body }) => {
      const { pbsHost, syncedBidders } = configByBidder[body.ext.prebid.passthrough.relevant.bidder] || {};
      if (!pbsHost) {
        return;
      }
      const { gdprApplies, consentString } = gdprConsent || {};
      let bidders = Object.keys(body.ext?.responsetimemillis || {});
      bidders = bidders.reduce((acc, curr) => {
        if (!syncedBidders[curr]) {
          acc.push(curr);
          syncedBidders[curr] = true;
        }
        return acc;
      }, []);
      bidders = shuffle(bidders).slice(0, MAX_SYNC_COUNT); // Shuffle to not always leave out the same bidders
      if (!bidders.length) {
        return; // All bidders already synced
      }
      if (syncOptions.iframeEnabled) {
        const params = {
          endpoint: `${pbsHost}/cookie_sync`,
          max_sync_count: bidders.length,
          gdpr: gdprApplies ? 1 : 0,
          gdpr_consent: consentString,
          us_privacy: uspConsent,
          bidders: bidders.join(','),
        };
        const qs = Object.entries(params)
          .filter(([k, v]) => ![null, undefined, ''].includes(v))
          .map(([k, v]) => `${k}=${encodeURIComponent(v.toString())}`)
          .join('&');
        syncs.push({ type: 'iframe', url: `${SYNC_HTML}?${qs}` });
      } else { // Else, try to pixel-sync (for future-compatibility)
        const pixels = deepAccess(body, `ext.relevant.sync`, []).filter(({ type }) => type === 'redirect');
        syncs.push(...pixels.map(({ url }) => ({ type: 'image', url })));
      }
    });
    return syncs;
  },
};

registerBidder(spec);