Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 119 additions & 45 deletions modules/yieldmoBidAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isNumber,
isStr,
logError,
logWarn,
parseQueryStringParameters,
parseUrl
} from '../src/utils.js';
Expand Down Expand Up @@ -59,7 +60,7 @@ export const spec = {
*/
isBidRequestValid: function (bid) {
return !!(bid && bid.adUnitCode && bid.bidId && (hasBannerMediaType(bid) || hasVideoMediaType(bid)) &&
validateVideoParams(bid));
validateVideoParams(bid) && validateBlocklistParams(bid));
},

/**
Expand Down Expand Up @@ -151,6 +152,18 @@ export const spec = {
if (eids.length) {
serverRequest.eids = JSON.stringify(eids);
};

// Blocklists (request-level): merge ortb2 + params, send as comma-delimited
// params per the ad server's prebid-js endpoint (AS-5349). Omitted when empty.
const bcat = getBlocklist(bidderRequest, bannerBidRequests[0], 'bcat');
if (bcat.length) {
serverRequest.bcat = bcat.join(',');
}
const badv = getBlocklist(bidderRequest, bannerBidRequests[0], 'badv');
if (badv.length) {
serverRequest.badv = badv.join(',');
}

// check if url exceeded max length
const fullUrl = `${bannerUrl}?${parseQueryStringParameters(serverRequest)}`;
let extraCharacters = fullUrl.length - MAX_BANNER_REQUEST_URL_LENGTH;
Expand Down Expand Up @@ -398,6 +411,40 @@ function getId(request, idType) {
return (typeof deepAccess(request, 'userId') === 'object') ? request.userId[idType] : undefined;
}

/**
* Resolve a request-level blocklist field (bcat/badv) from its two sources — the
* standardized ORTB global (`bidderRequest.ortb2.<field>`) and the Yieldmo-specific
* param (`bid.params.<field>`) — into a single deduped array of strings. Neither
* source is allowed to silently win (union, not precedence). Invalid values are
* ignored and logged rather than dropping the bid: a source that isn't an array, and
* any non-string/empty element, are filtered out (with a warning) so a
* misconfiguration is surfaced without losing the impression.
* @param {BidderRequest} bidderRequest bidder request (source of ortb2.<field>)
* @param {BidRequest} bid bid request (source of params.<field>)
* @param {string} field blocklist field name — 'bcat' or 'badv'
* @return {string[]} deduped, trimmed, non-empty string entries (possibly empty)
*/
function getBlocklist(bidderRequest, bid, field) {
const normalize = (value, source) => {
if (value === undefined || value === null) {
return [];
}
if (!isArray(value)) {
logWarn(`yieldmo: ignoring ${source} blocklist value; expected an array of strings, got ${JSON.stringify(value)}`);
return [];
}
const dropped = value.filter(item => !isStr(item) || !item.trim());
if (dropped.length) {
logWarn(`yieldmo: ignoring invalid ${source} blocklist entries (expected non-empty strings): ${JSON.stringify(dropped)}`);
}
return value.filter(item => isStr(item) && item.trim()).map(item => item.trim());
};
return [...new Set([
...normalize(deepAccess(bidderRequest, `ortb2.${field}`), 'ortb2'),
...normalize(deepAccess(bid, `params.${field}`), 'params'),
])];
}

/**
* @param {BidRequest[]} bidRequests bid request object
* @param {BidderRequest} bidderRequest bidder request object
Expand All @@ -412,8 +459,8 @@ function openRtbRequest(bidRequests, bidderRequest) {
imp: bidRequests.map(bidRequest => openRtbImpression(bidRequest)),
site: openRtbSite(bidRequests[0], bidderRequest),
device: deepAccess(bidderRequest, 'ortb2.device'),
badv: bidRequests[0].params.badv || [],
bcat: deepAccess(bidderRequest, 'bcat') || bidRequests[0].params.bcat || [],
badv: getBlocklist(bidderRequest, bidRequests[0], 'badv'),
bcat: getBlocklist(bidderRequest, bidRequests[0], 'bcat'),
ext: {
prebid: '$prebid.version$',
},
Expand Down Expand Up @@ -602,6 +649,54 @@ function populateOpenRtbGdpr(openRtbRequest, bidderRequest) {
}
}

const isDefined = val => typeof val !== 'undefined';

const paramRequired = (paramStr, value, conditionStr) => {
let error = `"${paramStr}" is required`;
if (conditionStr) {
error += ' when ' + conditionStr;
}
throw new Error(error);
};

const paramInvalid = (paramStr, value, expectedStr) => {
expectedStr = expectedStr ? ', expected: ' + expectedStr : '';
value = JSON.stringify(value);
throw new Error(`"${paramStr}"=${value} is invalid${expectedStr}`);
};

/**
* Build a field validator bound to a bid. `video.*` paths are checked against both
* `params.video.*` and `mediaTypes.video.*`; any other path is read directly.
* The error callback (paramRequired/paramInvalid) throws, so callers wrap in try/catch.
* @param {BidRequest} bid bid request
* @return {(fieldPath: string, validateCb: Function, errorCb: Function, errorCbParam?: string) => *}
*/
const createParamValidator = (bid) => (fieldPath, validateCb, errorCb, errorCbParam) => {
if (fieldPath.indexOf('video') === 0) {
const valueFieldPath = 'params.' + fieldPath;
const mediaFieldPath = 'mediaTypes.' + fieldPath;
const valueParams = deepAccess(bid, valueFieldPath);
const mediaTypesParams = deepAccess(bid, mediaFieldPath);
const hasValidValueParams = validateCb(valueParams);
const hasValidMediaTypesParams = validateCb(mediaTypesParams);

if (hasValidValueParams) return valueParams;
else if (hasValidMediaTypesParams) return hasValidMediaTypesParams;
else {
if (!hasValidValueParams) errorCb(valueFieldPath, valueParams, errorCbParam);
else if (!hasValidMediaTypesParams) errorCb(mediaFieldPath, mediaTypesParams, errorCbParam);
}
return valueParams || mediaTypesParams;
} else {
const value = deepAccess(bid, fieldPath);
if (!validateCb(value)) {
errorCb(fieldPath, value, errorCbParam);
}
return value;
}
};

/**
* Determines whether or not the given video bid request is valid. If it's not a video bid, returns true.
* @param {object} bid bid to validate
Expand All @@ -611,46 +706,7 @@ function validateVideoParams(bid) {
if (!hasVideoMediaType(bid)) {
return true;
}

const paramRequired = (paramStr, value, conditionStr) => {
let error = `"${paramStr}" is required`;
if (conditionStr) {
error += ' when ' + conditionStr;
}
throw new Error(error);
};

const paramInvalid = (paramStr, value, expectedStr) => {
expectedStr = expectedStr ? ', expected: ' + expectedStr : '';
value = JSON.stringify(value);
throw new Error(`"${paramStr}"=${value} is invalid${expectedStr}`);
};

const isDefined = val => typeof val !== 'undefined';
const validate = (fieldPath, validateCb, errorCb, errorCbParam) => {
if (fieldPath.indexOf('video') === 0) {
const valueFieldPath = 'params.' + fieldPath;
const mediaFieldPath = 'mediaTypes.' + fieldPath;
const valueParams = deepAccess(bid, valueFieldPath);
const mediaTypesParams = deepAccess(bid, mediaFieldPath);
const hasValidValueParams = validateCb(valueParams);
const hasValidMediaTypesParams = validateCb(mediaTypesParams);

if (hasValidValueParams) return valueParams;
else if (hasValidMediaTypesParams) return hasValidMediaTypesParams;
else {
if (!hasValidValueParams) errorCb(valueFieldPath, valueParams, errorCbParam);
else if (!hasValidMediaTypesParams) errorCb(mediaFieldPath, mediaTypesParams, errorCbParam);
}
return valueParams || mediaTypesParams;
} else {
const value = deepAccess(bid, fieldPath);
if (!validateCb(value)) {
errorCb(fieldPath, value, errorCbParam);
}
return value;
}
};
const validate = createParamValidator(bid);

try {
validate('video.context', val => !isEmpty(val), paramRequired);
Expand Down Expand Up @@ -680,10 +736,28 @@ function validateVideoParams(bid) {
validate('video.skippable', val => !isDefined(val) || isBoolean(val), paramInvalid);
validate('video.skipafter', val => !isDefined(val) || isNumber(val), paramInvalid);
validate('video.pos', val => !isDefined(val) || isNumber(val), paramInvalid);
validate('params.badv', val => !isDefined(val) || isArray(val), paramInvalid,
'array of strings, ex: ["ford.com","pepsi.com"]');
return true;
} catch (e) {
logError(e.message);
return false;
}
}

/**
* Validate the publisher-set blocklist params (`bcat`/`badv`) for all media types
* (banner and video). A missing value is allowed; a value that is present but is
* not an array is rejected (drops the bid) — the agreed middle ground between
* dropping nothing and dropping over an absent optional field. See FS-12403.
* @param {BidRequest} bid bid request
* @return {boolean} true if valid (or absent), false if present but malformed
*/
function validateBlocklistParams(bid) {
const validate = createParamValidator(bid);
try {
validate('params.bcat', val => !isDefined(val) || isArray(val), paramInvalid,
'array of strings, ex: ["IAB1-5","IAB1-6"]');
validate('params.badv', val => !isDefined(val) || isArray(val), paramInvalid,
'array of strings, ex: ["ford.com","pepsi.com"]');
return true;
} catch (e) {
logError(e.message);
Expand Down
25 changes: 23 additions & 2 deletions modules/yieldmoBidAdapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ var adUnits = [{ // Banner adUnit
params: {
placementId: '1779781193098233305', // string with at most 19 characters (may include numbers only)
bidFloor: .28, // optional param
lr_env: '***' // Optional. Live Ramp ATS envelope
lr_env: '***', // Optional. Live Ramp ATS envelope
bcat: ['IAB1-5', 'IAB1-6'], // optional, array of blocked IAB content categories (strings)
badv: ['ford.com', 'pepsi.com'] // optional, array of blocked advertiser domains (strings)
}
}]
}];
Expand Down Expand Up @@ -66,7 +68,9 @@ var adUnits = [{ // Video adUnit
skippable: true, // optional, boolean
skipafter: 10 // optional, integer
},
lr_env: '***' // Optional. Live Ramp ATS envelope
lr_env: '***', // Optional. Live Ramp ATS envelope
bcat: ['IAB1-5', 'IAB1-6'], // optional, array of blocked IAB content categories (strings)
badv: ['ford.com', 'pepsi.com'] // optional, array of blocked advertiser domains (strings)
}
}]
}];
Expand Down Expand Up @@ -104,3 +108,20 @@ Please also note, that we support the following OpenRTB params:
'mimes', 'startdelay', 'placement', 'startdelay', 'skipafter', 'protocols', 'api',
'playbackmethod', 'maxduration', 'minduration', 'pos', 'skip', 'skippable'.
They can be specified in `mediaTypes.video` or in `bids[].params.video`.

# Blocklists (bcat / badv)

`bcat` (blocked IAB content categories) and `badv` (blocked advertiser domains) are
supported for **both banner and video**. Each is an optional array of strings and can
be supplied from either source:

* the standardized first-party-data global — `ortb2.bcat` / `ortb2.badv`, or
* the Yieldmo bid params — `bids[].params.bcat` / `bids[].params.badv`.

When both sources are present they are **merged** (union) and de-duplicated — neither
source overrides the other.

Validation: a **missing** `bcat`/`badv` is always allowed, but if `params.bcat` /
`params.badv` is **present and not an array** the bid is rejected (`isBidRequestValid`
returns false). Non-string / empty elements inside an otherwise-valid array, and a
malformed `ortb2` value, are dropped with a console warning rather than failing the bid.
105 changes: 105 additions & 0 deletions test/spec/modules/yieldmoBidAdapter_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,28 @@ describe('YieldmoAdapter', function () {
expect(spec.isBidRequestValid(getBidAndExclude('api'))).to.be.false;
});
});

describe('Blocklist params (bcat / badv):', function () {
it('allows a bid when bcat/badv are absent (missing is fine)', function () {
expect(spec.isBidRequestValid(mockBannerBid())).to.be.true;
expect(spec.isBidRequestValid(mockVideoBid())).to.be.true;
});

it('allows a bid when bcat/badv are arrays', function () {
expect(spec.isBidRequestValid(mockBannerBid({}, { bcat: ['IAB1-1'], badv: ['x.com'] }))).to.be.true;
expect(spec.isBidRequestValid(mockVideoBid({}, { bcat: ['IAB1-1'], badv: ['x.com'] }))).to.be.true;
});

it('drops a bid when bcat is present but not an array', function () {
expect(spec.isBidRequestValid(mockBannerBid({}, { bcat: 'IAB1-1' }))).to.be.false;
expect(spec.isBidRequestValid(mockVideoBid({}, { bcat: 'IAB1-1' }))).to.be.false;
});

it('drops a bid when badv is present but not an array', function () {
expect(spec.isBidRequestValid(mockBannerBid({}, { badv: 'ford.com' }))).to.be.false;
expect(spec.isBidRequestValid(mockVideoBid({}, { badv: 'ford.com' }))).to.be.false;
});
});
});

describe('buildRequests', function () {
Expand Down Expand Up @@ -829,6 +851,89 @@ describe('YieldmoAdapter', function () {
expect(payload.device.language).to.exist;
});
});

describe('bcat / badv blocklists (FS-12403)', function () {
it('banner: sends merged bcat/badv as comma-delimited GET params', function () {
const bidderReq = mockBidderRequest({ ortb2: { bcat: ['IAB1-1'], badv: ['ortb.com'] } });
const data = buildAndGetData([mockBannerBid({}, { bcat: ['IAB2-2'], badv: ['param.com'] })], 0, bidderReq);
expect(data.bcat).to.equal('IAB1-1,IAB2-2');
expect(data.badv).to.equal('ortb.com,param.com');
});

it('banner: unions ortb2 + params (neither source silently wins)', function () {
const bidderReq = mockBidderRequest({ ortb2: { bcat: ['A'] } });
const data = buildAndGetData([mockBannerBid({}, { bcat: ['B'] })], 0, bidderReq);
expect(data.bcat.split(',')).to.have.members(['A', 'B']);
});

it('banner: dedupes values across the two sources, preserving order', function () {
const bidderReq = mockBidderRequest({ ortb2: { bcat: ['DUP', 'A'] } });
const data = buildAndGetData([mockBannerBid({}, { bcat: ['DUP', 'B'] })], 0, bidderReq);
expect(data.bcat).to.equal('DUP,A,B');
});

it('banner: reads from ortb2 alone', function () {
const bidderReq = mockBidderRequest({ ortb2: { bcat: ['IAB1-1'], badv: ['x.com'] } });
const data = buildAndGetData([mockBannerBid()], 0, bidderReq);
expect(data.bcat).to.equal('IAB1-1');
expect(data.badv).to.equal('x.com');
});

it('banner: reads from params alone', function () {
const data = buildAndGetData([mockBannerBid({}, { bcat: ['IAB1-1'], badv: ['x.com'] })], 0, mockBidderRequest());
expect(data.bcat).to.equal('IAB1-1');
expect(data.badv).to.equal('x.com');
});

it('banner: omits bcat/badv entirely when empty', function () {
const data = buildAndGetData([mockBannerBid()], 0, mockBidderRequest());
expect(data).to.not.have.property('bcat');
expect(data).to.not.have.property('badv');
});

it('video: sends merged bcat/badv as deduped arrays', function () {
const bidderReq = mockBidderRequest({ ortb2: { bcat: ['IAB1-1'], badv: ['ortb.com'] } }, [mockVideoBid()]);
const payload = buildAndGetData([mockVideoBid({}, { bcat: ['IAB2-2'], badv: ['param.com'] })], 0, bidderReq);
expect(payload.bcat).to.deep.equal(['IAB1-1', 'IAB2-2']);
expect(payload.badv).to.deep.equal(['ortb.com', 'param.com']);
});

it('video: reads ortb2.bcat (not the legacy bidderRequest.bcat path)', function () {
const bidderReq = mockBidderRequest({ bcat: ['WRONG'], ortb2: { bcat: ['RIGHT'] } }, [mockVideoBid()]);
const payload = buildAndGetData([mockVideoBid()], 0, bidderReq);
expect(payload.bcat).to.deep.equal(['RIGHT']);
});

it('video: defaults to empty arrays when no blocklists are set', function () {
const payload = buildAndGetData([mockVideoBid()], 0, mockBidderRequest({}, [mockVideoBid()]));
expect(payload.bcat).to.deep.equal([]);
expect(payload.badv).to.deep.equal([]);
});

describe('blocklist normalization in mergeBlocklist', function () {
let logWarnStub;
beforeEach(function () { logWarnStub = sinon.stub(utils, 'logWarn'); });
afterEach(function () { logWarnStub.restore(); });

it('ignores a non-array ortb2 source and warns (ortb2 is not bid-validated)', function () {
const bidderReq = mockBidderRequest({ ortb2: { bcat: 'IAB1-1' } });
const data = buildAndGetData([mockBannerBid()], 0, bidderReq);
expect(data).to.not.have.property('bcat');
expect(logWarnStub.called).to.be.true;
});

it('filters non-string / empty elements out of a valid array and warns', function () {
const data = buildAndGetData([mockBannerBid({}, { bcat: ['IAB1-1', '', 5, ' '] })], 0, mockBidderRequest());
expect(data.bcat).to.equal('IAB1-1');
expect(logWarnStub.called).to.be.true;
});

it('trims whitespace around entries', function () {
const data = buildAndGetData([mockBannerBid({}, { bcat: [' IAB1-1 '] })], 0, mockBidderRequest());
expect(data.bcat).to.equal('IAB1-1');
});
});
});
});

describe('interpretResponse', function () {
Expand Down
Loading