Skip to content

Commit 1fc7176

Browse files
ym-aaronclaudepatmmccann
authored
Yieldmo Bid Adapter: support bcat/badv on banner, fix ortb2 path & merge (prebid#14989)
* Yieldmo Bid Adapter: support bcat/badv on banner, fix ortb2 path & merge - Read bcat/badv from ortb2.<field> (previously the always-undefined bidderRequest.bcat path) and merge (union + dedupe) with params.<field> instead of `||` precedence, so neither source is silently dropped. - Send bcat/badv on the banner (non-video) request as comma-delimited params, omitted when empty; the video (OpenRTB) request continues to send arrays. - Validate params.bcat/badv are arrays when provided, for both banner and video (drops the bid if malformed); a missing value is allowed. Non-array ortb2 values and non-string/empty elements are filtered with a warning rather than dropping the bid. - Add unit tests and document both params in yieldmoBidAdapter.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * pr review update * Yieldmo Bid Adapter: fix lint indent Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * trigger CI re-run Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Patrick McCann <patmmccann@gmail.com>
1 parent 54cc275 commit 1fc7176

3 files changed

Lines changed: 250 additions & 50 deletions

File tree

modules/yieldmoBidAdapter.js

Lines changed: 122 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
isNumber,
1212
isStr,
1313
logError,
14+
logWarn,
1415
parseQueryStringParameters,
1516
parseUrl
1617
} from '../src/utils.js';
@@ -57,7 +58,7 @@ export const spec = {
5758
*/
5859
isBidRequestValid: function (bid) {
5960
return !!(bid && bid.adUnitCode && bid.bidId && (hasBannerMediaType(bid) || hasVideoMediaType(bid)) &&
60-
validateVideoParams(bid));
61+
validateVideoParams(bid) && validateBlocklistParams(bid));
6162
},
6263

6364
/**
@@ -145,6 +146,18 @@ export const spec = {
145146
if (eids.length) {
146147
serverRequest.eids = JSON.stringify(eids);
147148
};
149+
150+
// Blocklists (request-level): merge ortb2 + params, send as comma-delimited
151+
// params per the ad server's prebid-js endpoint (AS-5349). Omitted when empty.
152+
const bcat = getBlocklist(bidderRequest, bannerBidRequests[0], 'bcat');
153+
if (bcat.length) {
154+
serverRequest.bcat = bcat.join(',');
155+
}
156+
const badv = getBlocklist(bidderRequest, bannerBidRequests[0], 'badv');
157+
if (badv.length) {
158+
serverRequest.badv = badv.join(',');
159+
}
160+
148161
// check if url exceeded max length
149162
const fullUrl = `${bannerUrl}?${parseQueryStringParameters(serverRequest)}`;
150163
let extraCharacters = fullUrl.length - MAX_BANNER_REQUEST_URL_LENGTH;
@@ -392,6 +405,40 @@ function getId(request, idType) {
392405
return (typeof deepAccess(request, 'userId') === 'object') ? request.userId[idType] : undefined;
393406
}
394407

408+
/**
409+
* Resolve a request-level blocklist field (bcat/badv) from its two sources — the
410+
* standardized ORTB global (`bidderRequest.ortb2.<field>`) and the Yieldmo-specific
411+
* param (`bid.params.<field>`) — into a single deduped array of strings. Neither
412+
* source is allowed to silently win (union, not precedence). Invalid values are
413+
* ignored and logged rather than dropping the bid: a source that isn't an array, and
414+
* any non-string/empty element, are filtered out (with a warning) so a
415+
* misconfiguration is surfaced without losing the impression.
416+
* @param {BidderRequest} bidderRequest bidder request (source of ortb2.<field>)
417+
* @param {BidRequest} bid bid request (source of params.<field>)
418+
* @param {string} field blocklist field name — 'bcat' or 'badv'
419+
* @return {string[]} deduped, trimmed, non-empty string entries (possibly empty)
420+
*/
421+
function getBlocklist(bidderRequest, bid, field) {
422+
const normalize = (value, source) => {
423+
if (value === undefined || value === null) {
424+
return [];
425+
}
426+
if (!isArray(value)) {
427+
logWarn(`yieldmo: ignoring ${source} blocklist value; expected an array of strings, got ${JSON.stringify(value)}`);
428+
return [];
429+
}
430+
const dropped = value.filter(item => !isStr(item) || !item.trim());
431+
if (dropped.length) {
432+
logWarn(`yieldmo: ignoring invalid ${source} blocklist entries (expected non-empty strings): ${JSON.stringify(dropped)}`);
433+
}
434+
return value.filter(item => isStr(item) && item.trim()).map(item => item.trim());
435+
};
436+
return [...new Set([
437+
...normalize(deepAccess(bidderRequest, `ortb2.${field}`), 'ortb2'),
438+
...normalize(deepAccess(bid, `params.${field}`), 'params'),
439+
])];
440+
}
441+
395442
/**
396443
* @param {BidRequest[]} bidRequests bid request object
397444
* @param {BidderRequest} bidderRequest bidder request object
@@ -406,8 +453,8 @@ function openRtbRequest(bidRequests, bidderRequest) {
406453
imp: bidRequests.map(bidRequest => openRtbImpression(bidRequest)),
407454
site: openRtbSite(bidRequests[0], bidderRequest),
408455
device: deepAccess(bidderRequest, 'ortb2.device'),
409-
badv: bidRequests[0].params.badv || [],
410-
bcat: deepAccess(bidderRequest, 'bcat') || bidRequests[0].params.bcat || [],
456+
badv: getBlocklist(bidderRequest, bidRequests[0], 'badv'),
457+
bcat: getBlocklist(bidderRequest, bidRequests[0], 'bcat'),
411458
ext: {
412459
prebid: '$prebid.version$',
413460
},
@@ -587,6 +634,54 @@ function populateOpenRtbGdpr(openRtbRequest, bidderRequest) {
587634
}
588635
}
589636

637+
const isDefined = val => typeof val !== 'undefined';
638+
639+
const paramRequired = (paramStr, value, conditionStr) => {
640+
let error = `"${paramStr}" is required`;
641+
if (conditionStr) {
642+
error += ' when ' + conditionStr;
643+
}
644+
throw new Error(error);
645+
};
646+
647+
const paramInvalid = (paramStr, value, expectedStr) => {
648+
expectedStr = expectedStr ? ', expected: ' + expectedStr : '';
649+
value = JSON.stringify(value);
650+
throw new Error(`"${paramStr}"=${value} is invalid${expectedStr}`);
651+
};
652+
653+
/**
654+
* Build a field validator bound to a bid. `video.*` paths are checked against both
655+
* `params.video.*` and `mediaTypes.video.*`; any other path is read directly.
656+
* The error callback (paramRequired/paramInvalid) throws, so callers wrap in try/catch.
657+
* @param {BidRequest} bid bid request
658+
* @return {(fieldPath: string, validateCb: Function, errorCb: Function, errorCbParam?: string) => *}
659+
*/
660+
const createParamValidator = (bid) => (fieldPath, validateCb, errorCb, errorCbParam) => {
661+
if (fieldPath.indexOf('video') === 0) {
662+
const valueFieldPath = 'params.' + fieldPath;
663+
const mediaFieldPath = 'mediaTypes.' + fieldPath;
664+
const valueParams = deepAccess(bid, valueFieldPath);
665+
const mediaTypesParams = deepAccess(bid, mediaFieldPath);
666+
const hasValidValueParams = validateCb(valueParams);
667+
const hasValidMediaTypesParams = validateCb(mediaTypesParams);
668+
669+
if (hasValidValueParams) return valueParams;
670+
else if (hasValidMediaTypesParams) return mediaTypesParams;
671+
else {
672+
if (!hasValidValueParams) errorCb(valueFieldPath, valueParams, errorCbParam);
673+
else if (!hasValidMediaTypesParams) errorCb(mediaFieldPath, mediaTypesParams, errorCbParam);
674+
}
675+
return valueParams || mediaTypesParams;
676+
} else {
677+
const value = deepAccess(bid, fieldPath);
678+
if (!validateCb(value)) {
679+
errorCb(fieldPath, value, errorCbParam);
680+
}
681+
return value;
682+
}
683+
};
684+
590685
/**
591686
* Determines whether or not the given video bid request is valid. If it's not a video bid, returns true.
592687
* @param {object} bid bid to validate
@@ -596,55 +691,16 @@ function validateVideoParams(bid) {
596691
if (!hasVideoMediaType(bid)) {
597692
return true;
598693
}
599-
600-
const paramRequired = (paramStr, value, conditionStr) => {
601-
let error = `"${paramStr}" is required`;
602-
if (conditionStr) {
603-
error += ' when ' + conditionStr;
604-
}
605-
throw new Error(error);
606-
};
607-
608-
const paramInvalid = (paramStr, value, expectedStr) => {
609-
expectedStr = expectedStr ? ', expected: ' + expectedStr : '';
610-
value = JSON.stringify(value);
611-
throw new Error(`"${paramStr}"=${value} is invalid${expectedStr}`);
612-
};
613-
614-
const isDefined = val => typeof val !== 'undefined';
615-
const validate = (fieldPath, validateCb, errorCb, errorCbParam) => {
616-
if (fieldPath.indexOf('video') === 0) {
617-
const valueFieldPath = 'params.' + fieldPath;
618-
const mediaFieldPath = 'mediaTypes.' + fieldPath;
619-
const valueParams = deepAccess(bid, valueFieldPath);
620-
const mediaTypesParams = deepAccess(bid, mediaFieldPath);
621-
const hasValidValueParams = validateCb(valueParams);
622-
const hasValidMediaTypesParams = validateCb(mediaTypesParams);
623-
624-
if (hasValidValueParams) return valueParams;
625-
else if (hasValidMediaTypesParams) return hasValidMediaTypesParams;
626-
else {
627-
if (!hasValidValueParams) errorCb(valueFieldPath, valueParams, errorCbParam);
628-
else if (!hasValidMediaTypesParams) errorCb(mediaFieldPath, mediaTypesParams, errorCbParam);
629-
}
630-
return valueParams || mediaTypesParams;
631-
} else {
632-
const value = deepAccess(bid, fieldPath);
633-
if (!validateCb(value)) {
634-
errorCb(fieldPath, value, errorCbParam);
635-
}
636-
return value;
637-
}
638-
};
694+
const validate = createParamValidator(bid);
639695

640696
try {
641697
validate('video.context', val => !isEmpty(val), paramRequired);
642698

643699
validate('params.placementId', val => !isEmpty(val), paramRequired);
644700

645-
validate('video.playerSize', val => isArrayOfNums(val, 2) ||
646-
(isArray(val) && val.every(v => isArrayOfNums(v, 2))),
647-
paramInvalid, 'array of 2 integers, ex: [640,480] or [[640,480]]');
701+
validate('video.playerSize',
702+
val => isArrayOfNums(val, 2) || (isArray(val) && val.every(v => isArrayOfNums(v, 2))),
703+
paramInvalid, 'array of 2 integers, ex: [640,480] or [[640,480]]');
648704

649705
validate('video.mimes', val => isDefined(val), paramRequired);
650706
validate('video.mimes', val => isArray(val) && val.every(v => isStr(v)), paramInvalid,
@@ -665,10 +721,28 @@ function validateVideoParams(bid) {
665721
validate('video.skippable', val => !isDefined(val) || isBoolean(val), paramInvalid);
666722
validate('video.skipafter', val => !isDefined(val) || isNumber(val), paramInvalid);
667723
validate('video.pos', val => !isDefined(val) || isNumber(val), paramInvalid);
668-
validate('params.badv', val => !isDefined(val) || isArray(val), paramInvalid,
669-
'array of strings, ex: ["ford.com","pepsi.com"]');
724+
return true;
725+
} catch (e) {
726+
logError(e.message);
727+
return false;
728+
}
729+
}
730+
731+
/**
732+
* Validate the publisher-set blocklist params (`bcat`/`badv`) for all media types
733+
* (banner and video). A missing value is allowed; a value that is present but is
734+
* not an array is rejected (drops the bid) — the agreed middle ground between
735+
* dropping nothing and dropping over an absent optional field. See FS-12403.
736+
* @param {BidRequest} bid bid request
737+
* @return {boolean} true if valid (or absent), false if present but malformed
738+
*/
739+
function validateBlocklistParams(bid) {
740+
const validate = createParamValidator(bid);
741+
try {
670742
validate('params.bcat', val => !isDefined(val) || isArray(val), paramInvalid,
671743
'array of strings, ex: ["IAB1-5","IAB1-6"]');
744+
validate('params.badv', val => !isDefined(val) || isArray(val), paramInvalid,
745+
'array of strings, ex: ["ford.com","pepsi.com"]');
672746
return true;
673747
} catch (e) {
674748
logError(e.message);

modules/yieldmoBidAdapter.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ var adUnits = [{ // Banner adUnit
3131
params: {
3232
placementId: '1779781193098233305', // string with at most 19 characters (may include numbers only)
3333
bidFloor: .28, // optional param
34-
lr_env: '***' // Optional. Live Ramp ATS envelope
34+
lr_env: '***', // Optional. Live Ramp ATS envelope
35+
bcat: ['IAB1-5', 'IAB1-6'], // optional, array of blocked IAB content categories (strings)
36+
badv: ['ford.com', 'pepsi.com'] // optional, array of blocked advertiser domains (strings)
3537
}
3638
}]
3739
}];
@@ -66,7 +68,9 @@ var adUnits = [{ // Video adUnit
6668
skippable: true, // optional, boolean
6769
skipafter: 10 // optional, integer
6870
},
69-
lr_env: '***' // Optional. Live Ramp ATS envelope
71+
lr_env: '***', // Optional. Live Ramp ATS envelope
72+
bcat: ['IAB1-5', 'IAB1-6'], // optional, array of blocked IAB content categories (strings)
73+
badv: ['ford.com', 'pepsi.com'] // optional, array of blocked advertiser domains (strings)
7074
}
7175
}]
7276
}];
@@ -104,3 +108,20 @@ Please also note, that we support the following OpenRTB params:
104108
'mimes', 'startdelay', 'placement', 'startdelay', 'skipafter', 'protocols', 'api',
105109
'playbackmethod', 'maxduration', 'minduration', 'pos', 'skip', 'skippable'.
106110
They can be specified in `mediaTypes.video` or in `bids[].params.video`.
111+
112+
# Blocklists (bcat / badv)
113+
114+
`bcat` (blocked IAB content categories) and `badv` (blocked advertiser domains) are
115+
supported for **both banner and video**. Each is an optional array of strings and can
116+
be supplied from either source:
117+
118+
* the standardized first-party-data global — `ortb2.bcat` / `ortb2.badv`, or
119+
* the Yieldmo bid params — `bids[].params.bcat` / `bids[].params.badv`.
120+
121+
When both sources are present they are **merged** (union) and de-duplicated — neither
122+
source overrides the other.
123+
124+
Validation: a **missing** `bcat`/`badv` is always allowed, but if `params.bcat` /
125+
`params.badv` is **present and not an array** the bid is rejected (`isBidRequestValid`
126+
returns false). Non-string / empty elements inside an otherwise-valid array, and a
127+
malformed `ortb2` value, are dropped with a console warning rather than failing the bid.

test/spec/modules/yieldmoBidAdapter_spec.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,28 @@ describe('YieldmoAdapter', function () {
148148
expect(spec.isBidRequestValid(getBidAndExclude('api'))).to.be.false;
149149
});
150150
});
151+
152+
describe('Blocklist params (bcat / badv):', function () {
153+
it('allows a bid when bcat/badv are absent (missing is fine)', function () {
154+
expect(spec.isBidRequestValid(mockBannerBid())).to.be.true;
155+
expect(spec.isBidRequestValid(mockVideoBid())).to.be.true;
156+
});
157+
158+
it('allows a bid when bcat/badv are arrays', function () {
159+
expect(spec.isBidRequestValid(mockBannerBid({}, { bcat: ['IAB1-1'], badv: ['x.com'] }))).to.be.true;
160+
expect(spec.isBidRequestValid(mockVideoBid({}, { bcat: ['IAB1-1'], badv: ['x.com'] }))).to.be.true;
161+
});
162+
163+
it('drops a bid when bcat is present but not an array', function () {
164+
expect(spec.isBidRequestValid(mockBannerBid({}, { bcat: 'IAB1-1' }))).to.be.false;
165+
expect(spec.isBidRequestValid(mockVideoBid({}, { bcat: 'IAB1-1' }))).to.be.false;
166+
});
167+
168+
it('drops a bid when badv is present but not an array', function () {
169+
expect(spec.isBidRequestValid(mockBannerBid({}, { badv: 'ford.com' }))).to.be.false;
170+
expect(spec.isBidRequestValid(mockVideoBid({}, { badv: 'ford.com' }))).to.be.false;
171+
});
172+
});
151173
});
152174

153175
describe('buildRequests', function () {
@@ -815,6 +837,89 @@ describe('YieldmoAdapter', function () {
815837
expect(payload.device.language).to.exist;
816838
});
817839
});
840+
841+
describe('bcat / badv blocklists (FS-12403)', function () {
842+
it('banner: sends merged bcat/badv as comma-delimited GET params', function () {
843+
const bidderReq = mockBidderRequest({ ortb2: { bcat: ['IAB1-1'], badv: ['ortb.com'] } });
844+
const data = buildAndGetData([mockBannerBid({}, { bcat: ['IAB2-2'], badv: ['param.com'] })], 0, bidderReq);
845+
expect(data.bcat).to.equal('IAB1-1,IAB2-2');
846+
expect(data.badv).to.equal('ortb.com,param.com');
847+
});
848+
849+
it('banner: unions ortb2 + params (neither source silently wins)', function () {
850+
const bidderReq = mockBidderRequest({ ortb2: { bcat: ['A'] } });
851+
const data = buildAndGetData([mockBannerBid({}, { bcat: ['B'] })], 0, bidderReq);
852+
expect(data.bcat.split(',')).to.have.members(['A', 'B']);
853+
});
854+
855+
it('banner: dedupes values across the two sources, preserving order', function () {
856+
const bidderReq = mockBidderRequest({ ortb2: { bcat: ['DUP', 'A'] } });
857+
const data = buildAndGetData([mockBannerBid({}, { bcat: ['DUP', 'B'] })], 0, bidderReq);
858+
expect(data.bcat).to.equal('DUP,A,B');
859+
});
860+
861+
it('banner: reads from ortb2 alone', function () {
862+
const bidderReq = mockBidderRequest({ ortb2: { bcat: ['IAB1-1'], badv: ['x.com'] } });
863+
const data = buildAndGetData([mockBannerBid()], 0, bidderReq);
864+
expect(data.bcat).to.equal('IAB1-1');
865+
expect(data.badv).to.equal('x.com');
866+
});
867+
868+
it('banner: reads from params alone', function () {
869+
const data = buildAndGetData([mockBannerBid({}, { bcat: ['IAB1-1'], badv: ['x.com'] })], 0, mockBidderRequest());
870+
expect(data.bcat).to.equal('IAB1-1');
871+
expect(data.badv).to.equal('x.com');
872+
});
873+
874+
it('banner: omits bcat/badv entirely when empty', function () {
875+
const data = buildAndGetData([mockBannerBid()], 0, mockBidderRequest());
876+
expect(data).to.not.have.property('bcat');
877+
expect(data).to.not.have.property('badv');
878+
});
879+
880+
it('video: sends merged bcat/badv as deduped arrays', function () {
881+
const bidderReq = mockBidderRequest({ ortb2: { bcat: ['IAB1-1'], badv: ['ortb.com'] } }, [mockVideoBid()]);
882+
const payload = buildAndGetData([mockVideoBid({}, { bcat: ['IAB2-2'], badv: ['param.com'] })], 0, bidderReq);
883+
expect(payload.bcat).to.deep.equal(['IAB1-1', 'IAB2-2']);
884+
expect(payload.badv).to.deep.equal(['ortb.com', 'param.com']);
885+
});
886+
887+
it('video: reads ortb2.bcat (not the legacy bidderRequest.bcat path)', function () {
888+
const bidderReq = mockBidderRequest({ bcat: ['WRONG'], ortb2: { bcat: ['RIGHT'] } }, [mockVideoBid()]);
889+
const payload = buildAndGetData([mockVideoBid()], 0, bidderReq);
890+
expect(payload.bcat).to.deep.equal(['RIGHT']);
891+
});
892+
893+
it('video: defaults to empty arrays when no blocklists are set', function () {
894+
const payload = buildAndGetData([mockVideoBid()], 0, mockBidderRequest({}, [mockVideoBid()]));
895+
expect(payload.bcat).to.deep.equal([]);
896+
expect(payload.badv).to.deep.equal([]);
897+
});
898+
899+
describe('blocklist normalization in mergeBlocklist', function () {
900+
let logWarnStub;
901+
beforeEach(function () { logWarnStub = sinon.stub(utils, 'logWarn'); });
902+
afterEach(function () { logWarnStub.restore(); });
903+
904+
it('ignores a non-array ortb2 source and warns (ortb2 is not bid-validated)', function () {
905+
const bidderReq = mockBidderRequest({ ortb2: { bcat: 'IAB1-1' } });
906+
const data = buildAndGetData([mockBannerBid()], 0, bidderReq);
907+
expect(data).to.not.have.property('bcat');
908+
expect(logWarnStub.called).to.be.true;
909+
});
910+
911+
it('filters non-string / empty elements out of a valid array and warns', function () {
912+
const data = buildAndGetData([mockBannerBid({}, { bcat: ['IAB1-1', '', 5, ' '] })], 0, mockBidderRequest());
913+
expect(data.bcat).to.equal('IAB1-1');
914+
expect(logWarnStub.called).to.be.true;
915+
});
916+
917+
it('trims whitespace around entries', function () {
918+
const data = buildAndGetData([mockBannerBid({}, { bcat: [' IAB1-1 '] })], 0, mockBidderRequest());
919+
expect(data.bcat).to.equal('IAB1-1');
920+
});
921+
});
922+
});
818923
});
819924

820925
describe('interpretResponse', function () {

0 commit comments

Comments
 (0)